getServerCmds() {
+ return serverCommands;
+ }
+
+ public boolean isZoomModeEnabled() {
+ return isPanZoomMode;
+ }
+ public void toggleZoomMode() {
+ this.isPanZoomMode = !this.isPanZoomMode;
+ }
+
+ public void rotateScreen() {
+ if (currentOrientation == Configuration.ORIENTATION_LANDSCAPE) {
+ currentOrientation = Configuration.ORIENTATION_PORTRAIT;
+ setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT);
+ } else {
+ currentOrientation = Configuration.ORIENTATION_LANDSCAPE;
+ setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE);
+ }
+ }
+
+ public void selectMouseMode(){
+ String[] strings = getResources().getStringArray(R.array.mouse_mode_names);
+ String[] items = Arrays.copyOf(strings,strings.length + 1);
+ items[items.length - 1] = getString(R.string.toggle_local_mouse_cursor);
+ new AlertDialog.Builder(this).setItems(items, (dialog, which) -> {
+ dialog.dismiss();
+ if(which == strings.length){
+ toggleMouseLocalCursor();
+ return;
+ }
+ applyMouseMode(which);
+ }).setTitle(getString(R.string.game_menu_select_mouse_mode)).create().show();
+ }
+
+ //本地鼠标光标切换
+ private void toggleMouseLocalCursor(){
+ if (!grabbedInput) {
+ inputCaptureProvider.enableCapture();
+ grabbedInput = true;
+ }
+ cursorVisible = !cursorVisible;
+ if (cursorVisible) {
+ inputCaptureProvider.showCursor();
+ } else {
+ inputCaptureProvider.hideCursor();
+ }
+ }
+
+ private void applyMouseMode(int mode) {
+ switch (mode) {
+ case 0: // Multi-touch
+ prefConfig.enableMultiTouchScreen = true;
+ prefConfig.touchscreenTrackpad = false;
+ break;
+ case 1: // Normal mouse
+ case 5: // Normal mouse with swapped buttons
+ prefConfig.enableMultiTouchScreen = false;
+ prefConfig.touchscreenTrackpad = false;
+ break;
+ case 2: // Trackpad (natural)
+ case 3: // Trackpad (gaming)
+ prefConfig.enableMultiTouchScreen = false;
+ prefConfig.touchscreenTrackpad = true;
+ break;
+ case 4: // Touch mouse disabled
+ break;
+ default:
+ break;
+ }
+
+ //Initialize touch contexts
+ for (int i = 0; i < touchContextMap.length; i++) {
+ if (touchContextMap[i] != null) touchContextMap[i].cancelTouch();
+ if (mode == 4) {
+ // Touch mouse disabled
+ touchContextMap[i] = null;
+ } else if (!prefConfig.touchscreenTrackpad) {
+ touchContextMap[i] = new AbsoluteTouchContext(conn, i, streamView, mode == 5);
+ } else if (mode == 3) {
+ touchContextMap[i] = new RelativeTouchContext(conn, i, REFERENCE_HORIZ_RES, REFERENCE_VERT_RES, streamView, prefConfig);
+ } else {
+ touchContextMap[i] = new TrackpadContext(conn, i);
+ }
+ }
+
+ // Always exit zoom mode if mouse mode has changed
+ isPanZoomMode = false;
+ }
+
+ public void toggleHUD(){
+ prefConfig.enablePerfOverlay = !prefConfig.enablePerfOverlay;
+ if(prefConfig.enablePerfOverlay){
+ performanceOverlayView.setVisibility(View.VISIBLE);
+ if(prefConfig.enablePerfOverlayLite){
+ performanceOverlayLite.setVisibility(View.VISIBLE);
+ }else{
+ performanceOverlayBig.setVisibility(View.VISIBLE);
+ }
+ return;
+ }
+ performanceOverlayView.setVisibility(View.GONE);
+ }
+
+ //切换触控灵敏度开关
+ public void switchTouchSensitivity(){
+ prefConfig.enableTouchSensitivity = !prefConfig.enableTouchSensitivity;
+ }
+
+ public void disconnect() {
+ if (prefConfig.smartClipboardSync) {
+ getClipboard(-1);
+ }
+ finish();
+ }
+
+ public void quit() {
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setTitle(R.string.game_dialog_title_quit_confirm);
+ builder.setMessage(R.string.game_dialog_message_quit_confirm);
+
+ builder.setPositiveButton(getString(R.string.yes), (dialog, which) -> {
+ quitOnStop = true;
+ dialog.dismiss();
+ finish();
+ });
+
+ builder.setNegativeButton(getString(R.string.no), (dialog, which) -> dialog.dismiss());
+
+ AlertDialog dialog = builder.create();
+ dialog.show();
+ }
+
+ @Override
+ public void showGameMenu(GameInputDevice device) {
+ if (gameMenuCallbacks != null) {
+ gameMenuCallbacks.showMenu(device);
+ }
+ }
+
+ public void hideGameMenu() {
+ if (gameMenuCallbacks != null) {
+ gameMenuCallbacks.hideMenu();
+ }
+ }
+
+ public SecondaryDisplayPresentation presentation;
+ public void showSecondScreen(){
+ DisplayManager displayManager = (DisplayManager) getSystemService(Context.DISPLAY_SERVICE);
+ Display[] displays = displayManager.getDisplays();
+ int mainDisplayId = Display.DEFAULT_DISPLAY;
+ int secondaryDisplayId = -1;
+ for (Display display : displays) {
+// LimeLog.info(display.toString());
+ if (display.getDisplayId() != mainDisplayId) {
+ secondaryDisplayId = display.getDisplayId();
+ break;
+ }
+ }
+ if (secondaryDisplayId != -1) {
+ Display secondaryDisplay = displayManager.getDisplay(secondaryDisplayId);
+ presentation = new SecondaryDisplayPresentation(this, secondaryDisplay);
+ presentation.show();
+ if(rootView!= null) {
+ ((ViewGroup)rootView).removeView(streamView); // <- fix
+ presentation.addView(streamView);
+ }
+ // Force mouse mode as trackpad during presentation as user won't see anything on device screen
+ applyMouseMode(2);
+ }
+ }
+
+
+ // 设置surfaceView的圆角 setSurfaceviewCorner(UiHelper.dpToPx(this,24));
+ private void setSurfaceviewCorner(final float radius) {
+
+ streamView.setOutlineProvider(new ViewOutlineProvider() {
+ @Override
+ public void getOutline(View view, Outline outline) {
+ Rect rect = new Rect();
+ view.getGlobalVisibleRect(rect);
+ int leftMargin = 0;
+ int topMargin = 0;
+ Rect selfRect = new Rect(leftMargin, topMargin, rect.right - rect.left - leftMargin, rect.bottom - rect.top - topMargin);
+ outline.setRoundRect(selfRect, radius);
+ }
+ });
+ streamView.setClipToOutline(true);
+ }
}
diff --git a/app/src/main/java/com/limelight/GameMenu.java b/app/src/main/java/com/limelight/GameMenu.java
new file mode 100755
index 0000000000..5e0941d347
--- /dev/null
+++ b/app/src/main/java/com/limelight/GameMenu.java
@@ -0,0 +1,400 @@
+package com.limelight;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.SharedPreferences;
+import android.os.Handler;
+import android.os.Looper;
+import android.text.TextUtils;
+import android.view.View;
+import android.view.ViewTreeObserver;
+import android.view.Window;
+import android.widget.ArrayAdapter;
+import android.widget.Toast;
+
+import com.limelight.binding.input.GameInputDevice;
+import com.limelight.binding.input.KeyboardTranslator;
+import com.limelight.nvstream.NvConnection;
+import com.limelight.nvstream.input.KeyboardPacket;
+import com.limelight.preferences.PreferenceConfiguration;
+import com.limelight.utils.KeyMapper;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Provide options for ongoing Game Stream.
+ *
+ * Shown on back action in game activity.
+ */
+public class GameMenu implements Game.GameMenuCallbacks {
+
+ private static final long TEST_GAME_FOCUS_DELAY = 10;
+ private static final long KEY_UP_DELAY = 25;
+
+ public static final String PREF_NAME = "specialPrefs"; // SharedPreferences的名称
+
+ public static final String KEY_NAME = "special_key"; // 要保存的键名称
+
+ public static class MenuOption {
+ private final String label;
+ private final boolean withGameFocus;
+ private final Runnable runnable;
+
+ public MenuOption(String label, boolean withGameFocus, Runnable runnable) {
+ this.label = label;
+ this.withGameFocus = withGameFocus;
+ this.runnable = runnable;
+ }
+
+ public MenuOption(String label, Runnable runnable) {
+ this(label, false, runnable);
+ }
+ }
+
+ private final Game game;
+ private final NvConnection conn;
+
+ private AlertDialog currentDialog;
+
+ public GameMenu(Game game, NvConnection conn) {
+ this.game = game;
+ this.conn = conn;
+ }
+
+ private String getString(int id) {
+ return game.getResources().getString(id);
+ }
+
+ public static byte getModifier(short key) {
+ switch (key) {
+ case KeyboardTranslator.VK_LSHIFT:
+ return KeyboardPacket.MODIFIER_SHIFT;
+ case KeyboardTranslator.VK_LCONTROL:
+ return KeyboardPacket.MODIFIER_CTRL;
+ case KeyboardTranslator.VK_LWIN:
+ return KeyboardPacket.MODIFIER_META;
+ case KeyboardTranslator.VK_LMENU:
+ return KeyboardPacket.MODIFIER_ALT;
+ default:
+ return 0;
+ }
+ }
+
+ private void sendKeys(short[] keys) {
+ final byte[] modifier = {(byte) 0};
+
+ for (short key : keys) {
+ conn.sendKeyboardInput(key, KeyboardPacket.KEY_DOWN, modifier[0], (byte) 0);
+
+ // Apply the modifier of the pressed key, e.g. CTRL first issues a CTRL event (without
+ // modifier) and then sends the following keys with the CTRL modifier applied
+ modifier[0] |= getModifier(key);
+ }
+
+ new Handler().postDelayed((() -> {
+ for (int pos = keys.length - 1; pos >= 0; pos--) {
+ short key = keys[pos];
+
+ // Remove the keys modifier before releasing the key
+ modifier[0] &= (byte) ~getModifier(key);
+
+ conn.sendKeyboardInput(key, KeyboardPacket.KEY_UP, modifier[0], (byte) 0);
+ }
+ }), KEY_UP_DELAY);
+ }
+
+ private void runWithGameFocus(Runnable runnable) {
+ // Ensure that the Game activity is still active (not finished)
+ if (game.isFinishing()) {
+ return;
+ }
+ // Check if the game window has focus again, if not try again after delay
+ if (!game.hasWindowFocus()) {
+ new Handler().postDelayed(() -> runWithGameFocus(runnable), TEST_GAME_FOCUS_DELAY);
+ return;
+ }
+ // Game Activity has focus, run runnable
+ runnable.run();
+ }
+
+ private void run(MenuOption option) {
+ if (option.runnable == null) {
+ return;
+ }
+
+ if (option.withGameFocus) {
+ runWithGameFocus(option.runnable);
+ } else {
+ option.runnable.run();
+ }
+ }
+
+ private void showMenuDialog(String title, MenuOption[] options) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(game);
+ builder.setTitle(title);
+
+ final ArrayAdapter actions =
+ new ArrayAdapter(game, android.R.layout.simple_list_item_1);
+
+ builder.setAdapter(actions, (dialog, which) -> {
+ String label = actions.getItem(which);
+ for (MenuOption option : options) {
+ if (!label.equals(option.label)) {
+ continue;
+ }
+
+ run(option);
+ break;
+ }
+ });
+
+ if (currentDialog != null) {
+ currentDialog.dismiss();
+ }
+ currentDialog = builder.show();
+
+ Window window = currentDialog.getWindow();
+
+ if (window != null) {
+ View decorView = window.getDecorView();
+ decorView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
+ @Override
+ public void onGlobalLayout() {
+
+ decorView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
+
+ new Handler(Looper.getMainLooper()).post(() -> {
+ for (MenuOption option : options) {
+ actions.add(option.label);
+ }
+ actions.notifyDataSetChanged();
+ });
+ }
+ });
+ }
+ }
+
+ private void showSpecialKeysMenu() {
+ List options = new ArrayList<>();
+
+ if(!PreferenceConfiguration.readPreferences(game).enableClearDefaultSpecial){
+ options.add(new MenuOption(getString(R.string.game_menu_send_keys_esc),
+ () -> sendKeys(new short[]{KeyboardTranslator.VK_ESCAPE})));
+
+ options.add(new MenuOption(getString(R.string.game_menu_send_keys_f11),
+ () -> sendKeys(new short[]{KeyboardTranslator.VK_F11})));
+
+ options.add(new MenuOption(getString(R.string.game_menu_send_keys_alt_f4),
+ () -> sendKeys(new short[]{KeyboardTranslator.VK_LMENU, KeyboardTranslator.VK_F4})));
+
+ options.add(new MenuOption(getString(R.string.game_menu_send_keys_alt_enter),
+ () -> sendKeys(new short[]{KeyboardTranslator.VK_LMENU, KeyboardTranslator.VK_RETURN})));
+
+ options.add(new MenuOption(getString(R.string.game_menu_send_keys_ctrl_v),
+ () -> sendKeys(new short[]{KeyboardTranslator.VK_LCONTROL, KeyboardTranslator.VK_V})));
+
+ options.add(new MenuOption(getString(R.string.game_menu_send_keys_win),
+ () -> sendKeys(new short[]{KeyboardTranslator.VK_LWIN})));
+
+ options.add(new MenuOption(getString(R.string.game_menu_send_keys_win_d),
+ () -> sendKeys(new short[]{KeyboardTranslator.VK_LWIN, KeyboardTranslator.VK_D})));
+
+ options.add(new MenuOption(getString(R.string.game_menu_send_keys_win_g),
+ () -> sendKeys(new short[]{KeyboardTranslator.VK_LWIN, KeyboardTranslator.VK_G})));
+
+ options.add(new MenuOption(getString(R.string.game_menu_send_keys_ctrl_alt_tab),
+ () -> sendKeys(new short[]{KeyboardTranslator.VK_LCONTROL, KeyboardTranslator.VK_LMENU, KeyboardTranslator.VK_TAB})));
+
+ options.add(new MenuOption(getString(R.string.game_menu_send_keys_shift_tab),
+ () -> sendKeys(new short[]{KeyboardTranslator.VK_LSHIFT, KeyboardTranslator.VK_TAB})));
+
+ options.add(new MenuOption(getString(R.string.game_menu_send_keys_win_shift_left),
+ () -> sendKeys(new short[]{KeyboardTranslator.VK_LWIN, KeyboardTranslator.VK_LSHIFT, KeyboardTranslator.VK_LEFT})));
+
+ options.add(new MenuOption(getString(R.string.game_menu_send_keys_ctrl_alt_shift_q),
+ () -> sendKeys(new short[]{KeyboardTranslator.VK_LCONTROL,KeyboardTranslator.VK_LMENU, KeyboardTranslator.VK_LSHIFT, KeyboardTranslator.VK_Q})));
+
+ options.add(new MenuOption(getString(R.string.game_menu_send_keys_ctrl_alt_shift_f1),
+ () -> sendKeys(new short[]{KeyboardTranslator.VK_LCONTROL,KeyboardTranslator.VK_LMENU, KeyboardTranslator.VK_LSHIFT, KeyboardTranslator.VK_F1})));
+
+ options.add(new MenuOption(getString(R.string.game_menu_send_keys_ctrl_alt_shift_f12),
+ () -> sendKeys(new short[]{KeyboardTranslator.VK_LCONTROL,KeyboardTranslator.VK_LMENU, KeyboardTranslator.VK_LSHIFT, KeyboardTranslator.VK_F12})));
+
+ options.add(new MenuOption(getString(R.string.game_menu_send_keys_alt_b),
+ () -> sendKeys(new short[]{KeyboardTranslator.VK_LWIN, KeyboardTranslator.VK_LMENU, KeyboardTranslator.VK_B})));
+ options.add(new MenuOption(getString(R.string.game_menu_send_keys_win_x_u_s), () -> {
+ sendKeys(new short[]{KeyboardTranslator.VK_LWIN, KeyboardTranslator.VK_X});
+ new Handler().postDelayed((() -> sendKeys(new short[]{KeyboardTranslator.VK_U, KeyboardTranslator.VK_S})), 200);
+ }));
+ options.add(new MenuOption(getString(R.string.game_menu_send_keys_win_x_u_u), () -> {
+ sendKeys(new short[]{KeyboardTranslator.VK_LWIN, KeyboardTranslator.VK_X});
+ new Handler().postDelayed((() -> sendKeys(new short[]{KeyboardTranslator.VK_U, KeyboardTranslator.VK_U})), 200);
+ }));
+ options.add(new MenuOption(getString(R.string.game_menu_send_keys_win_x_u_r), () -> {
+ sendKeys(new short[]{KeyboardTranslator.VK_LWIN, KeyboardTranslator.VK_X});
+ new Handler().postDelayed((() -> sendKeys(new short[]{KeyboardTranslator.VK_U, KeyboardTranslator.VK_R})), 200);
+ }));
+ options.add(new MenuOption(getString(R.string.game_menu_send_keys_win_x_u_i), () -> {
+ sendKeys(new short[]{KeyboardTranslator.VK_LWIN, KeyboardTranslator.VK_X});
+ new Handler().postDelayed((() -> sendKeys(new short[]{KeyboardTranslator.VK_U, KeyboardTranslator.VK_I})), 200);
+ }));
+
+ }
+
+ //自定义导入的指令
+ SharedPreferences preferences = game.getSharedPreferences(PREF_NAME, Activity.MODE_PRIVATE);
+ String value = preferences.getString(KEY_NAME,"");
+
+ if(!TextUtils.isEmpty(value)){
+ try {
+ JSONObject object = new JSONObject(value);
+ JSONArray array = object.optJSONArray("data");
+ if(array != null&&array.length()>0){
+ for (int i = 0; i < array.length(); i++) {
+ JSONObject object1 = array.getJSONObject(i);
+ String name = object1.optString("name");
+ JSONArray array1 = object1.getJSONArray("keys");
+ short[] datas = new short[array1.length()];
+ for (int j = 0; j < array1.length(); j++) {
+ String code = array1.getString(j);
+ int keycode;
+ if (code.startsWith("0x")) {
+ keycode = Integer.parseInt(code.substring(2), 16);
+ } else if (code.startsWith("VK_")) {
+ Field vkCodeField = KeyMapper.class.getDeclaredField(code);
+ keycode = vkCodeField.getInt(null);
+ } else {
+ throw new Exception("Unknown key code: " + code);
+ }
+ datas[j] = (short) keycode;
+ }
+ MenuOption option = new MenuOption(name, () -> sendKeys(datas));
+ options.add(option);
+ }
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ Toast.makeText(game,getString(R.string.wrong_import_format),Toast.LENGTH_SHORT).show();
+ }
+ }
+ options.add(new MenuOption(getString(R.string.game_menu_cancel), null));
+
+ showMenuDialog(getString(R.string.game_menu_send_keys), options.toArray(new MenuOption[options.size()]));
+ }
+
+ private void showAdvancedMenu(GameInputDevice device) {
+ List options = new ArrayList<>();
+
+ if (game.presentation == null) {
+ options.add(new MenuOption(getString(R.string.game_menu_select_mouse_mode), true,
+ game::selectMouseMode));
+ }
+
+ options.add(new MenuOption(getString(R.string.game_menu_hud), true,
+ game::toggleHUD));
+
+ options.add(new MenuOption(getString(R.string.game_menu_toggle_keyboard_model), true,
+ game::showHideKeyboardController));
+
+ options.add(new MenuOption(getString(R.string.game_menu_toggle_virtual_model), true,
+ game::showHideVirtualController));
+ options.add(new MenuOption(getString(R.string.game_menu_toggle_virtual_keyboard_model), true,
+ game::showHidekeyBoardLayoutController));
+
+ options.add(new MenuOption(getString(R.string.game_menu_task_manager), true,
+ () -> sendKeys(new short[]{KeyboardTranslator.VK_LCONTROL, KeyboardTranslator.VK_LSHIFT, KeyboardTranslator.VK_ESCAPE})));
+
+ options.add(new MenuOption(getString(R.string.game_menu_send_keys), true, this::showSpecialKeysMenu));
+
+ options.add(new MenuOption(getString(R.string.game_menu_switch_touch_sensitivity_model), true,
+ game::switchTouchSensitivity));
+
+ if (device != null) {
+ options.addAll(device.getGameMenuOptions());
+ }
+
+ options.add(new MenuOption(getString(R.string.game_menu_cancel), null));
+
+ showMenuDialog(getString(R.string.game_menu_advanced), options.toArray(new MenuOption[options.size()]));
+ }
+
+ private void showServerCmd(ArrayList serverCmds) {
+ List options = new ArrayList<>();
+
+ AtomicInteger index = new AtomicInteger(0);
+ for (String str : serverCmds) {
+ final int finalI = index.getAndIncrement();
+ options.add(new MenuOption("> " + str, true, () -> game.sendExecServerCmd(finalI)));
+ };
+
+ options.add(new MenuOption(getString(R.string.game_menu_cancel), null));
+
+ showMenuDialog(getString(R.string.game_menu_server_cmd), options.toArray(new MenuOption[options.size()]));
+ }
+
+ public void showMenu(GameInputDevice device) {
+ List options = new ArrayList<>();
+
+ options.add(new MenuOption(getString(R.string.game_menu_disconnect), game::disconnect));
+
+ options.add(new MenuOption(getString(R.string.game_menu_quit_session), game::quit));
+
+ options.add(new MenuOption(getString(R.string.game_menu_upload_clipboard), true,
+ () -> game.sendClipboard(true)));
+
+ options.add(new MenuOption(getString(R.string.game_menu_fetch_clipboard), true,
+ () -> game.getClipboard(0)));
+
+ options.add(new MenuOption(getString(R.string.game_menu_server_cmd), true,
+ () -> {
+ ArrayList serverCmds = game.getServerCmds();
+
+ if (serverCmds.isEmpty()) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(game);
+ builder.setTitle(R.string.game_dialog_title_server_cmd_empty);
+ builder.setMessage(R.string.game_dialog_message_server_cmd_empty);
+
+ AlertDialog dialog = builder.create();
+ dialog.show();
+ } else {
+ this.showServerCmd(serverCmds);
+ }
+ }));
+
+ options.add(new MenuOption(getString(R.string.game_menu_toggle_keyboard), true,
+ game::toggleKeyboard));
+
+ options.add(new MenuOption(getString(game.isZoomModeEnabled() ? R.string.game_menu_disable_zoom_mode : R.string.game_menu_enable_zoom_mode), true,
+ game::toggleZoomMode));
+
+ options.add(new MenuOption(getString(R.string.game_menu_rotate_screen), true,
+ game::rotateScreen));
+
+ options.add(new MenuOption(getString(R.string.game_menu_advanced), true,
+ () -> showAdvancedMenu(device)));
+
+ options.add(new MenuOption(getString(R.string.game_menu_cancel), null));
+
+ showMenuDialog(getString(R.string.quick_menu_title), options.toArray(new MenuOption[options.size()]));
+ }
+
+ public void hideMenu() {
+ if (currentDialog != null) {
+ currentDialog.dismiss();
+ currentDialog = null;
+ }
+ }
+
+ public boolean isMenuOpen() {
+ if (currentDialog == null) {
+ return false;
+ }
+ return currentDialog.isShowing();
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/limelight/HelpActivity.java b/app/src/main/java/com/limelight/HelpActivity.java
old mode 100644
new mode 100755
index 25b125122d..f2603c396a
--- a/app/src/main/java/com/limelight/HelpActivity.java
+++ b/app/src/main/java/com/limelight/HelpActivity.java
@@ -1,116 +1,116 @@
-package com.limelight;
-
-import android.app.Activity;
-import android.graphics.Bitmap;
-import android.os.Build;
-import android.os.Bundle;
-import android.webkit.WebView;
-import android.webkit.WebViewClient;
-import android.window.OnBackInvokedCallback;
-import android.window.OnBackInvokedDispatcher;
-
-import com.limelight.utils.SpinnerDialog;
-
-public class HelpActivity extends Activity {
-
- private SpinnerDialog loadingDialog;
- private WebView webView;
-
- private boolean backCallbackRegistered;
- private OnBackInvokedCallback onBackInvokedCallback;
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- onBackInvokedCallback = new OnBackInvokedCallback() {
- @Override
- public void onBackInvoked() {
- // We should always be able to go back because we unregister our callback
- // when we can't go back. Nonetheless, we will still check anyway.
- if (webView.canGoBack()) {
- webView.goBack();
- }
- }
- };
- }
-
- webView = new WebView(this);
- setContentView(webView);
-
- // These allow the user to zoom the page
- webView.getSettings().setBuiltInZoomControls(true);
- webView.getSettings().setDisplayZoomControls(false);
-
- // This sets the view to display the whole page by default
- webView.getSettings().setUseWideViewPort(true);
- webView.getSettings().setLoadWithOverviewMode(true);
-
- // This allows the links to places on the same page to work
- webView.getSettings().setJavaScriptEnabled(true);
-
- webView.setWebViewClient(new WebViewClient() {
- @Override
- public void onPageStarted(WebView view, String url, Bitmap favicon) {
- if (loadingDialog == null) {
- loadingDialog = SpinnerDialog.displayDialog(HelpActivity.this,
- getResources().getString(R.string.help_loading_title),
- getResources().getString(R.string.help_loading_msg), false);
- }
-
- refreshBackDispatchState();
- }
-
- @Override
- public void onPageFinished(WebView view, String url) {
- if (loadingDialog != null) {
- loadingDialog.dismiss();
- loadingDialog = null;
- }
-
- refreshBackDispatchState();
- }
- });
-
- webView.loadUrl(getIntent().getData().toString());
- }
-
- private void refreshBackDispatchState() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- if (webView.canGoBack() && !backCallbackRegistered) {
- getOnBackInvokedDispatcher().registerOnBackInvokedCallback(
- OnBackInvokedDispatcher.PRIORITY_DEFAULT, onBackInvokedCallback);
- backCallbackRegistered = true;
- }
- else if (!webView.canGoBack() && backCallbackRegistered) {
- getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(onBackInvokedCallback);
- backCallbackRegistered = false;
- }
- }
- }
-
- @Override
- protected void onDestroy() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- if (backCallbackRegistered) {
- getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(onBackInvokedCallback);
- }
- }
-
- super.onDestroy();
- }
-
- @Override
- // NOTE: This will NOT be called on Android 13+ with android:enableOnBackInvokedCallback="true"
- public void onBackPressed() {
- // Back goes back through the WebView history
- // until no more history remains
- if (webView.canGoBack()) {
- webView.goBack();
- }
- else {
- super.onBackPressed();
- }
- }
-}
+package com.limelight;
+
+import android.app.Activity;
+import android.graphics.Bitmap;
+import android.os.Build;
+import android.os.Bundle;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+import android.window.OnBackInvokedCallback;
+import android.window.OnBackInvokedDispatcher;
+
+import com.limelight.utils.SpinnerDialog;
+
+public class HelpActivity extends Activity {
+
+ private SpinnerDialog loadingDialog;
+ private WebView webView;
+
+ private boolean backCallbackRegistered;
+ private OnBackInvokedCallback onBackInvokedCallback;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ onBackInvokedCallback = new OnBackInvokedCallback() {
+ @Override
+ public void onBackInvoked() {
+ // We should always be able to go back because we unregister our callback
+ // when we can't go back. Nonetheless, we will still check anyway.
+ if (webView.canGoBack()) {
+ webView.goBack();
+ }
+ }
+ };
+ }
+
+ webView = new WebView(this);
+ setContentView(webView);
+
+ // These allow the user to zoom the page
+ webView.getSettings().setBuiltInZoomControls(true);
+ webView.getSettings().setDisplayZoomControls(false);
+
+ // This sets the view to display the whole page by default
+ webView.getSettings().setUseWideViewPort(true);
+ webView.getSettings().setLoadWithOverviewMode(true);
+
+ // This allows the links to places on the same page to work
+ webView.getSettings().setJavaScriptEnabled(true);
+
+ webView.setWebViewClient(new WebViewClient() {
+ @Override
+ public void onPageStarted(WebView view, String url, Bitmap favicon) {
+ if (loadingDialog == null) {
+ loadingDialog = SpinnerDialog.displayDialog(HelpActivity.this,
+ getResources().getString(R.string.help_loading_title),
+ getResources().getString(R.string.help_loading_msg), false);
+ }
+
+ refreshBackDispatchState();
+ }
+
+ @Override
+ public void onPageFinished(WebView view, String url) {
+ if (loadingDialog != null) {
+ loadingDialog.dismiss();
+ loadingDialog = null;
+ }
+
+ refreshBackDispatchState();
+ }
+ });
+
+ webView.loadUrl(getIntent().getData().toString());
+ }
+
+ private void refreshBackDispatchState() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ if (webView.canGoBack() && !backCallbackRegistered) {
+ getOnBackInvokedDispatcher().registerOnBackInvokedCallback(
+ OnBackInvokedDispatcher.PRIORITY_DEFAULT, onBackInvokedCallback);
+ backCallbackRegistered = true;
+ }
+ else if (!webView.canGoBack() && backCallbackRegistered) {
+ getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(onBackInvokedCallback);
+ backCallbackRegistered = false;
+ }
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ if (backCallbackRegistered) {
+ getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(onBackInvokedCallback);
+ }
+ }
+
+ super.onDestroy();
+ }
+
+ @Override
+ // NOTE: This will NOT be called on Android 13+ with android:enableOnBackInvokedCallback="true"
+ public void onBackPressed() {
+ // Back goes back through the WebView history
+ // until no more history remains
+ if (webView.canGoBack()) {
+ webView.goBack();
+ }
+ else {
+ super.onBackPressed();
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/KeyboardAccessibilityService.java b/app/src/main/java/com/limelight/KeyboardAccessibilityService.java
new file mode 100755
index 0000000000..fa2e3225ea
--- /dev/null
+++ b/app/src/main/java/com/limelight/KeyboardAccessibilityService.java
@@ -0,0 +1,72 @@
+package com.limelight;
+
+import android.accessibilityservice.AccessibilityService;
+import android.accessibilityservice.AccessibilityServiceInfo;
+import android.view.KeyEvent;
+import android.view.accessibility.AccessibilityEvent;
+import android.widget.Toast;
+
+import java.util.Arrays;
+import java.util.List;
+
+public class KeyboardAccessibilityService extends AccessibilityService {
+
+ //不屏蔽的按键列表
+ private final static List BLACKLIST_KEYS = Arrays.asList(
+ KeyEvent.KEYCODE_VOLUME_UP,
+ KeyEvent.KEYCODE_VOLUME_DOWN,
+ KeyEvent.KEYCODE_POWER
+ );
+
+ @Override
+ public boolean onKeyEvent(KeyEvent event) {
+ int action = event.getAction();
+ int keyCode = event.getKeyCode();
+// Toast.makeText(getApplicationContext(),"scancode:"+event.getScanCode()+",code:"+event.getKeyCode(),Toast.LENGTH_LONG).show();
+ //主要解决系统自带快捷键在pc端无法使用问题 home键 scancode=172 code- 3
+ if (Game.instance != null && Game.instance.connected && !BLACKLIST_KEYS.contains(keyCode)) {
+
+ if (action == KeyEvent.ACTION_DOWN) {
+ //fix 小米平板esc键按钮映射错误 KEYCODE_BACK=4
+ if(event.getScanCode()==1){
+ Game.instance.handleKeyDown(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ESCAPE));
+ return true;
+ }
+ Game.instance.handleKeyDown(event);
+ return true;
+ } else if (action == KeyEvent.ACTION_UP) {
+ //fix 小米平板esc键按钮映射错误 KEYCODE_BACK=4
+ if(event.getScanCode()==1){
+ Game.instance.handleKeyUp(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ESCAPE));
+ return true;
+ }
+ Game.instance.handleKeyUp(event);
+ return true;
+ }
+ }
+
+ return super.onKeyEvent(event);
+ }
+
+ @Override
+ public void onServiceConnected() {
+ LimeLog.info("Keyboard service is connected");
+ AccessibilityServiceInfo info = new AccessibilityServiceInfo();
+ info.packageNames = new String[] { BuildConfig.APPLICATION_ID };
+ info.eventTypes = AccessibilityEvent.TYPES_ALL_MASK;
+ info.notificationTimeout = 100;
+ info.flags = AccessibilityServiceInfo.FLAG_REQUEST_FILTER_KEY_EVENTS;
+ info.feedbackType = AccessibilityServiceInfo.FEEDBACK_SPOKEN;
+ setServiceInfo(info);
+ }
+
+ @Override
+ public void onAccessibilityEvent(AccessibilityEvent accessibilityEvent) {
+// LimeLog.info("onAccessibilityEvent:"+accessibilityEvent.toString());
+ }
+ @Override
+ public void onInterrupt() {
+
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/limelight/LimeLog.java b/app/src/main/java/com/limelight/LimeLog.java
old mode 100644
new mode 100755
index ba0ba298cc..07f21b9e0b
--- a/app/src/main/java/com/limelight/LimeLog.java
+++ b/app/src/main/java/com/limelight/LimeLog.java
@@ -1,25 +1,25 @@
-package com.limelight;
-
-import java.io.IOException;
-import java.util.logging.FileHandler;
-import java.util.logging.Logger;
-
-public class LimeLog {
- private static final Logger LOGGER = Logger.getLogger(LimeLog.class.getName());
-
- public static void info(String msg) {
- LOGGER.info(msg);
- }
-
- public static void warning(String msg) {
- LOGGER.warning(msg);
- }
-
- public static void severe(String msg) {
- LOGGER.severe(msg);
- }
-
- public static void setFileHandler(String fileName) throws IOException {
- LOGGER.addHandler(new FileHandler(fileName));
- }
-}
+package com.limelight;
+
+import java.io.IOException;
+import java.util.logging.FileHandler;
+import java.util.logging.Logger;
+
+public class LimeLog {
+ private static final Logger LOGGER = Logger.getLogger(LimeLog.class.getName());
+
+ public static void info(String msg) {
+ LOGGER.info(msg);
+ }
+
+ public static void warning(String msg) {
+ LOGGER.warning(msg);
+ }
+
+ public static void severe(String msg) {
+ LOGGER.severe(msg);
+ }
+
+ public static void setFileHandler(String fileName) throws IOException {
+ LOGGER.addHandler(new FileHandler(fileName));
+ }
+}
diff --git a/app/src/main/java/com/limelight/PcView.java b/app/src/main/java/com/limelight/PcView.java
old mode 100644
new mode 100755
index 4ec6094f6e..71c2ddd9d7
--- a/app/src/main/java/com/limelight/PcView.java
+++ b/app/src/main/java/com/limelight/PcView.java
@@ -1,788 +1,902 @@
-package com.limelight;
-
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.net.UnknownHostException;
-
-import com.limelight.binding.PlatformBinding;
-import com.limelight.binding.crypto.AndroidCryptoProvider;
-import com.limelight.computers.ComputerManagerListener;
-import com.limelight.computers.ComputerManagerService;
-import com.limelight.grid.PcGridAdapter;
-import com.limelight.grid.assets.DiskAssetLoader;
-import com.limelight.nvstream.http.ComputerDetails;
-import com.limelight.nvstream.http.NvApp;
-import com.limelight.nvstream.http.NvHTTP;
-import com.limelight.nvstream.http.PairingManager;
-import com.limelight.nvstream.http.PairingManager.PairState;
-import com.limelight.nvstream.wol.WakeOnLanSender;
-import com.limelight.preferences.AddComputerManually;
-import com.limelight.preferences.GlPreferences;
-import com.limelight.preferences.PreferenceConfiguration;
-import com.limelight.preferences.StreamSettings;
-import com.limelight.ui.AdapterFragment;
-import com.limelight.ui.AdapterFragmentCallbacks;
-import com.limelight.utils.Dialog;
-import com.limelight.utils.HelpLauncher;
-import com.limelight.utils.ServerHelper;
-import com.limelight.utils.ShortcutHelper;
-import com.limelight.utils.UiHelper;
-
-import android.app.Activity;
-import android.app.ActivityManager;
-import android.app.Service;
-import android.content.ComponentName;
-import android.content.Intent;
-import android.content.ServiceConnection;
-import android.content.res.Configuration;
-import android.opengl.GLSurfaceView;
-import android.os.Build;
-import android.os.Bundle;
-import android.os.IBinder;
-import android.preference.PreferenceManager;
-import android.view.ContextMenu;
-import android.view.Menu;
-import android.view.MenuItem;
-import android.view.View;
-import android.view.ContextMenu.ContextMenuInfo;
-import android.view.View.OnClickListener;
-import android.widget.AbsListView;
-import android.widget.AdapterView;
-import android.widget.AdapterView.OnItemClickListener;
-import android.widget.ImageButton;
-import android.widget.RelativeLayout;
-import android.widget.Toast;
-import android.widget.AdapterView.AdapterContextMenuInfo;
-
-import org.xmlpull.v1.XmlPullParserException;
-
-import javax.microedition.khronos.egl.EGLConfig;
-import javax.microedition.khronos.opengles.GL10;
-
-public class PcView extends Activity implements AdapterFragmentCallbacks {
- private RelativeLayout noPcFoundLayout;
- private PcGridAdapter pcGridAdapter;
- private ShortcutHelper shortcutHelper;
- private ComputerManagerService.ComputerManagerBinder managerBinder;
- private boolean freezeUpdates, runningPolling, inForeground, completeOnCreateCalled;
- private final ServiceConnection serviceConnection = new ServiceConnection() {
- public void onServiceConnected(ComponentName className, IBinder binder) {
- final ComputerManagerService.ComputerManagerBinder localBinder =
- ((ComputerManagerService.ComputerManagerBinder)binder);
-
- // Wait in a separate thread to avoid stalling the UI
- new Thread() {
- @Override
- public void run() {
- // Wait for the binder to be ready
- localBinder.waitForReady();
-
- // Now make the binder visible
- managerBinder = localBinder;
-
- // Start updates
- startComputerUpdates();
-
- // Force a keypair to be generated early to avoid discovery delays
- new AndroidCryptoProvider(PcView.this).getClientCertificate();
- }
- }.start();
- }
-
- public void onServiceDisconnected(ComponentName className) {
- managerBinder = null;
- }
- };
-
- @Override
- public void onConfigurationChanged(Configuration newConfig) {
- super.onConfigurationChanged(newConfig);
-
- // Only reinitialize views if completeOnCreate() was called
- // before this callback. If it was not, completeOnCreate() will
- // handle initializing views with the config change accounted for.
- // This is not prone to races because both callbacks are invoked
- // in the main thread.
- if (completeOnCreateCalled) {
- // Reinitialize views just in case orientation changed
- initializeViews();
- }
- }
-
- private final static int PAIR_ID = 2;
- private final static int UNPAIR_ID = 3;
- private final static int WOL_ID = 4;
- private final static int DELETE_ID = 5;
- private final static int RESUME_ID = 6;
- private final static int QUIT_ID = 7;
- private final static int VIEW_DETAILS_ID = 8;
- private final static int FULL_APP_LIST_ID = 9;
- private final static int TEST_NETWORK_ID = 10;
- private final static int GAMESTREAM_EOL_ID = 11;
-
- private void initializeViews() {
- setContentView(R.layout.activity_pc_view);
-
- UiHelper.notifyNewRootView(this);
-
- // Allow floating expanded PiP overlays while browsing PCs
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- setShouldDockBigOverlays(false);
- }
-
- // Set default preferences if we've never been run
- PreferenceManager.setDefaultValues(this, R.xml.preferences, false);
-
- // Set the correct layout for the PC grid
- pcGridAdapter.updateLayoutWithPreferences(this, PreferenceConfiguration.readPreferences(this));
-
- // Setup the list view
- ImageButton settingsButton = findViewById(R.id.settingsButton);
- ImageButton addComputerButton = findViewById(R.id.manuallyAddPc);
- ImageButton helpButton = findViewById(R.id.helpButton);
-
- settingsButton.setOnClickListener(new OnClickListener() {
- @Override
- public void onClick(View v) {
- startActivity(new Intent(PcView.this, StreamSettings.class));
- }
- });
- addComputerButton.setOnClickListener(new OnClickListener() {
- @Override
- public void onClick(View v) {
- Intent i = new Intent(PcView.this, AddComputerManually.class);
- startActivity(i);
- }
- });
- helpButton.setOnClickListener(new OnClickListener() {
- @Override
- public void onClick(View v) {
- HelpLauncher.launchSetupGuide(PcView.this);
- }
- });
-
- // Amazon review didn't like the help button because the wiki was not entirely
- // navigable via the Fire TV remote (though the relevant parts were). Let's hide
- // it on Fire TV.
- if (getPackageManager().hasSystemFeature("amazon.hardware.fire_tv")) {
- helpButton.setVisibility(View.GONE);
- }
-
- getFragmentManager().beginTransaction()
- .replace(R.id.pcFragmentContainer, new AdapterFragment())
- .commitAllowingStateLoss();
-
- noPcFoundLayout = findViewById(R.id.no_pc_found_layout);
- if (pcGridAdapter.getCount() == 0) {
- noPcFoundLayout.setVisibility(View.VISIBLE);
- }
- else {
- noPcFoundLayout.setVisibility(View.INVISIBLE);
- }
- pcGridAdapter.notifyDataSetChanged();
- }
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
-
- // Assume we're in the foreground when created to avoid a race
- // between binding to CMS and onResume()
- inForeground = true;
-
- // Create a GLSurfaceView to fetch GLRenderer unless we have
- // a cached result already.
- final GlPreferences glPrefs = GlPreferences.readPreferences(this);
- if (!glPrefs.savedFingerprint.equals(Build.FINGERPRINT) || glPrefs.glRenderer.isEmpty()) {
- GLSurfaceView surfaceView = new GLSurfaceView(this);
- surfaceView.setRenderer(new GLSurfaceView.Renderer() {
- @Override
- public void onSurfaceCreated(GL10 gl10, EGLConfig eglConfig) {
- // Save the GLRenderer string so we don't need to do this next time
- glPrefs.glRenderer = gl10.glGetString(GL10.GL_RENDERER);
- glPrefs.savedFingerprint = Build.FINGERPRINT;
- glPrefs.writePreferences();
-
- LimeLog.info("Fetched GL Renderer: " + glPrefs.glRenderer);
-
- runOnUiThread(new Runnable() {
- @Override
- public void run() {
- completeOnCreate();
- }
- });
- }
-
- @Override
- public void onSurfaceChanged(GL10 gl10, int i, int i1) {
- }
-
- @Override
- public void onDrawFrame(GL10 gl10) {
- }
- });
- setContentView(surfaceView);
- }
- else {
- LimeLog.info("Cached GL Renderer: " + glPrefs.glRenderer);
- completeOnCreate();
- }
- }
-
- private void completeOnCreate() {
- completeOnCreateCalled = true;
-
- shortcutHelper = new ShortcutHelper(this);
-
- UiHelper.setLocale(this);
-
- // Bind to the computer manager service
- bindService(new Intent(PcView.this, ComputerManagerService.class), serviceConnection,
- Service.BIND_AUTO_CREATE);
-
- pcGridAdapter = new PcGridAdapter(this, PreferenceConfiguration.readPreferences(this));
-
- initializeViews();
- }
-
- private void startComputerUpdates() {
- // Only allow polling to start if we're bound to CMS, polling is not already running,
- // and our activity is in the foreground.
- if (managerBinder != null && !runningPolling && inForeground) {
- freezeUpdates = false;
- managerBinder.startPolling(new ComputerManagerListener() {
- @Override
- public void notifyComputerUpdated(final ComputerDetails details) {
- if (!freezeUpdates) {
- PcView.this.runOnUiThread(new Runnable() {
- @Override
- public void run() {
- updateComputer(details);
- }
- });
-
- // Add a launcher shortcut for this PC (off the main thread to prevent ANRs)
- if (details.pairState == PairState.PAIRED) {
- shortcutHelper.createAppViewShortcutForOnlineHost(details);
- }
- }
- }
- });
- runningPolling = true;
- }
- }
-
- private void stopComputerUpdates(boolean wait) {
- if (managerBinder != null) {
- if (!runningPolling) {
- return;
- }
-
- freezeUpdates = true;
-
- managerBinder.stopPolling();
-
- if (wait) {
- managerBinder.waitForPollingStopped();
- }
-
- runningPolling = false;
- }
- }
-
- @Override
- public void onDestroy() {
- super.onDestroy();
-
- if (managerBinder != null) {
- unbindService(serviceConnection);
- }
- }
-
- @Override
- protected void onResume() {
- super.onResume();
-
- // Display a decoder crash notification if we've returned after a crash
- UiHelper.showDecoderCrashDialog(this);
-
- inForeground = true;
- startComputerUpdates();
- }
-
- @Override
- protected void onPause() {
- super.onPause();
-
- inForeground = false;
- stopComputerUpdates(false);
- }
-
- @Override
- protected void onStop() {
- super.onStop();
-
- Dialog.closeDialogs();
- }
-
- @Override
- public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) {
- stopComputerUpdates(false);
-
- // Call superclass
- super.onCreateContextMenu(menu, v, menuInfo);
-
- AdapterContextMenuInfo info = (AdapterContextMenuInfo) menuInfo;
- ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(info.position);
-
- // Add a header with PC status details
- menu.clearHeader();
- String headerTitle = computer.details.name + " - ";
- switch (computer.details.state)
- {
- case ONLINE:
- headerTitle += getResources().getString(R.string.pcview_menu_header_online);
- break;
- case OFFLINE:
- menu.setHeaderIcon(R.drawable.ic_pc_offline);
- headerTitle += getResources().getString(R.string.pcview_menu_header_offline);
- break;
- case UNKNOWN:
- headerTitle += getResources().getString(R.string.pcview_menu_header_unknown);
- break;
- }
-
- menu.setHeaderTitle(headerTitle);
-
- // Inflate the context menu
- if (computer.details.state == ComputerDetails.State.OFFLINE ||
- computer.details.state == ComputerDetails.State.UNKNOWN) {
- menu.add(Menu.NONE, WOL_ID, 1, getResources().getString(R.string.pcview_menu_send_wol));
- menu.add(Menu.NONE, GAMESTREAM_EOL_ID, 2, getResources().getString(R.string.pcview_menu_eol));
- }
- else if (computer.details.pairState != PairState.PAIRED) {
- menu.add(Menu.NONE, PAIR_ID, 1, getResources().getString(R.string.pcview_menu_pair_pc));
- if (computer.details.nvidiaServer) {
- menu.add(Menu.NONE, GAMESTREAM_EOL_ID, 2, getResources().getString(R.string.pcview_menu_eol));
- }
- }
- else {
- if (computer.details.runningGameId != 0) {
- menu.add(Menu.NONE, RESUME_ID, 1, getResources().getString(R.string.applist_menu_resume));
- menu.add(Menu.NONE, QUIT_ID, 2, getResources().getString(R.string.applist_menu_quit));
- }
-
- if (computer.details.nvidiaServer) {
- menu.add(Menu.NONE, GAMESTREAM_EOL_ID, 3, getResources().getString(R.string.pcview_menu_eol));
- }
-
- menu.add(Menu.NONE, FULL_APP_LIST_ID, 4, getResources().getString(R.string.pcview_menu_app_list));
- }
-
- menu.add(Menu.NONE, TEST_NETWORK_ID, 5, getResources().getString(R.string.pcview_menu_test_network));
- menu.add(Menu.NONE, DELETE_ID, 6, getResources().getString(R.string.pcview_menu_delete_pc));
- menu.add(Menu.NONE, VIEW_DETAILS_ID, 7, getResources().getString(R.string.pcview_menu_details));
- }
-
- @Override
- public void onContextMenuClosed(Menu menu) {
- // For some reason, this gets called again _after_ onPause() is called on this activity.
- // startComputerUpdates() manages this and won't actual start polling until the activity
- // returns to the foreground.
- startComputerUpdates();
- }
-
- private void doPair(final ComputerDetails computer) {
- if (computer.state == ComputerDetails.State.OFFLINE || computer.activeAddress == null) {
- Toast.makeText(PcView.this, getResources().getString(R.string.pair_pc_offline), Toast.LENGTH_SHORT).show();
- return;
- }
- if (managerBinder == null) {
- Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show();
- return;
- }
-
- Toast.makeText(PcView.this, getResources().getString(R.string.pairing), Toast.LENGTH_SHORT).show();
- new Thread(new Runnable() {
- @Override
- public void run() {
- NvHTTP httpConn;
- String message;
- boolean success = false;
- try {
- // Stop updates and wait while pairing
- stopComputerUpdates(true);
-
- httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer),
- computer.httpsPort, managerBinder.getUniqueId(), computer.serverCert,
- PlatformBinding.getCryptoProvider(PcView.this));
- if (httpConn.getPairState() == PairState.PAIRED) {
- // Don't display any toast, but open the app list
- message = null;
- success = true;
- }
- else {
- final String pinStr = PairingManager.generatePinString();
-
- // Spin the dialog off in a thread because it blocks
- Dialog.displayDialog(PcView.this, getResources().getString(R.string.pair_pairing_title),
- getResources().getString(R.string.pair_pairing_msg)+" "+pinStr+"\n\n"+
- getResources().getString(R.string.pair_pairing_help), false);
-
- PairingManager pm = httpConn.getPairingManager();
-
- PairState pairState = pm.pair(httpConn.getServerInfo(true), pinStr);
- if (pairState == PairState.PIN_WRONG) {
- message = getResources().getString(R.string.pair_incorrect_pin);
- }
- else if (pairState == PairState.FAILED) {
- if (computer.runningGameId != 0) {
- message = getResources().getString(R.string.pair_pc_ingame);
- }
- else {
- message = getResources().getString(R.string.pair_fail);
- }
- }
- else if (pairState == PairState.ALREADY_IN_PROGRESS) {
- message = getResources().getString(R.string.pair_already_in_progress);
- }
- else if (pairState == PairState.PAIRED) {
- // Just navigate to the app view without displaying a toast
- message = null;
- success = true;
-
- // Pin this certificate for later HTTPS use
- managerBinder.getComputer(computer.uuid).serverCert = pm.getPairedCert();
-
- // Invalidate reachability information after pairing to force
- // a refresh before reading pair state again
- managerBinder.invalidateStateForComputer(computer.uuid);
- }
- else {
- // Should be no other values
- message = null;
- }
- }
- } catch (UnknownHostException e) {
- message = getResources().getString(R.string.error_unknown_host);
- } catch (FileNotFoundException e) {
- message = getResources().getString(R.string.error_404);
- } catch (XmlPullParserException | IOException e) {
- e.printStackTrace();
- message = e.getMessage();
- }
-
- Dialog.closeDialogs();
-
- final String toastMessage = message;
- final boolean toastSuccess = success;
- runOnUiThread(new Runnable() {
- @Override
- public void run() {
- if (toastMessage != null) {
- Toast.makeText(PcView.this, toastMessage, Toast.LENGTH_LONG).show();
- }
-
- if (toastSuccess) {
- // Open the app list after a successful pairing attempt
- doAppList(computer, true, false);
- }
- else {
- // Start polling again if we're still in the foreground
- startComputerUpdates();
- }
- }
- });
- }
- }).start();
- }
-
- private void doWakeOnLan(final ComputerDetails computer) {
- if (computer.state == ComputerDetails.State.ONLINE) {
- Toast.makeText(PcView.this, getResources().getString(R.string.wol_pc_online), Toast.LENGTH_SHORT).show();
- return;
- }
-
- if (computer.macAddress == null) {
- Toast.makeText(PcView.this, getResources().getString(R.string.wol_no_mac), Toast.LENGTH_SHORT).show();
- return;
- }
-
- new Thread(new Runnable() {
- @Override
- public void run() {
- String message;
- try {
- WakeOnLanSender.sendWolPacket(computer);
- message = getResources().getString(R.string.wol_waking_msg);
- } catch (IOException e) {
- message = getResources().getString(R.string.wol_fail);
- }
-
- final String toastMessage = message;
- runOnUiThread(new Runnable() {
- @Override
- public void run() {
- Toast.makeText(PcView.this, toastMessage, Toast.LENGTH_LONG).show();
- }
- });
- }
- }).start();
- }
-
- private void doUnpair(final ComputerDetails computer) {
- if (computer.state == ComputerDetails.State.OFFLINE || computer.activeAddress == null) {
- Toast.makeText(PcView.this, getResources().getString(R.string.error_pc_offline), Toast.LENGTH_SHORT).show();
- return;
- }
- if (managerBinder == null) {
- Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show();
- return;
- }
-
- Toast.makeText(PcView.this, getResources().getString(R.string.unpairing), Toast.LENGTH_SHORT).show();
- new Thread(new Runnable() {
- @Override
- public void run() {
- NvHTTP httpConn;
- String message;
- try {
- httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer),
- computer.httpsPort, managerBinder.getUniqueId(), computer.serverCert,
- PlatformBinding.getCryptoProvider(PcView.this));
- if (httpConn.getPairState() == PairingManager.PairState.PAIRED) {
- httpConn.unpair();
- if (httpConn.getPairState() == PairingManager.PairState.NOT_PAIRED) {
- message = getResources().getString(R.string.unpair_success);
- }
- else {
- message = getResources().getString(R.string.unpair_fail);
- }
- }
- else {
- message = getResources().getString(R.string.unpair_error);
- }
- } catch (UnknownHostException e) {
- message = getResources().getString(R.string.error_unknown_host);
- } catch (FileNotFoundException e) {
- message = getResources().getString(R.string.error_404);
- } catch (XmlPullParserException | IOException e) {
- message = e.getMessage();
- e.printStackTrace();
- }
-
- final String toastMessage = message;
- runOnUiThread(new Runnable() {
- @Override
- public void run() {
- Toast.makeText(PcView.this, toastMessage, Toast.LENGTH_LONG).show();
- }
- });
- }
- }).start();
- }
-
- private void doAppList(ComputerDetails computer, boolean newlyPaired, boolean showHiddenGames) {
- if (computer.state == ComputerDetails.State.OFFLINE) {
- Toast.makeText(PcView.this, getResources().getString(R.string.error_pc_offline), Toast.LENGTH_SHORT).show();
- return;
- }
- if (managerBinder == null) {
- Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show();
- return;
- }
-
- Intent i = new Intent(this, AppView.class);
- i.putExtra(AppView.NAME_EXTRA, computer.name);
- i.putExtra(AppView.UUID_EXTRA, computer.uuid);
- i.putExtra(AppView.NEW_PAIR_EXTRA, newlyPaired);
- i.putExtra(AppView.SHOW_HIDDEN_APPS_EXTRA, showHiddenGames);
- startActivity(i);
- }
-
- @Override
- public boolean onContextItemSelected(MenuItem item) {
- AdapterContextMenuInfo info = (AdapterContextMenuInfo) item.getMenuInfo();
- final ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(info.position);
- switch (item.getItemId()) {
- case PAIR_ID:
- doPair(computer.details);
- return true;
-
- case UNPAIR_ID:
- doUnpair(computer.details);
- return true;
-
- case WOL_ID:
- doWakeOnLan(computer.details);
- return true;
-
- case DELETE_ID:
- if (ActivityManager.isUserAMonkey()) {
- LimeLog.info("Ignoring delete PC request from monkey");
- return true;
- }
- UiHelper.displayDeletePcConfirmationDialog(this, computer.details, new Runnable() {
- @Override
- public void run() {
- if (managerBinder == null) {
- Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show();
- return;
- }
- removeComputer(computer.details);
- }
- }, null);
- return true;
-
- case FULL_APP_LIST_ID:
- doAppList(computer.details, false, true);
- return true;
-
- case RESUME_ID:
- if (managerBinder == null) {
- Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show();
- return true;
- }
-
- ServerHelper.doStart(this, new NvApp("app", computer.details.runningGameId, false), computer.details, managerBinder);
- return true;
-
- case QUIT_ID:
- if (managerBinder == null) {
- Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show();
- return true;
- }
-
- // Display a confirmation dialog first
- UiHelper.displayQuitConfirmationDialog(this, new Runnable() {
- @Override
- public void run() {
- ServerHelper.doQuit(PcView.this, computer.details,
- new NvApp("app", 0, false), managerBinder, null);
- }
- }, null);
- return true;
-
- case VIEW_DETAILS_ID:
- Dialog.displayDialog(PcView.this, getResources().getString(R.string.title_details), computer.details.toString(), false);
- return true;
-
- case TEST_NETWORK_ID:
- ServerHelper.doNetworkTest(PcView.this);
- return true;
-
- case GAMESTREAM_EOL_ID:
- HelpLauncher.launchGameStreamEolFaq(PcView.this);
- return true;
-
- default:
- return super.onContextItemSelected(item);
- }
- }
-
- private void removeComputer(ComputerDetails details) {
- managerBinder.removeComputer(details);
-
- new DiskAssetLoader(this).deleteAssetsForComputer(details.uuid);
-
- // Delete hidden games preference value
- getSharedPreferences(AppView.HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE)
- .edit()
- .remove(details.uuid)
- .apply();
-
- for (int i = 0; i < pcGridAdapter.getCount(); i++) {
- ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(i);
-
- if (details.equals(computer.details)) {
- // Disable or delete shortcuts referencing this PC
- shortcutHelper.disableComputerShortcut(details,
- getResources().getString(R.string.scut_deleted_pc));
-
- pcGridAdapter.removeComputer(computer);
- pcGridAdapter.notifyDataSetChanged();
-
- if (pcGridAdapter.getCount() == 0) {
- // Show the "Discovery in progress" view
- noPcFoundLayout.setVisibility(View.VISIBLE);
- }
-
- break;
- }
- }
- }
-
- private void updateComputer(ComputerDetails details) {
- ComputerObject existingEntry = null;
-
- for (int i = 0; i < pcGridAdapter.getCount(); i++) {
- ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(i);
-
- // Check if this is the same computer
- if (details.uuid.equals(computer.details.uuid)) {
- existingEntry = computer;
- break;
- }
- }
-
- if (existingEntry != null) {
- // Replace the information in the existing entry
- existingEntry.details = details;
- }
- else {
- // Add a new entry
- pcGridAdapter.addComputer(new ComputerObject(details));
-
- // Remove the "Discovery in progress" view
- noPcFoundLayout.setVisibility(View.INVISIBLE);
- }
-
- // Notify the view that the data has changed
- pcGridAdapter.notifyDataSetChanged();
- }
-
- @Override
- public int getAdapterFragmentLayoutId() {
- return R.layout.pc_grid_view;
- }
-
- @Override
- public void receiveAbsListView(AbsListView listView) {
- listView.setAdapter(pcGridAdapter);
- listView.setOnItemClickListener(new OnItemClickListener() {
- @Override
- public void onItemClick(AdapterView> arg0, View arg1, int pos,
- long id) {
- ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(pos);
- if (computer.details.state == ComputerDetails.State.UNKNOWN ||
- computer.details.state == ComputerDetails.State.OFFLINE) {
- // Open the context menu if a PC is offline or refreshing
- openContextMenu(arg1);
- } else if (computer.details.pairState != PairState.PAIRED) {
- // Pair an unpaired machine by default
- doPair(computer.details);
- } else {
- doAppList(computer.details, false, false);
- }
- }
- });
- UiHelper.applyStatusBarPadding(listView);
- registerForContextMenu(listView);
- }
-
- public static class ComputerObject {
- public ComputerDetails details;
-
- public ComputerObject(ComputerDetails details) {
- if (details == null) {
- throw new IllegalArgumentException("details must not be null");
- }
- this.details = details;
- }
-
- @Override
- public String toString() {
- return details.name;
- }
- }
-}
+package com.limelight;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.net.UnknownHostException;
+
+import com.limelight.binding.PlatformBinding;
+import com.limelight.binding.crypto.AndroidCryptoProvider;
+import com.limelight.computers.ComputerManagerListener;
+import com.limelight.computers.ComputerManagerService;
+import com.limelight.grid.PcGridAdapter;
+import com.limelight.grid.assets.DiskAssetLoader;
+import com.limelight.nvstream.http.ComputerDetails;
+import com.limelight.nvstream.http.NvApp;
+import com.limelight.nvstream.http.NvHTTP;
+import com.limelight.nvstream.http.PairingManager;
+import com.limelight.nvstream.http.PairingManager.PairState;
+import com.limelight.nvstream.wol.WakeOnLanSender;
+import com.limelight.preferences.AddComputerManually;
+import com.limelight.preferences.GlPreferences;
+import com.limelight.preferences.PreferenceConfiguration;
+import com.limelight.preferences.StreamSettings;
+import com.limelight.ui.AdapterFragment;
+import com.limelight.ui.AdapterFragmentCallbacks;
+import com.limelight.utils.Dialog;
+import com.limelight.utils.HelpLauncher;
+import com.limelight.utils.ServerHelper;
+import com.limelight.utils.ShortcutHelper;
+import com.limelight.utils.UiHelper;
+
+import android.app.Activity;
+import android.app.ActivityManager;
+import android.app.AlertDialog;
+import android.app.Service;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.content.res.Configuration;
+import android.opengl.GLSurfaceView;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.text.InputFilter;
+import android.text.InputType;
+import android.view.ContextMenu;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ContextMenu.ContextMenuInfo;
+import android.view.View.OnClickListener;
+import android.widget.AbsListView;
+import android.widget.AdapterView;
+import android.widget.AdapterView.OnItemClickListener;
+import android.widget.EditText;
+import android.widget.ImageButton;
+import android.widget.LinearLayout;
+import android.widget.RelativeLayout;
+import android.widget.Toast;
+import android.widget.AdapterView.AdapterContextMenuInfo;
+
+import androidx.preference.PreferenceManager;
+
+import org.xmlpull.v1.XmlPullParserException;
+
+import javax.microedition.khronos.egl.EGLConfig;
+import javax.microedition.khronos.opengles.GL10;
+
+public class PcView extends Activity implements AdapterFragmentCallbacks {
+ private RelativeLayout noPcFoundLayout;
+ private PcGridAdapter pcGridAdapter;
+ private ShortcutHelper shortcutHelper;
+ private ComputerManagerService.ComputerManagerBinder managerBinder;
+ private boolean freezeUpdates, runningPolling, inForeground, completeOnCreateCalled;
+ private ComputerDetails.AddressTuple pendingPairingAddress;
+ private String pendingPairingPin, pendingPairingPassphrase;
+ private final ServiceConnection serviceConnection = new ServiceConnection() {
+ public void onServiceConnected(ComponentName className, IBinder binder) {
+ final ComputerManagerService.ComputerManagerBinder localBinder =
+ ((ComputerManagerService.ComputerManagerBinder)binder);
+
+ // Wait in a separate thread to avoid stalling the UI
+ new Thread() {
+ @Override
+ public void run() {
+ // Wait for the binder to be ready
+ localBinder.waitForReady();
+
+ // Now make the binder visible
+ managerBinder = localBinder;
+
+ // Start updates
+ startComputerUpdates();
+
+ // Force a keypair to be generated early to avoid discovery delays
+ new AndroidCryptoProvider(PcView.this).getClientCertificate();
+ }
+ }.start();
+ }
+
+ public void onServiceDisconnected(ComponentName className) {
+ managerBinder = null;
+ }
+ };
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+
+ // Only reinitialize views if completeOnCreate() was called
+ // before this callback. If it was not, completeOnCreate() will
+ // handle initializing views with the config change accounted for.
+ // This is not prone to races because both callbacks are invoked
+ // in the main thread.
+ if (completeOnCreateCalled) {
+ // Reinitialize views just in case orientation changed
+ initializeViews();
+ }
+ }
+
+ private final static int PAIR_ID = 2;
+ private final static int UNPAIR_ID = 3;
+ private final static int WOL_ID = 4;
+ private final static int DELETE_ID = 5;
+ private final static int RESUME_ID = 6;
+ private final static int QUIT_ID = 7;
+ private final static int VIEW_DETAILS_ID = 8;
+ private final static int FULL_APP_LIST_ID = 9;
+ private final static int TEST_NETWORK_ID = 10;
+ private final static int GAMESTREAM_EOL_ID = 11;
+ private final static int OPEN_MANAGEMENT_PAGE_ID = 20;
+ private final static int PAIR_ID_OTP = 21;
+
+ private void initializeViews() {
+ setContentView(R.layout.activity_pc_view);
+
+ UiHelper.notifyNewRootView(this);
+
+ // Allow floating expanded PiP overlays while browsing PCs
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ setShouldDockBigOverlays(false);
+ }
+
+ // Set default preferences if we've never been run
+ PreferenceManager.setDefaultValues(this, R.xml.preferences, false);
+
+ // Set the correct layout for the PC grid
+ pcGridAdapter.updateLayoutWithPreferences(this, PreferenceConfiguration.readPreferences(this));
+
+ // Setup the list view
+ ImageButton settingsButton = findViewById(R.id.settingsButton);
+ ImageButton addComputerButton = findViewById(R.id.manuallyAddPc);
+ ImageButton helpButton = findViewById(R.id.helpButton);
+
+ settingsButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ startActivity(new Intent(PcView.this, StreamSettings.class));
+ }
+ });
+ addComputerButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Intent i = new Intent(PcView.this, AddComputerManually.class);
+ startActivity(i);
+ }
+ });
+ helpButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ HelpLauncher.launchSetupGuide(PcView.this);
+ }
+ });
+
+ // Amazon review didn't like the help button because the wiki was not entirely
+ // navigable via the Fire TV remote (though the relevant parts were). Let's hide
+ // it on Fire TV.
+ if (getPackageManager().hasSystemFeature("amazon.hardware.fire_tv")) {
+ helpButton.setVisibility(View.GONE);
+ }
+
+ getFragmentManager().beginTransaction()
+ .replace(R.id.pcFragmentContainer, new AdapterFragment())
+ .commitAllowingStateLoss();
+
+ noPcFoundLayout = findViewById(R.id.no_pc_found_layout);
+ if (pcGridAdapter.getCount() == 0) {
+ noPcFoundLayout.setVisibility(View.VISIBLE);
+ }
+ else {
+ noPcFoundLayout.setVisibility(View.INVISIBLE);
+ }
+ pcGridAdapter.notifyDataSetChanged();
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // Assume we're in the foreground when created to avoid a race
+ // between binding to CMS and onResume()
+ inForeground = true;
+
+ // Create a GLSurfaceView to fetch GLRenderer unless we have
+ // a cached result already.
+ final GlPreferences glPrefs = GlPreferences.readPreferences(this);
+ if (!glPrefs.savedFingerprint.equals(Build.FINGERPRINT) || glPrefs.glRenderer.isEmpty()) {
+ GLSurfaceView surfaceView = new GLSurfaceView(this);
+ surfaceView.setRenderer(new GLSurfaceView.Renderer() {
+ @Override
+ public void onSurfaceCreated(GL10 gl10, EGLConfig eglConfig) {
+ // Save the GLRenderer string so we don't need to do this next time
+ glPrefs.glRenderer = gl10.glGetString(GL10.GL_RENDERER);
+ glPrefs.savedFingerprint = Build.FINGERPRINT;
+ glPrefs.writePreferences();
+
+ LimeLog.info("Fetched GL Renderer: " + glPrefs.glRenderer);
+
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ completeOnCreate();
+ }
+ });
+ }
+
+ @Override
+ public void onSurfaceChanged(GL10 gl10, int i, int i1) {
+ }
+
+ @Override
+ public void onDrawFrame(GL10 gl10) {
+ }
+ });
+ setContentView(surfaceView);
+ }
+ else {
+ LimeLog.info("Cached GL Renderer: " + glPrefs.glRenderer);
+ completeOnCreate();
+ }
+
+ Intent intent = getIntent();
+
+ String hostname = intent.getStringExtra("hostname");
+ int port = intent.getIntExtra("port", NvHTTP.DEFAULT_HTTP_PORT);
+ pendingPairingPin = intent.getStringExtra("pin");
+ pendingPairingPassphrase = intent.getStringExtra("passphrase");
+
+ if (hostname != null && pendingPairingPin != null && pendingPairingPassphrase != null) {
+ pendingPairingAddress = new ComputerDetails.AddressTuple(hostname, port);
+ } else {
+ pendingPairingPin = null;
+ pendingPairingPassphrase = null;
+ }
+ }
+
+ private void completeOnCreate() {
+ completeOnCreateCalled = true;
+
+ shortcutHelper = new ShortcutHelper(this);
+
+ UiHelper.setLocale(this);
+
+ // Bind to the computer manager service
+ bindService(new Intent(PcView.this, ComputerManagerService.class), serviceConnection,
+ Service.BIND_AUTO_CREATE);
+
+ pcGridAdapter = new PcGridAdapter(this, PreferenceConfiguration.readPreferences(this));
+
+ initializeViews();
+ }
+
+ private void startComputerUpdates() {
+ // Only allow polling to start if we're bound to CMS, polling is not already running,
+ // and our activity is in the foreground.
+ if (managerBinder != null && !runningPolling && inForeground) {
+ freezeUpdates = false;
+ managerBinder.startPolling(new ComputerManagerListener() {
+ @Override
+ public void notifyComputerUpdated(final ComputerDetails details) {
+ if (!freezeUpdates) {
+ PcView.this.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ updateComputer(details);
+ }
+ });
+
+ // Add a launcher shortcut for this PC (off the main thread to prevent ANRs)
+ if (details.pairState == PairState.PAIRED) {
+ shortcutHelper.createAppViewShortcutForOnlineHost(details);
+// } else
+ }
+ if (pendingPairingAddress != null) {
+ if (
+ details.state == ComputerDetails.State.ONLINE &&
+ details.activeAddress.equals(pendingPairingAddress)
+ ) {
+ PcView.this.runOnUiThread(() -> {
+ doPair(details, pendingPairingPin, pendingPairingPassphrase);
+ pendingPairingAddress = null;
+ pendingPairingPin = null;
+ pendingPairingPassphrase = null;
+ });
+ }
+ }
+ }
+ }
+ });
+ runningPolling = true;
+ }
+ }
+
+ private void stopComputerUpdates(boolean wait) {
+ if (managerBinder != null) {
+ if (!runningPolling) {
+ return;
+ }
+
+ freezeUpdates = true;
+
+ managerBinder.stopPolling();
+
+ if (wait) {
+ managerBinder.waitForPollingStopped();
+ }
+
+ runningPolling = false;
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+
+ if (managerBinder != null) {
+ unbindService(serviceConnection);
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+
+ // Display a decoder crash notification if we've returned after a crash
+ UiHelper.showDecoderCrashDialog(this);
+
+ inForeground = true;
+ startComputerUpdates();
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+
+ inForeground = false;
+ stopComputerUpdates(false);
+ }
+
+ @Override
+ protected void onStop() {
+ super.onStop();
+
+ Dialog.closeDialogs();
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) {
+ stopComputerUpdates(false);
+
+ // Call superclass
+ super.onCreateContextMenu(menu, v, menuInfo);
+
+ AdapterContextMenuInfo info = (AdapterContextMenuInfo) menuInfo;
+ ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(info.position);
+
+ // Add a header with PC status details
+ menu.clearHeader();
+ String headerTitle = computer.details.name + " - ";
+ switch (computer.details.state)
+ {
+ case ONLINE:
+ headerTitle += getResources().getString(R.string.pcview_menu_header_online);
+ break;
+ case OFFLINE:
+ menu.setHeaderIcon(R.drawable.ic_pc_offline);
+ headerTitle += getResources().getString(R.string.pcview_menu_header_offline);
+ break;
+ case UNKNOWN:
+ headerTitle += getResources().getString(R.string.pcview_menu_header_unknown);
+ break;
+ }
+
+ menu.setHeaderTitle(headerTitle);
+
+ // Inflate the context menu
+ if (computer.details.state == ComputerDetails.State.OFFLINE ||
+ computer.details.state == ComputerDetails.State.UNKNOWN) {
+ menu.add(Menu.NONE, WOL_ID, 1, getResources().getString(R.string.pcview_menu_send_wol));
+ menu.add(Menu.NONE, GAMESTREAM_EOL_ID, 2, getResources().getString(R.string.pcview_menu_eol));
+ }
+ else if (computer.details.pairState != PairState.PAIRED) {
+ menu.add(Menu.NONE, PAIR_ID_OTP, 1, getResources().getString(R.string.pcview_menu_pair_pc_otp));
+ menu.add(Menu.NONE, PAIR_ID, 2, getResources().getString(R.string.pcview_menu_pair_pc));
+ if (computer.details.nvidiaServer) {
+ menu.add(Menu.NONE, GAMESTREAM_EOL_ID, 3, getResources().getString(R.string.pcview_menu_eol));
+ } else {
+ menu.add(Menu.NONE, OPEN_MANAGEMENT_PAGE_ID, 3, getResources().getString(R.string.pcview_menu_open_management_page));
+ }
+ }
+ else {
+ if (computer.details.runningGameId != 0) {
+ menu.add(Menu.NONE, RESUME_ID, 1, getResources().getString(R.string.applist_menu_resume));
+ menu.add(Menu.NONE, QUIT_ID, 2, getResources().getString(R.string.applist_menu_quit));
+ }
+
+ if (computer.details.nvidiaServer) {
+ menu.add(Menu.NONE, GAMESTREAM_EOL_ID, 3, getResources().getString(R.string.pcview_menu_eol));
+ } else {
+ menu.add(Menu.NONE, OPEN_MANAGEMENT_PAGE_ID, 3, getResources().getString(R.string.pcview_menu_open_management_page));
+ }
+
+ menu.add(Menu.NONE, FULL_APP_LIST_ID, 4, getResources().getString(R.string.pcview_menu_app_list));
+ }
+
+ menu.add(Menu.NONE, TEST_NETWORK_ID, 5, getResources().getString(R.string.pcview_menu_test_network));
+ menu.add(Menu.NONE, DELETE_ID, 6, getResources().getString(R.string.pcview_menu_delete_pc));
+ menu.add(Menu.NONE, VIEW_DETAILS_ID, 7, getResources().getString(R.string.pcview_menu_details));
+ }
+
+ @Override
+ public void onContextMenuClosed(Menu menu) {
+ // For some reason, this gets called again _after_ onPause() is called on this activity.
+ // startComputerUpdates() manages this and won't actual start polling until the activity
+ // returns to the foreground.
+ startComputerUpdates();
+ }
+
+ private void doPair(final ComputerDetails computer, String otp, String passphrase) {
+ if (computer.state == ComputerDetails.State.OFFLINE || computer.activeAddress == null) {
+ Toast.makeText(PcView.this, getResources().getString(R.string.pair_pc_offline), Toast.LENGTH_SHORT).show();
+ return;
+ }
+ if (managerBinder == null) {
+ Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show();
+ return;
+ }
+
+ Toast.makeText(PcView.this, getResources().getString(R.string.pairing), Toast.LENGTH_SHORT).show();
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ NvHTTP httpConn;
+ String message;
+ boolean success = false;
+ try {
+ // Stop updates and wait while pairing
+ stopComputerUpdates(true);
+
+ httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer),
+ computer.httpsPort, managerBinder.getUniqueId(), computer.serverCert,
+ PlatformBinding.getCryptoProvider(PcView.this));
+ if (httpConn.getPairState() == PairState.PAIRED) {
+ // Don't display any toast, but open the app list
+ message = null;
+ success = true;
+ }
+ else {
+ String pinStr = otp;
+ if (pinStr == null) {
+ pinStr = PairingManager.generatePinString();
+ }
+
+ // Spin the dialog off in a thread because it blocks
+ if (passphrase == null) {
+ Dialog.displayDialog(PcView.this, getResources().getString(R.string.pair_pairing_title),
+ getResources().getString(R.string.pair_pairing_msg)+" "+pinStr+"\n\n"+
+ getResources().getString(R.string.pair_pairing_help), false);
+ } else {
+ Dialog.displayDialog(PcView.this, getResources().getString(R.string.pair_pairing_title),
+ getResources().getString(R.string.pair_otp_pairing_msg)+"\n\n"+
+ getResources().getString(R.string.pair_otp_pairing_help), false);
+ }
+
+ PairingManager pm = httpConn.getPairingManager();
+
+ PairState pairState = pm.pair(httpConn.getServerInfo(true), pinStr, passphrase);
+ if (pairState == PairState.PIN_WRONG) {
+ message = getResources().getString(R.string.pair_incorrect_pin);
+ }
+ else if (pairState == PairState.FAILED) {
+ if (computer.runningGameId != 0) {
+ message = getResources().getString(R.string.pair_pc_ingame);
+ }
+ else {
+ message = getResources().getString(R.string.pair_fail);
+ }
+ }
+ else if (pairState == PairState.ALREADY_IN_PROGRESS) {
+ message = getResources().getString(R.string.pair_already_in_progress);
+ }
+ else if (pairState == PairState.PAIRED) {
+ // Just navigate to the app view without displaying a toast
+ message = null;
+ success = true;
+
+ // Pin this certificate for later HTTPS use
+ managerBinder.getComputer(computer.uuid).serverCert = pm.getPairedCert();
+
+ // Invalidate reachability information after pairing to force
+ // a refresh before reading pair state again
+ managerBinder.invalidateStateForComputer(computer.uuid);
+ }
+ else {
+ // Should be no other values
+ message = null;
+ }
+ }
+ } catch (UnknownHostException e) {
+ message = getResources().getString(R.string.error_unknown_host);
+ } catch (FileNotFoundException e) {
+ message = getResources().getString(R.string.error_404);
+ } catch (XmlPullParserException | IOException e) {
+ e.printStackTrace();
+ message = e.getMessage();
+ }
+
+ Dialog.closeDialogs();
+
+ final String toastMessage = message;
+ final boolean toastSuccess = success;
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if (toastMessage != null) {
+ Toast.makeText(PcView.this, toastMessage, Toast.LENGTH_LONG).show();
+ }
+
+ if (toastSuccess) {
+ // Open the app list after a successful pairing attempt
+ doAppList(computer, true, false);
+ }
+ else {
+ // Start polling again if we're still in the foreground
+ startComputerUpdates();
+ }
+ }
+ });
+ }
+ }).start();
+ }
+
+ private void doOTPPair(final ComputerDetails computer) {
+ Context context = PcView.this;
+
+ LinearLayout layout = new LinearLayout(context);
+ layout.setOrientation(LinearLayout.VERTICAL);
+ layout.setPadding(50, 40, 50, 40);
+
+ final EditText otpInput = new EditText(context);
+ otpInput.setHint("PIN");
+ otpInput.setInputType(InputType.TYPE_CLASS_NUMBER);
+ otpInput.setFilters(new InputFilter[] { new InputFilter.LengthFilter(4) });
+
+ final EditText passphraseInput = new EditText(context);
+ passphraseInput.setHint(getString(R.string.pair_passphrase_hint));
+ passphraseInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
+
+ layout.addView(otpInput);
+ layout.addView(passphraseInput);
+
+ AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(context);
+ dialogBuilder.setTitle(R.string.pcview_menu_pair_pc_otp);
+ dialogBuilder.setView(layout);
+
+ dialogBuilder.setPositiveButton(getString(R.string.proceed), null);
+
+ dialogBuilder.setNegativeButton(getString(R.string.cancel), (dialog, which) -> dialog.dismiss());
+ AlertDialog dialog = dialogBuilder.create();
+ dialog.show();
+
+ dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> {
+ String pin = otpInput.getText().toString();
+ String passphrase = passphraseInput.getText().toString();
+ if (pin.length() != 4) {
+ Toast.makeText(context, getString(R.string.pair_pin_length_msg), Toast.LENGTH_SHORT).show();
+ return;
+ }
+ if (passphrase.length() < 4 ) {
+ Toast.makeText(context, getString(R.string.pair_passphrase_length_msg), Toast.LENGTH_SHORT).show();
+ return;
+ }
+ doPair(computer, pin, passphrase);
+ dialog.dismiss(); // Manually dismiss the dialog if the input is valid
+ });
+ }
+
+ private void doWakeOnLan(final ComputerDetails computer) {
+ if (computer.state == ComputerDetails.State.ONLINE) {
+ Toast.makeText(PcView.this, getResources().getString(R.string.wol_pc_online), Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ if (computer.macAddress == null) {
+ Toast.makeText(PcView.this, getResources().getString(R.string.wol_no_mac), Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ String message;
+ try {
+ WakeOnLanSender.sendWolPacket(computer);
+ message = getResources().getString(R.string.wol_waking_msg);
+ } catch (IOException e) {
+ message = getResources().getString(R.string.wol_fail);
+ }
+
+ final String toastMessage = message;
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ Toast.makeText(PcView.this, toastMessage, Toast.LENGTH_LONG).show();
+ }
+ });
+ }
+ }).start();
+ }
+
+ private void doUnpair(final ComputerDetails computer) {
+ if (computer.state == ComputerDetails.State.OFFLINE || computer.activeAddress == null) {
+ Toast.makeText(PcView.this, getResources().getString(R.string.error_pc_offline), Toast.LENGTH_SHORT).show();
+ return;
+ }
+ if (managerBinder == null) {
+ Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show();
+ return;
+ }
+
+ Toast.makeText(PcView.this, getResources().getString(R.string.unpairing), Toast.LENGTH_SHORT).show();
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ NvHTTP httpConn;
+ String message;
+ try {
+ httpConn = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer),
+ computer.httpsPort, managerBinder.getUniqueId(), computer.serverCert,
+ PlatformBinding.getCryptoProvider(PcView.this));
+ if (httpConn.getPairState() == PairState.PAIRED) {
+ httpConn.unpair();
+ if (httpConn.getPairState() == PairState.NOT_PAIRED) {
+ message = getResources().getString(R.string.unpair_success);
+ }
+ else {
+ message = getResources().getString(R.string.unpair_fail);
+ }
+ }
+ else {
+ message = getResources().getString(R.string.unpair_error);
+ }
+ } catch (UnknownHostException e) {
+ message = getResources().getString(R.string.error_unknown_host);
+ } catch (FileNotFoundException e) {
+ message = getResources().getString(R.string.error_404);
+ } catch (XmlPullParserException | IOException e) {
+ message = e.getMessage();
+ e.printStackTrace();
+ }
+
+ final String toastMessage = message;
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ Toast.makeText(PcView.this, toastMessage, Toast.LENGTH_LONG).show();
+ }
+ });
+ }
+ }).start();
+ }
+
+ private void doAppList(ComputerDetails computer, boolean newlyPaired, boolean showHiddenGames) {
+ if (computer.state == ComputerDetails.State.OFFLINE) {
+ Toast.makeText(PcView.this, getResources().getString(R.string.error_pc_offline), Toast.LENGTH_SHORT).show();
+ return;
+ }
+ if (managerBinder == null) {
+ Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show();
+ return;
+ }
+
+ Intent i = new Intent(this, AppView.class);
+ i.putExtra(AppView.NAME_EXTRA, computer.name);
+ i.putExtra(AppView.UUID_EXTRA, computer.uuid);
+ i.putExtra(AppView.NEW_PAIR_EXTRA, newlyPaired);
+ i.putExtra(AppView.SHOW_HIDDEN_APPS_EXTRA, showHiddenGames);
+ startActivity(i);
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem item) {
+ AdapterContextMenuInfo info = (AdapterContextMenuInfo) item.getMenuInfo();
+ final ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(info.position);
+ switch (item.getItemId()) {
+ case PAIR_ID:
+ doPair(computer.details, null, null);
+ return true;
+
+ case PAIR_ID_OTP:
+ doOTPPair(computer.details);
+ return true;
+
+ case UNPAIR_ID:
+ doUnpair(computer.details);
+ return true;
+
+ case WOL_ID:
+ doWakeOnLan(computer.details);
+ return true;
+
+ case DELETE_ID:
+ if (ActivityManager.isUserAMonkey()) {
+ LimeLog.info("Ignoring delete PC request from monkey");
+ return true;
+ }
+ UiHelper.displayDeletePcConfirmationDialog(this, computer.details, new Runnable() {
+ @Override
+ public void run() {
+ if (managerBinder == null) {
+ Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show();
+ return;
+ }
+ removeComputer(computer.details);
+ }
+ }, null);
+ return true;
+
+ case FULL_APP_LIST_ID:
+ doAppList(computer.details, false, true);
+ return true;
+
+ case RESUME_ID:
+ if (managerBinder == null) {
+ Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show();
+ return true;
+ }
+
+ ServerHelper.doStart(this, new NvApp("app", computer.details.runningGameId, false), computer.details, managerBinder, false);
+ return true;
+
+ case QUIT_ID:
+ if (managerBinder == null) {
+ Toast.makeText(PcView.this, getResources().getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show();
+ return true;
+ }
+
+ // Display a confirmation dialog first
+ UiHelper.displayQuitConfirmationDialog(this, new Runnable() {
+ @Override
+ public void run() {
+ ServerHelper.doQuit(PcView.this, computer.details,
+ new NvApp("app", 0, false), managerBinder, null);
+ }
+ }, null);
+ return true;
+
+ case VIEW_DETAILS_ID:
+ Dialog.displayDialog(PcView.this, getResources().getString(R.string.title_details), computer.details.toString(), false);
+ return true;
+
+ case TEST_NETWORK_ID:
+ ServerHelper.doNetworkTest(PcView.this);
+ return true;
+
+ case GAMESTREAM_EOL_ID:
+ HelpLauncher.launchGameStreamEolFaq(PcView.this);
+ return true;
+
+ case OPEN_MANAGEMENT_PAGE_ID:
+ String managementUrl = computer.guessManagementUrl();
+ if (managementUrl == null) {
+ Toast.makeText(PcView.this, getResources().getString(R.string.pcview_error_no_management_url), Toast.LENGTH_LONG).show();
+ } else {
+ HelpLauncher.launchUrl(PcView.this, managementUrl);
+ }
+
+ default:
+ return super.onContextItemSelected(item);
+ }
+ }
+
+ private void removeComputer(ComputerDetails details) {
+ managerBinder.removeComputer(details);
+
+ new DiskAssetLoader(this).deleteAssetsForComputer(details.uuid);
+
+ // Delete hidden games preference value
+ getSharedPreferences(AppView.HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE)
+ .edit()
+ .remove(details.uuid)
+ .apply();
+
+ for (int i = 0; i < pcGridAdapter.getCount(); i++) {
+ ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(i);
+
+ if (details.equals(computer.details)) {
+ // Disable or delete shortcuts referencing this PC
+ shortcutHelper.disableComputerShortcut(details,
+ getResources().getString(R.string.scut_deleted_pc));
+
+ pcGridAdapter.removeComputer(computer);
+ pcGridAdapter.notifyDataSetChanged();
+
+ if (pcGridAdapter.getCount() == 0) {
+ // Show the "Discovery in progress" view
+ noPcFoundLayout.setVisibility(View.VISIBLE);
+ }
+
+ break;
+ }
+ }
+ }
+
+ private void updateComputer(ComputerDetails details) {
+ ComputerObject existingEntry = null;
+
+ for (int i = 0; i < pcGridAdapter.getCount(); i++) {
+ ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(i);
+
+ // Check if this is the same computer
+ if (details.uuid.equals(computer.details.uuid)) {
+ existingEntry = computer;
+ break;
+ }
+ }
+
+ if (existingEntry != null) {
+ // Replace the information in the existing entry
+ existingEntry.details = details;
+ }
+ else {
+ // Add a new entry
+ pcGridAdapter.addComputer(new ComputerObject(details));
+
+ // Remove the "Discovery in progress" view
+ noPcFoundLayout.setVisibility(View.INVISIBLE);
+ }
+
+ // Notify the view that the data has changed
+ pcGridAdapter.notifyDataSetChanged();
+ }
+
+ @Override
+ public int getAdapterFragmentLayoutId() {
+ return R.layout.pc_grid_view;
+ }
+
+ @Override
+ public void receiveAbsListView(AbsListView listView) {
+ listView.setAdapter(pcGridAdapter);
+ listView.setOnItemClickListener(new OnItemClickListener() {
+ @Override
+ public void onItemClick(AdapterView> arg0, View arg1, int pos,
+ long id) {
+ ComputerObject computer = (ComputerObject) pcGridAdapter.getItem(pos);
+ if (computer.details.state == ComputerDetails.State.UNKNOWN ||
+ computer.details.state == ComputerDetails.State.OFFLINE) {
+ // Open the context menu if a PC is offline or refreshing
+ openContextMenu(arg1);
+ } else if (computer.details.pairState != PairState.PAIRED) {
+ // Pair an unpaired machine by default
+ doPair(computer.details, null, null);
+ } else {
+ doAppList(computer.details, false, false);
+ }
+ }
+ });
+ UiHelper.applyStatusBarPadding(listView);
+ registerForContextMenu(listView);
+ }
+
+ public static class ComputerObject {
+ public ComputerDetails details;
+
+ public ComputerObject(ComputerDetails details) {
+ if (details == null) {
+ throw new IllegalArgumentException("details must not be null");
+ }
+ this.details = details;
+ }
+
+ @Override
+ public String toString() {
+ return details.name;
+ }
+ public String guessManagementUrl() {
+ if (details.activeAddress == null) return null;
+ return "https://" + details.activeAddress.address + ":" + (details.guessExternalPort() + 1);
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/PosterContentProvider.java b/app/src/main/java/com/limelight/PosterContentProvider.java
old mode 100644
new mode 100755
index 0b45608460..f049e0bfc0
--- a/app/src/main/java/com/limelight/PosterContentProvider.java
+++ b/app/src/main/java/com/limelight/PosterContentProvider.java
@@ -1,107 +1,107 @@
-package com.limelight;
-
-import android.content.ContentProvider;
-import android.content.ContentResolver;
-import android.content.ContentValues;
-import android.content.UriMatcher;
-import android.database.Cursor;
-import android.net.Uri;
-import android.os.ParcelFileDescriptor;
-
-import com.limelight.grid.assets.DiskAssetLoader;
-
-import java.io.File;
-import java.io.FileNotFoundException;
-import java.util.List;
-
-public class PosterContentProvider extends ContentProvider {
-
-
- public static final String AUTHORITY = "poster." + BuildConfig.APPLICATION_ID;
- public static final String PNG_MIME_TYPE = "image/png";
- public static final int APP_ID_PATH_INDEX = 2;
- public static final int COMPUTER_UUID_PATH_INDEX = 1;
- private DiskAssetLoader mDiskAssetLoader;
-
- private static final UriMatcher sUriMatcher;
- private static final String BOXART_PATH = "boxart";
- private static final int BOXART_URI_ID = 1;
-
- static {
- sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
- sUriMatcher.addURI(AUTHORITY, BOXART_PATH, BOXART_URI_ID);
- }
-
- @Override
- public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
- int match = sUriMatcher.match(uri);
- if (match == BOXART_URI_ID) {
- return openBoxArtFile(uri, mode);
- }
- return openBoxArtFile(uri, mode);
-
- }
-
- public ParcelFileDescriptor openBoxArtFile(Uri uri, String mode) throws FileNotFoundException {
- if (!"r".equals(mode)) {
- throw new UnsupportedOperationException("This provider is only for read mode");
- }
-
- List segments = uri.getPathSegments();
- if (segments.size() != 3) {
- throw new FileNotFoundException();
- }
- String appId = segments.get(APP_ID_PATH_INDEX);
- String uuid = segments.get(COMPUTER_UUID_PATH_INDEX);
- File file = mDiskAssetLoader.getFile(uuid, Integer.parseInt(appId));
- if (file.exists()) {
- return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
- }
- throw new FileNotFoundException();
- }
-
- @Override
- public int delete(Uri uri, String selection, String[] selectionArgs) {
- throw new UnsupportedOperationException("This provider is only for read mode");
- }
-
- @Override
- public String getType(Uri uri) {
- return PNG_MIME_TYPE;
- }
-
- @Override
- public Uri insert(Uri uri, ContentValues values) {
- throw new UnsupportedOperationException("This provider is only for read mode");
- }
-
- @Override
- public boolean onCreate() {
- mDiskAssetLoader = new DiskAssetLoader(getContext());
- return true;
- }
-
- @Override
- public Cursor query(Uri uri, String[] projection, String selection,
- String[] selectionArgs, String sortOrder) {
- throw new UnsupportedOperationException("This provider doesn't support query");
- }
-
- @Override
- public int update(Uri uri, ContentValues values, String selection,
- String[] selectionArgs) {
- throw new UnsupportedOperationException("This provider is support read only");
- }
-
-
- public static Uri createBoxArtUri(String uuid, String appId) {
- return new Uri.Builder()
- .scheme(ContentResolver.SCHEME_CONTENT)
- .authority(AUTHORITY)
- .appendPath(BOXART_PATH)
- .appendPath(uuid)
- .appendPath(appId)
- .build();
- }
-
-}
+package com.limelight;
+
+import android.content.ContentProvider;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+
+import com.limelight.grid.assets.DiskAssetLoader;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.util.List;
+
+public class PosterContentProvider extends ContentProvider {
+
+
+ public static final String AUTHORITY = "poster." + BuildConfig.APPLICATION_ID;
+ public static final String PNG_MIME_TYPE = "image/png";
+ public static final int APP_ID_PATH_INDEX = 2;
+ public static final int COMPUTER_UUID_PATH_INDEX = 1;
+ private DiskAssetLoader mDiskAssetLoader;
+
+ private static final UriMatcher sUriMatcher;
+ private static final String BOXART_PATH = "boxart";
+ private static final int BOXART_URI_ID = 1;
+
+ static {
+ sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
+ sUriMatcher.addURI(AUTHORITY, BOXART_PATH, BOXART_URI_ID);
+ }
+
+ @Override
+ public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
+ int match = sUriMatcher.match(uri);
+ if (match == BOXART_URI_ID) {
+ return openBoxArtFile(uri, mode);
+ }
+ return openBoxArtFile(uri, mode);
+
+ }
+
+ public ParcelFileDescriptor openBoxArtFile(Uri uri, String mode) throws FileNotFoundException {
+ if (!"r".equals(mode)) {
+ throw new UnsupportedOperationException("This provider is only for read mode");
+ }
+
+ List segments = uri.getPathSegments();
+ if (segments.size() != 3) {
+ throw new FileNotFoundException();
+ }
+ String appId = segments.get(APP_ID_PATH_INDEX);
+ String uuid = segments.get(COMPUTER_UUID_PATH_INDEX);
+ File file = mDiskAssetLoader.getFile(uuid, Integer.parseInt(appId));
+ if (file.exists()) {
+ return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
+ }
+ throw new FileNotFoundException();
+ }
+
+ @Override
+ public int delete(Uri uri, String selection, String[] selectionArgs) {
+ throw new UnsupportedOperationException("This provider is only for read mode");
+ }
+
+ @Override
+ public String getType(Uri uri) {
+ return PNG_MIME_TYPE;
+ }
+
+ @Override
+ public Uri insert(Uri uri, ContentValues values) {
+ throw new UnsupportedOperationException("This provider is only for read mode");
+ }
+
+ @Override
+ public boolean onCreate() {
+ mDiskAssetLoader = new DiskAssetLoader(getContext());
+ return true;
+ }
+
+ @Override
+ public Cursor query(Uri uri, String[] projection, String selection,
+ String[] selectionArgs, String sortOrder) {
+ throw new UnsupportedOperationException("This provider doesn't support query");
+ }
+
+ @Override
+ public int update(Uri uri, ContentValues values, String selection,
+ String[] selectionArgs) {
+ throw new UnsupportedOperationException("This provider is support read only");
+ }
+
+
+ public static Uri createBoxArtUri(String uuid, String appId) {
+ return new Uri.Builder()
+ .scheme(ContentResolver.SCHEME_CONTENT)
+ .authority(AUTHORITY)
+ .appendPath(BOXART_PATH)
+ .appendPath(uuid)
+ .appendPath(appId)
+ .build();
+ }
+
+}
diff --git a/app/src/main/java/com/limelight/SecondaryDisplayPresentation.java b/app/src/main/java/com/limelight/SecondaryDisplayPresentation.java
new file mode 100755
index 0000000000..e88b70ee6b
--- /dev/null
+++ b/app/src/main/java/com/limelight/SecondaryDisplayPresentation.java
@@ -0,0 +1,45 @@
+package com.limelight;
+
+import android.app.Presentation;
+import android.content.Context;
+import android.os.Bundle;
+import android.view.Display;
+import android.view.View;
+import android.widget.FrameLayout;
+
+import com.limelight.ui.StreamView;
+
+/**
+ * Description
+ * Date: 2024-03-29
+ * Time: 17:26
+ */
+public class SecondaryDisplayPresentation extends Presentation {
+
+ private FrameLayout view;
+ public SecondaryDisplayPresentation(Context context, Display display) {
+ super(context, display);
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ view = (FrameLayout) View.inflate(getContext(),R.layout.activity_game_display,null);
+ setContentView(view);
+ }
+
+ public void addView(StreamView streamView){
+ view.addView(streamView);
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+ }
+
+ @Override
+ protected void onStop() {
+ super.onStop();
+ view.removeAllViews();
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/limelight/SensitivityBean.java b/app/src/main/java/com/limelight/SensitivityBean.java
new file mode 100755
index 0000000000..cae3760580
--- /dev/null
+++ b/app/src/main/java/com/limelight/SensitivityBean.java
@@ -0,0 +1,48 @@
+package com.limelight;
+
+/**
+ * Description
+ * Date: 2024-05-10
+ * Time: 23:35
+ */
+public class SensitivityBean {
+ //真实的坐标
+ private float lastAbsoluteX =-1;
+ private float lastAbsoluteY =-1;
+
+ //调整灵敏度后的坐标
+ private float lastRelativelyX =-1;
+ private float lastRelativelyY =-1;
+
+ public float getLastAbsoluteX() {
+ return lastAbsoluteX;
+ }
+
+ public void setLastAbsoluteX(float lastAbsoluteX) {
+ this.lastAbsoluteX = lastAbsoluteX;
+ }
+
+ public float getLastAbsoluteY() {
+ return lastAbsoluteY;
+ }
+
+ public void setLastAbsoluteY(float lastAbsoluteY) {
+ this.lastAbsoluteY = lastAbsoluteY;
+ }
+
+ public float getLastRelativelyX() {
+ return lastRelativelyX;
+ }
+
+ public void setLastRelativelyX(float lastRelativelyX) {
+ this.lastRelativelyX = lastRelativelyX;
+ }
+
+ public float getLastRelativelyY() {
+ return lastRelativelyY;
+ }
+
+ public void setLastRelativelyY(float lastRelativelyY) {
+ this.lastRelativelyY = lastRelativelyY;
+ }
+}
diff --git a/app/src/main/java/com/limelight/ShortcutTrampoline.java b/app/src/main/java/com/limelight/ShortcutTrampoline.java
old mode 100644
new mode 100755
index 6157379eca..2e7d08c476
--- a/app/src/main/java/com/limelight/ShortcutTrampoline.java
+++ b/app/src/main/java/com/limelight/ShortcutTrampoline.java
@@ -129,7 +129,7 @@ public void run() {
// Launch game if provided app ID, otherwise launch app view
if (app != null) {
if (details.runningGameId == 0 || details.runningGameId == app.getAppId()) {
- intentStack.add(ServerHelper.createStartIntent(ShortcutTrampoline.this, app, details, managerBinder));
+ intentStack.add(ServerHelper.createStartIntent(ShortcutTrampoline.this, app, details, managerBinder, false));
// Close this activity
finish();
@@ -139,7 +139,7 @@ public void run() {
} else {
// Create the start intent immediately, so we can safely unbind the managerBinder
// below before we return.
- final Intent startIntent = ServerHelper.createStartIntent(ShortcutTrampoline.this, app, details, managerBinder);
+ final Intent startIntent = ServerHelper.createStartIntent(ShortcutTrampoline.this, app, details, managerBinder, false);
UiHelper.displayQuitConfirmationDialog(ShortcutTrampoline.this, new Runnable() {
@Override
@@ -179,7 +179,7 @@ public void run() {
// If a game is running, we'll make the stream the top level activity
if (details.runningGameId != 0) {
intentStack.add(ServerHelper.createStartIntent(ShortcutTrampoline.this,
- new NvApp(null, details.runningGameId, false), details, managerBinder));
+ new NvApp(null, details.runningGameId, false), details, managerBinder, false));
}
// Now start the activities
diff --git a/app/src/main/java/com/limelight/binding/PlatformBinding.java b/app/src/main/java/com/limelight/binding/PlatformBinding.java
old mode 100644
new mode 100755
index 73bb2a308e..f7092b08f6
--- a/app/src/main/java/com/limelight/binding/PlatformBinding.java
+++ b/app/src/main/java/com/limelight/binding/PlatformBinding.java
@@ -1,14 +1,14 @@
-package com.limelight.binding;
-
-import android.content.Context;
-
-import com.limelight.binding.audio.AndroidAudioRenderer;
-import com.limelight.binding.crypto.AndroidCryptoProvider;
-import com.limelight.nvstream.av.audio.AudioRenderer;
-import com.limelight.nvstream.http.LimelightCryptoProvider;
-
-public class PlatformBinding {
- public static LimelightCryptoProvider getCryptoProvider(Context c) {
- return new AndroidCryptoProvider(c);
- }
-}
+package com.limelight.binding;
+
+import android.content.Context;
+
+import com.limelight.binding.audio.AndroidAudioRenderer;
+import com.limelight.binding.crypto.AndroidCryptoProvider;
+import com.limelight.nvstream.av.audio.AudioRenderer;
+import com.limelight.nvstream.http.LimelightCryptoProvider;
+
+public class PlatformBinding {
+ public static LimelightCryptoProvider getCryptoProvider(Context c) {
+ return new AndroidCryptoProvider(c);
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/audio/AndroidAudioRenderer.java b/app/src/main/java/com/limelight/binding/audio/AndroidAudioRenderer.java
old mode 100644
new mode 100755
index dc25cc8912..53cbf6879a
--- a/app/src/main/java/com/limelight/binding/audio/AndroidAudioRenderer.java
+++ b/app/src/main/java/com/limelight/binding/audio/AndroidAudioRenderer.java
@@ -1,233 +1,233 @@
-package com.limelight.binding.audio;
-
-import android.content.Context;
-import android.content.Intent;
-import android.media.AudioAttributes;
-import android.media.AudioFormat;
-import android.media.AudioManager;
-import android.media.AudioTrack;
-import android.media.audiofx.AudioEffect;
-import android.os.Build;
-
-import com.limelight.LimeLog;
-import com.limelight.nvstream.av.audio.AudioRenderer;
-import com.limelight.nvstream.jni.MoonBridge;
-
-public class AndroidAudioRenderer implements AudioRenderer {
-
- private final Context context;
- private final boolean enableAudioFx;
-
- private AudioTrack track;
-
- public AndroidAudioRenderer(Context context, boolean enableAudioFx) {
- this.context = context;
- this.enableAudioFx = enableAudioFx;
- }
-
- private AudioTrack createAudioTrack(int channelConfig, int sampleRate, int bufferSize, boolean lowLatency) {
- AudioAttributes.Builder attributesBuilder = new AudioAttributes.Builder()
- .setUsage(AudioAttributes.USAGE_GAME);
- AudioFormat format = new AudioFormat.Builder()
- .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
- .setSampleRate(sampleRate)
- .setChannelMask(channelConfig)
- .build();
-
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
- // Use FLAG_LOW_LATENCY on L through N
- if (lowLatency) {
- attributesBuilder.setFlags(AudioAttributes.FLAG_LOW_LATENCY);
- }
- }
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- AudioTrack.Builder trackBuilder = new AudioTrack.Builder()
- .setAudioFormat(format)
- .setAudioAttributes(attributesBuilder.build())
- .setTransferMode(AudioTrack.MODE_STREAM)
- .setBufferSizeInBytes(bufferSize);
-
- // Use PERFORMANCE_MODE_LOW_LATENCY on O and later
- if (lowLatency) {
- trackBuilder.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY);
- }
-
- return trackBuilder.build();
- }
- else {
- return new AudioTrack(attributesBuilder.build(),
- format,
- bufferSize,
- AudioTrack.MODE_STREAM,
- AudioManager.AUDIO_SESSION_ID_GENERATE);
- }
- }
-
- @Override
- public int setup(MoonBridge.AudioConfiguration audioConfiguration, int sampleRate, int samplesPerFrame) {
- int channelConfig;
- int bytesPerFrame;
-
- switch (audioConfiguration.channelCount)
- {
- case 2:
- channelConfig = AudioFormat.CHANNEL_OUT_STEREO;
- break;
- case 4:
- channelConfig = AudioFormat.CHANNEL_OUT_QUAD;
- break;
- case 6:
- channelConfig = AudioFormat.CHANNEL_OUT_5POINT1;
- break;
- case 8:
- // AudioFormat.CHANNEL_OUT_7POINT1_SURROUND isn't available until Android 6.0,
- // yet the CHANNEL_OUT_SIDE_LEFT and CHANNEL_OUT_SIDE_RIGHT constants were added
- // in 5.0, so just hardcode the constant so we can work on Lollipop.
- channelConfig = 0x000018fc; // AudioFormat.CHANNEL_OUT_7POINT1_SURROUND
- break;
- default:
- LimeLog.severe("Decoder returned unhandled channel count");
- return -1;
- }
-
- LimeLog.info("Audio channel config: "+String.format("0x%X", channelConfig));
-
- bytesPerFrame = audioConfiguration.channelCount * samplesPerFrame * 2;
-
- // We're not supposed to request less than the minimum
- // buffer size for our buffer, but it appears that we can
- // do this on many devices and it lowers audio latency.
- // We'll try the small buffer size first and if it fails,
- // use the recommended larger buffer size.
-
- for (int i = 0; i < 4; i++) {
- boolean lowLatency;
- int bufferSize;
-
- // We will try:
- // 1) Small buffer, low latency mode
- // 2) Large buffer, low latency mode
- // 3) Small buffer, standard mode
- // 4) Large buffer, standard mode
-
- switch (i) {
- case 0:
- case 1:
- lowLatency = true;
- break;
- case 2:
- case 3:
- lowLatency = false;
- break;
- default:
- // Unreachable
- throw new IllegalStateException();
- }
-
- switch (i) {
- case 0:
- case 2:
- bufferSize = bytesPerFrame * 2;
- break;
-
- case 1:
- case 3:
- // Try the larger buffer size
- bufferSize = Math.max(AudioTrack.getMinBufferSize(sampleRate,
- channelConfig,
- AudioFormat.ENCODING_PCM_16BIT),
- bytesPerFrame * 2);
-
- // Round to next frame
- bufferSize = (((bufferSize + (bytesPerFrame - 1)) / bytesPerFrame) * bytesPerFrame);
- break;
- default:
- // Unreachable
- throw new IllegalStateException();
- }
-
- // Skip low latency options if hardware sample rate doesn't match the content
- if (AudioTrack.getNativeOutputSampleRate(AudioManager.STREAM_MUSIC) != sampleRate && lowLatency) {
- continue;
- }
-
- // Skip low latency options when using audio effects, since low latency mode
- // precludes the use of the audio effect pipeline (as of Android 13).
- if (enableAudioFx && lowLatency) {
- continue;
- }
-
- try {
- track = createAudioTrack(channelConfig, sampleRate, bufferSize, lowLatency);
- track.play();
-
- // Successfully created working AudioTrack. We're done here.
- LimeLog.info("Audio track configuration: "+bufferSize+" "+lowLatency);
- break;
- } catch (Exception e) {
- // Try to release the AudioTrack if we got far enough
- e.printStackTrace();
- try {
- if (track != null) {
- track.release();
- track = null;
- }
- } catch (Exception ignored) {}
- }
- }
-
- if (track == null) {
- // Couldn't create any audio track for playback
- return -2;
- }
-
- return 0;
- }
-
- @Override
- public void playDecodedAudio(short[] audioData) {
- // Only queue up to 40 ms of pending audio data in addition to what AudioTrack is buffering for us.
- if (MoonBridge.getPendingAudioDuration() < 40) {
- // This will block until the write is completed. That can cause a backlog
- // of pending audio data, so we do the above check to be able to bound
- // latency at 40 ms in that situation.
- track.write(audioData, 0, audioData.length);
- }
- else {
- LimeLog.info("Too much pending audio data: " + MoonBridge.getPendingAudioDuration() +" ms");
- }
- }
-
- @Override
- public void start() {
- if (enableAudioFx) {
- // Open an audio effect control session to allow equalizers to apply audio effects
- Intent i = new Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION);
- i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, track.getAudioSessionId());
- i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.getPackageName());
- i.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_GAME);
- context.sendBroadcast(i);
- }
- }
-
- @Override
- public void stop() {
- if (enableAudioFx) {
- // Close our audio effect control session when we're stopping
- Intent i = new Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION);
- i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, track.getAudioSessionId());
- i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.getPackageName());
- context.sendBroadcast(i);
- }
- }
-
- @Override
- public void cleanup() {
- // Immediately drop all pending data
- track.pause();
- track.flush();
-
- track.release();
- }
-}
+package com.limelight.binding.audio;
+
+import android.content.Context;
+import android.content.Intent;
+import android.media.AudioAttributes;
+import android.media.AudioFormat;
+import android.media.AudioManager;
+import android.media.AudioTrack;
+import android.media.audiofx.AudioEffect;
+import android.os.Build;
+
+import com.limelight.LimeLog;
+import com.limelight.nvstream.av.audio.AudioRenderer;
+import com.limelight.nvstream.jni.MoonBridge;
+
+public class AndroidAudioRenderer implements AudioRenderer {
+
+ private final Context context;
+ private final boolean enableAudioFx;
+
+ private AudioTrack track;
+
+ public AndroidAudioRenderer(Context context, boolean enableAudioFx) {
+ this.context = context;
+ this.enableAudioFx = enableAudioFx;
+ }
+
+ private AudioTrack createAudioTrack(int channelConfig, int sampleRate, int bufferSize, boolean lowLatency) {
+ AudioAttributes.Builder attributesBuilder = new AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_GAME);
+ AudioFormat format = new AudioFormat.Builder()
+ .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
+ .setSampleRate(sampleRate)
+ .setChannelMask(channelConfig)
+ .build();
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ // Use FLAG_LOW_LATENCY on L through N
+ if (lowLatency) {
+ attributesBuilder.setFlags(AudioAttributes.FLAG_LOW_LATENCY);
+ }
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ AudioTrack.Builder trackBuilder = new AudioTrack.Builder()
+ .setAudioFormat(format)
+ .setAudioAttributes(attributesBuilder.build())
+ .setTransferMode(AudioTrack.MODE_STREAM)
+ .setBufferSizeInBytes(bufferSize);
+
+ // Use PERFORMANCE_MODE_LOW_LATENCY on O and later
+ if (lowLatency) {
+ trackBuilder.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY);
+ }
+
+ return trackBuilder.build();
+ }
+ else {
+ return new AudioTrack(attributesBuilder.build(),
+ format,
+ bufferSize,
+ AudioTrack.MODE_STREAM,
+ AudioManager.AUDIO_SESSION_ID_GENERATE);
+ }
+ }
+
+ @Override
+ public int setup(MoonBridge.AudioConfiguration audioConfiguration, int sampleRate, int samplesPerFrame) {
+ int channelConfig;
+ int bytesPerFrame;
+
+ switch (audioConfiguration.channelCount)
+ {
+ case 2:
+ channelConfig = AudioFormat.CHANNEL_OUT_STEREO;
+ break;
+ case 4:
+ channelConfig = AudioFormat.CHANNEL_OUT_QUAD;
+ break;
+ case 6:
+ channelConfig = AudioFormat.CHANNEL_OUT_5POINT1;
+ break;
+ case 8:
+ // AudioFormat.CHANNEL_OUT_7POINT1_SURROUND isn't available until Android 6.0,
+ // yet the CHANNEL_OUT_SIDE_LEFT and CHANNEL_OUT_SIDE_RIGHT constants were added
+ // in 5.0, so just hardcode the constant so we can work on Lollipop.
+ channelConfig = 0x000018fc; // AudioFormat.CHANNEL_OUT_7POINT1_SURROUND
+ break;
+ default:
+ LimeLog.severe("Decoder returned unhandled channel count");
+ return -1;
+ }
+
+ LimeLog.info("Audio channel config: "+String.format("0x%X", channelConfig));
+
+ bytesPerFrame = audioConfiguration.channelCount * samplesPerFrame * 2;
+
+ // We're not supposed to request less than the minimum
+ // buffer size for our buffer, but it appears that we can
+ // do this on many devices and it lowers audio latency.
+ // We'll try the small buffer size first and if it fails,
+ // use the recommended larger buffer size.
+
+ for (int i = 0; i < 4; i++) {
+ boolean lowLatency;
+ int bufferSize;
+
+ // We will try:
+ // 1) Small buffer, low latency mode
+ // 2) Large buffer, low latency mode
+ // 3) Small buffer, standard mode
+ // 4) Large buffer, standard mode
+
+ switch (i) {
+ case 0:
+ case 1:
+ lowLatency = true;
+ break;
+ case 2:
+ case 3:
+ lowLatency = false;
+ break;
+ default:
+ // Unreachable
+ throw new IllegalStateException();
+ }
+
+ switch (i) {
+ case 0:
+ case 2:
+ bufferSize = bytesPerFrame * 2;
+ break;
+
+ case 1:
+ case 3:
+ // Try the larger buffer size
+ bufferSize = Math.max(AudioTrack.getMinBufferSize(sampleRate,
+ channelConfig,
+ AudioFormat.ENCODING_PCM_16BIT),
+ bytesPerFrame * 2);
+
+ // Round to next frame
+ bufferSize = (((bufferSize + (bytesPerFrame - 1)) / bytesPerFrame) * bytesPerFrame);
+ break;
+ default:
+ // Unreachable
+ throw new IllegalStateException();
+ }
+
+ // Skip low latency options if hardware sample rate doesn't match the content
+ if (AudioTrack.getNativeOutputSampleRate(AudioManager.STREAM_MUSIC) != sampleRate && lowLatency) {
+ continue;
+ }
+
+ // Skip low latency options when using audio effects, since low latency mode
+ // precludes the use of the audio effect pipeline (as of Android 13).
+ if (enableAudioFx && lowLatency) {
+ continue;
+ }
+
+ try {
+ track = createAudioTrack(channelConfig, sampleRate, bufferSize, lowLatency);
+ track.play();
+
+ // Successfully created working AudioTrack. We're done here.
+ LimeLog.info("Audio track configuration: "+bufferSize+" "+lowLatency);
+ break;
+ } catch (Exception e) {
+ // Try to release the AudioTrack if we got far enough
+ e.printStackTrace();
+ try {
+ if (track != null) {
+ track.release();
+ track = null;
+ }
+ } catch (Exception ignored) {}
+ }
+ }
+
+ if (track == null) {
+ // Couldn't create any audio track for playback
+ return -2;
+ }
+
+ return 0;
+ }
+
+ @Override
+ public void playDecodedAudio(short[] audioData) {
+ // Only queue up to 40 ms of pending audio data in addition to what AudioTrack is buffering for us.
+ if (MoonBridge.getPendingAudioDuration() < 40) {
+ // This will block until the write is completed. That can cause a backlog
+ // of pending audio data, so we do the above check to be able to bound
+ // latency at 40 ms in that situation.
+ track.write(audioData, 0, audioData.length);
+ }
+ else {
+ LimeLog.info("Too much pending audio data: " + MoonBridge.getPendingAudioDuration() +" ms");
+ }
+ }
+
+ @Override
+ public void start() {
+ if (enableAudioFx) {
+ // Open an audio effect control session to allow equalizers to apply audio effects
+ Intent i = new Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION);
+ i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, track.getAudioSessionId());
+ i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.getPackageName());
+ i.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_GAME);
+ context.sendBroadcast(i);
+ }
+ }
+
+ @Override
+ public void stop() {
+ if (enableAudioFx) {
+ // Close our audio effect control session when we're stopping
+ Intent i = new Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION);
+ i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, track.getAudioSessionId());
+ i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.getPackageName());
+ context.sendBroadcast(i);
+ }
+ }
+
+ @Override
+ public void cleanup() {
+ // Immediately drop all pending data
+ track.pause();
+ track.flush();
+
+ track.release();
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/crypto/AndroidCryptoProvider.java b/app/src/main/java/com/limelight/binding/crypto/AndroidCryptoProvider.java
old mode 100644
new mode 100755
index b3252dba50..3e855429c9
--- a/app/src/main/java/com/limelight/binding/crypto/AndroidCryptoProvider.java
+++ b/app/src/main/java/com/limelight/binding/crypto/AndroidCryptoProvider.java
@@ -1,259 +1,259 @@
-package com.limelight.binding.crypto;
-
-import java.io.ByteArrayInputStream;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.OutputStreamWriter;
-import java.io.StringWriter;
-import java.math.BigInteger;
-import java.security.KeyFactory;
-import java.security.KeyPair;
-import java.security.KeyPairGenerator;
-import java.security.NoSuchAlgorithmException;
-import java.security.PrivateKey;
-import java.security.Provider;
-import java.security.SecureRandom;
-import java.security.cert.CertificateException;
-import java.security.cert.CertificateFactory;
-import java.security.cert.X509Certificate;
-import java.security.spec.InvalidKeySpecException;
-import java.security.spec.PKCS8EncodedKeySpec;
-import java.util.Calendar;
-import java.util.Date;
-import java.util.Locale;
-
-import org.bouncycastle.asn1.x500.X500Name;
-import org.bouncycastle.asn1.x500.X500NameBuilder;
-import org.bouncycastle.asn1.x500.style.BCStyle;
-import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
-import org.bouncycastle.cert.X509v3CertificateBuilder;
-import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
-import org.bouncycastle.jce.provider.BouncyCastleProvider;
-import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
-import org.bouncycastle.operator.ContentSigner;
-import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
-
-import android.annotation.SuppressLint;
-import android.content.Context;
-import android.util.Base64;
-
-import com.limelight.LimeLog;
-import com.limelight.nvstream.http.LimelightCryptoProvider;
-
-public class AndroidCryptoProvider implements LimelightCryptoProvider {
-
- private final File certFile;
- private final File keyFile;
-
- private X509Certificate cert;
- private PrivateKey key;
- private byte[] pemCertBytes;
-
- private static final Object globalCryptoLock = new Object();
-
- private static final Provider bcProvider = new BouncyCastleProvider();
-
- public AndroidCryptoProvider(Context c) {
- String dataPath = c.getFilesDir().getAbsolutePath();
-
- certFile = new File(dataPath + File.separator + "client.crt");
- keyFile = new File(dataPath + File.separator + "client.key");
- }
-
- private byte[] loadFileToBytes(File f) {
- if (!f.exists()) {
- return null;
- }
-
- try (final FileInputStream fin = new FileInputStream(f)) {
- byte[] fileData = new byte[(int) f.length()];
- if (fin.read(fileData) != f.length()) {
- // Failed to read
- fileData = null;
- }
- return fileData;
- } catch (IOException e) {
- return null;
- }
- }
-
- private boolean loadCertKeyPair() {
- byte[] certBytes = loadFileToBytes(certFile);
- byte[] keyBytes = loadFileToBytes(keyFile);
-
- // If either file was missing, we definitely can't succeed
- if (certBytes == null || keyBytes == null) {
- LimeLog.info("Missing cert or key; need to generate a new one");
- return false;
- }
-
- try {
- CertificateFactory certFactory = CertificateFactory.getInstance("X.509", bcProvider);
- cert = (X509Certificate) certFactory.generateCertificate(new ByteArrayInputStream(certBytes));
- pemCertBytes = certBytes;
- KeyFactory keyFactory = KeyFactory.getInstance("RSA", bcProvider);
- key = keyFactory.generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
- } catch (CertificateException e) {
- // May happen if the cert is corrupt
- LimeLog.warning("Corrupted certificate");
- return false;
- } catch (NoSuchAlgorithmException e) {
- throw new RuntimeException(e);
- } catch (InvalidKeySpecException e) {
- // May happen if the key is corrupt
- LimeLog.warning("Corrupted key");
- return false;
- }
-
- return true;
- }
-
- @SuppressLint("TrulyRandom")
- private boolean generateCertKeyPair() {
- byte[] snBytes = new byte[8];
- new SecureRandom().nextBytes(snBytes);
-
- KeyPair keyPair;
- try {
- KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", bcProvider);
- keyPairGenerator.initialize(2048);
- keyPair = keyPairGenerator.generateKeyPair();
- } catch (NoSuchAlgorithmException e) {
- throw new RuntimeException(e);
- }
-
- Date now = new Date();
-
- // Expires in 20 years
- Calendar calendar = Calendar.getInstance();
- calendar.setTime(now);
- calendar.add(Calendar.YEAR, 20);
- Date expirationDate = calendar.getTime();
-
- BigInteger serial = new BigInteger(snBytes).abs();
-
- X500NameBuilder nameBuilder = new X500NameBuilder(BCStyle.INSTANCE);
- nameBuilder.addRDN(BCStyle.CN, "NVIDIA GameStream Client");
- X500Name name = nameBuilder.build();
-
- X509v3CertificateBuilder certBuilder = new X509v3CertificateBuilder(name, serial, now, expirationDate, Locale.ENGLISH, name,
- SubjectPublicKeyInfo.getInstance(keyPair.getPublic().getEncoded()));
-
- try {
- ContentSigner sigGen = new JcaContentSignerBuilder("SHA256withRSA").setProvider(bcProvider).build(keyPair.getPrivate());
- cert = new JcaX509CertificateConverter().setProvider(bcProvider).getCertificate(certBuilder.build(sigGen));
- key = keyPair.getPrivate();
- } catch (Exception e) {
- throw new RuntimeException(e);
- }
-
- LimeLog.info("Generated a new key pair");
-
- // Save the resulting pair
- saveCertKeyPair();
-
- return true;
- }
-
- private void saveCertKeyPair() {
- try (final FileOutputStream certOut = new FileOutputStream(certFile);
- final FileOutputStream keyOut = new FileOutputStream(keyFile)
- ) {
- // Write the certificate in OpenSSL PEM format (important for the server)
- StringWriter strWriter = new StringWriter();
- try (final JcaPEMWriter pemWriter = new JcaPEMWriter(strWriter)) {
- pemWriter.writeObject(cert);
- }
-
- // Line endings MUST be UNIX for the PC to accept the cert properly
- try (final OutputStreamWriter certWriter = new OutputStreamWriter(certOut)) {
- String pemStr = strWriter.getBuffer().toString();
- for (int i = 0; i < pemStr.length(); i++) {
- char c = pemStr.charAt(i);
- if (c != '\r')
- certWriter.append(c);
- }
- }
-
- // Write the private out in PKCS8 format
- keyOut.write(key.getEncoded());
-
- LimeLog.info("Saved generated key pair to disk");
- } catch (IOException e) {
- // This isn't good because it means we'll have
- // to re-pair next time
- e.printStackTrace();
- }
- }
-
- public X509Certificate getClientCertificate() {
- // Use a lock here to ensure only one guy will be generating or loading
- // the certificate and key at a time
- synchronized (globalCryptoLock) {
- // Return a loaded cert if we have one
- if (cert != null) {
- return cert;
- }
-
- // No loaded cert yet, let's see if we have one on disk
- if (loadCertKeyPair()) {
- // Got one
- return cert;
- }
-
- // Try to generate a new key pair
- if (!generateCertKeyPair()) {
- // Failed
- return null;
- }
-
- // Load the generated pair
- loadCertKeyPair();
- return cert;
- }
- }
-
- public PrivateKey getClientPrivateKey() {
- // Use a lock here to ensure only one guy will be generating or loading
- // the certificate and key at a time
- synchronized (globalCryptoLock) {
- // Return a loaded key if we have one
- if (key != null) {
- return key;
- }
-
- // No loaded key yet, let's see if we have one on disk
- if (loadCertKeyPair()) {
- // Got one
- return key;
- }
-
- // Try to generate a new key pair
- if (!generateCertKeyPair()) {
- // Failed
- return null;
- }
-
- // Load the generated pair
- loadCertKeyPair();
- return key;
- }
- }
-
- public byte[] getPemEncodedClientCertificate() {
- synchronized (globalCryptoLock) {
- // Call our helper function to do the cert loading/generation for us
- getClientCertificate();
-
- // Return a cached value if we have it
- return pemCertBytes;
- }
- }
-
- @Override
- public String encodeBase64String(byte[] data) {
- return Base64.encodeToString(data, Base64.NO_WRAP);
- }
-}
+package com.limelight.binding.crypto;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.StringWriter;
+import java.math.BigInteger;
+import java.security.KeyFactory;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.Provider;
+import java.security.SecureRandom;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.Locale;
+
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.asn1.x500.X500NameBuilder;
+import org.bouncycastle.asn1.x500.style.BCStyle;
+import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
+import org.bouncycastle.cert.X509v3CertificateBuilder;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
+import org.bouncycastle.operator.ContentSigner;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.util.Base64;
+
+import com.limelight.LimeLog;
+import com.limelight.nvstream.http.LimelightCryptoProvider;
+
+public class AndroidCryptoProvider implements LimelightCryptoProvider {
+
+ private final File certFile;
+ private final File keyFile;
+
+ private X509Certificate cert;
+ private PrivateKey key;
+ private byte[] pemCertBytes;
+
+ private static final Object globalCryptoLock = new Object();
+
+ private static final Provider bcProvider = new BouncyCastleProvider();
+
+ public AndroidCryptoProvider(Context c) {
+ String dataPath = c.getFilesDir().getAbsolutePath();
+
+ certFile = new File(dataPath + File.separator + "client.crt");
+ keyFile = new File(dataPath + File.separator + "client.key");
+ }
+
+ private byte[] loadFileToBytes(File f) {
+ if (!f.exists()) {
+ return null;
+ }
+
+ try (final FileInputStream fin = new FileInputStream(f)) {
+ byte[] fileData = new byte[(int) f.length()];
+ if (fin.read(fileData) != f.length()) {
+ // Failed to read
+ fileData = null;
+ }
+ return fileData;
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ private boolean loadCertKeyPair() {
+ byte[] certBytes = loadFileToBytes(certFile);
+ byte[] keyBytes = loadFileToBytes(keyFile);
+
+ // If either file was missing, we definitely can't succeed
+ if (certBytes == null || keyBytes == null) {
+ LimeLog.info("Missing cert or key; need to generate a new one");
+ return false;
+ }
+
+ try {
+ CertificateFactory certFactory = CertificateFactory.getInstance("X.509", bcProvider);
+ cert = (X509Certificate) certFactory.generateCertificate(new ByteArrayInputStream(certBytes));
+ pemCertBytes = certBytes;
+ KeyFactory keyFactory = KeyFactory.getInstance("RSA", bcProvider);
+ key = keyFactory.generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
+ } catch (CertificateException e) {
+ // May happen if the cert is corrupt
+ LimeLog.warning("Corrupted certificate");
+ return false;
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ } catch (InvalidKeySpecException e) {
+ // May happen if the key is corrupt
+ LimeLog.warning("Corrupted key");
+ return false;
+ }
+
+ return true;
+ }
+
+ @SuppressLint("TrulyRandom")
+ private boolean generateCertKeyPair() {
+ byte[] snBytes = new byte[8];
+ new SecureRandom().nextBytes(snBytes);
+
+ KeyPair keyPair;
+ try {
+ KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", bcProvider);
+ keyPairGenerator.initialize(2048);
+ keyPair = keyPairGenerator.generateKeyPair();
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ }
+
+ Date now = new Date();
+
+ // Expires in 20 years
+ Calendar calendar = Calendar.getInstance();
+ calendar.setTime(now);
+ calendar.add(Calendar.YEAR, 20);
+ Date expirationDate = calendar.getTime();
+
+ BigInteger serial = new BigInteger(snBytes).abs();
+
+ X500NameBuilder nameBuilder = new X500NameBuilder(BCStyle.INSTANCE);
+ nameBuilder.addRDN(BCStyle.CN, "NVIDIA GameStream Client");
+ X500Name name = nameBuilder.build();
+
+ X509v3CertificateBuilder certBuilder = new X509v3CertificateBuilder(name, serial, now, expirationDate, Locale.ENGLISH, name,
+ SubjectPublicKeyInfo.getInstance(keyPair.getPublic().getEncoded()));
+
+ try {
+ ContentSigner sigGen = new JcaContentSignerBuilder("SHA256withRSA").setProvider(bcProvider).build(keyPair.getPrivate());
+ cert = new JcaX509CertificateConverter().setProvider(bcProvider).getCertificate(certBuilder.build(sigGen));
+ key = keyPair.getPrivate();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+
+ LimeLog.info("Generated a new key pair");
+
+ // Save the resulting pair
+ saveCertKeyPair();
+
+ return true;
+ }
+
+ private void saveCertKeyPair() {
+ try (final FileOutputStream certOut = new FileOutputStream(certFile);
+ final FileOutputStream keyOut = new FileOutputStream(keyFile)
+ ) {
+ // Write the certificate in OpenSSL PEM format (important for the server)
+ StringWriter strWriter = new StringWriter();
+ try (final JcaPEMWriter pemWriter = new JcaPEMWriter(strWriter)) {
+ pemWriter.writeObject(cert);
+ }
+
+ // Line endings MUST be UNIX for the PC to accept the cert properly
+ try (final OutputStreamWriter certWriter = new OutputStreamWriter(certOut)) {
+ String pemStr = strWriter.getBuffer().toString();
+ for (int i = 0; i < pemStr.length(); i++) {
+ char c = pemStr.charAt(i);
+ if (c != '\r')
+ certWriter.append(c);
+ }
+ }
+
+ // Write the private out in PKCS8 format
+ keyOut.write(key.getEncoded());
+
+ LimeLog.info("Saved generated key pair to disk");
+ } catch (IOException e) {
+ // This isn't good because it means we'll have
+ // to re-pair next time
+ e.printStackTrace();
+ }
+ }
+
+ public X509Certificate getClientCertificate() {
+ // Use a lock here to ensure only one guy will be generating or loading
+ // the certificate and key at a time
+ synchronized (globalCryptoLock) {
+ // Return a loaded cert if we have one
+ if (cert != null) {
+ return cert;
+ }
+
+ // No loaded cert yet, let's see if we have one on disk
+ if (loadCertKeyPair()) {
+ // Got one
+ return cert;
+ }
+
+ // Try to generate a new key pair
+ if (!generateCertKeyPair()) {
+ // Failed
+ return null;
+ }
+
+ // Load the generated pair
+ loadCertKeyPair();
+ return cert;
+ }
+ }
+
+ public PrivateKey getClientPrivateKey() {
+ // Use a lock here to ensure only one guy will be generating or loading
+ // the certificate and key at a time
+ synchronized (globalCryptoLock) {
+ // Return a loaded key if we have one
+ if (key != null) {
+ return key;
+ }
+
+ // No loaded key yet, let's see if we have one on disk
+ if (loadCertKeyPair()) {
+ // Got one
+ return key;
+ }
+
+ // Try to generate a new key pair
+ if (!generateCertKeyPair()) {
+ // Failed
+ return null;
+ }
+
+ // Load the generated pair
+ loadCertKeyPair();
+ return key;
+ }
+ }
+
+ public byte[] getPemEncodedClientCertificate() {
+ synchronized (globalCryptoLock) {
+ // Call our helper function to do the cert loading/generation for us
+ getClientCertificate();
+
+ // Return a cached value if we have it
+ return pemCertBytes;
+ }
+ }
+
+ @Override
+ public String encodeBase64String(byte[] data) {
+ return Base64.encodeToString(data, Base64.NO_WRAP);
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/ControllerHandler.java b/app/src/main/java/com/limelight/binding/input/ControllerHandler.java
old mode 100644
new mode 100755
index f7c8c0d116..dbb1f2f074
--- a/app/src/main/java/com/limelight/binding/input/ControllerHandler.java
+++ b/app/src/main/java/com/limelight/binding/input/ControllerHandler.java
@@ -1,3265 +1,3480 @@
-package com.limelight.binding.input;
-
-import android.annotation.TargetApi;
-import android.app.Activity;
-import android.content.Context;
-import android.hardware.BatteryState;
-import android.hardware.Sensor;
-import android.hardware.SensorEvent;
-import android.hardware.SensorEventListener;
-import android.hardware.SensorManager;
-import android.hardware.input.InputManager;
-import android.hardware.lights.Light;
-import android.hardware.lights.LightState;
-import android.hardware.lights.LightsManager;
-import android.hardware.lights.LightsRequest;
-import android.hardware.usb.UsbDevice;
-import android.hardware.usb.UsbManager;
-import android.media.AudioAttributes;
-import android.os.Build;
-import android.os.CombinedVibration;
-import android.os.Handler;
-import android.os.HandlerThread;
-import android.os.Looper;
-import android.os.VibrationAttributes;
-import android.os.VibrationEffect;
-import android.os.Vibrator;
-import android.os.VibratorManager;
-import android.util.SparseArray;
-import android.view.InputDevice;
-import android.view.InputEvent;
-import android.view.KeyEvent;
-import android.view.MotionEvent;
-import android.view.Surface;
-import android.widget.Toast;
-
-import com.limelight.LimeLog;
-import com.limelight.R;
-import com.limelight.binding.input.driver.AbstractController;
-import com.limelight.binding.input.driver.UsbDriverListener;
-import com.limelight.binding.input.driver.UsbDriverService;
-import com.limelight.nvstream.NvConnection;
-import com.limelight.nvstream.input.ControllerPacket;
-import com.limelight.nvstream.input.MouseButtonPacket;
-import com.limelight.nvstream.jni.MoonBridge;
-import com.limelight.preferences.PreferenceConfiguration;
-import com.limelight.ui.GameGestures;
-import com.limelight.utils.Vector2d;
-
-import org.cgutman.shieldcontrollerextensions.SceChargingState;
-import org.cgutman.shieldcontrollerextensions.SceConnectionType;
-import org.cgutman.shieldcontrollerextensions.SceManager;
-
-import java.lang.reflect.InvocationTargetException;
-import java.util.Map;
-
-public class ControllerHandler implements InputManager.InputDeviceListener, UsbDriverListener {
-
- private static final int MAXIMUM_BUMPER_UP_DELAY_MS = 100;
-
- private static final int START_DOWN_TIME_MOUSE_MODE_MS = 750;
-
- private static final int MINIMUM_BUTTON_DOWN_TIME_MS = 25;
-
- private static final int EMULATING_SPECIAL = 0x1;
- private static final int EMULATING_SELECT = 0x2;
- private static final int EMULATING_TOUCHPAD = 0x4;
-
- private static final short MAX_GAMEPADS = 16; // Limited by bits in activeGamepadMask
-
- private static final int BATTERY_RECHECK_INTERVAL_MS = 120 * 1000;
-
- private static final Map ANDROID_TO_LI_BUTTON_MAP = Map.ofEntries(
- Map.entry(KeyEvent.KEYCODE_BUTTON_A, ControllerPacket.A_FLAG),
- Map.entry(KeyEvent.KEYCODE_BUTTON_B, ControllerPacket.B_FLAG),
- Map.entry(KeyEvent.KEYCODE_BUTTON_X, ControllerPacket.X_FLAG),
- Map.entry(KeyEvent.KEYCODE_BUTTON_Y, ControllerPacket.Y_FLAG),
- Map.entry(KeyEvent.KEYCODE_DPAD_UP, ControllerPacket.UP_FLAG),
- Map.entry(KeyEvent.KEYCODE_DPAD_DOWN, ControllerPacket.DOWN_FLAG),
- Map.entry(KeyEvent.KEYCODE_DPAD_LEFT, ControllerPacket.LEFT_FLAG),
- Map.entry(KeyEvent.KEYCODE_DPAD_RIGHT, ControllerPacket.RIGHT_FLAG),
- Map.entry(KeyEvent.KEYCODE_DPAD_UP_LEFT, ControllerPacket.UP_FLAG | ControllerPacket.LEFT_FLAG),
- Map.entry(KeyEvent.KEYCODE_DPAD_UP_RIGHT, ControllerPacket.UP_FLAG | ControllerPacket.RIGHT_FLAG),
- Map.entry(KeyEvent.KEYCODE_DPAD_DOWN_LEFT, ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG),
- Map.entry(KeyEvent.KEYCODE_DPAD_DOWN_RIGHT, ControllerPacket.DOWN_FLAG | ControllerPacket.RIGHT_FLAG),
- Map.entry(KeyEvent.KEYCODE_BUTTON_L1, ControllerPacket.LB_FLAG),
- Map.entry(KeyEvent.KEYCODE_BUTTON_R1, ControllerPacket.RB_FLAG),
- Map.entry(KeyEvent.KEYCODE_BUTTON_THUMBL, ControllerPacket.LS_CLK_FLAG),
- Map.entry(KeyEvent.KEYCODE_BUTTON_THUMBR, ControllerPacket.RS_CLK_FLAG),
- Map.entry(KeyEvent.KEYCODE_BUTTON_START, ControllerPacket.PLAY_FLAG),
- Map.entry(KeyEvent.KEYCODE_MENU, ControllerPacket.PLAY_FLAG),
- Map.entry(KeyEvent.KEYCODE_BUTTON_SELECT, ControllerPacket.BACK_FLAG),
- Map.entry(KeyEvent.KEYCODE_BACK, ControllerPacket.BACK_FLAG),
- Map.entry(KeyEvent.KEYCODE_BUTTON_MODE, ControllerPacket.SPECIAL_BUTTON_FLAG),
-
- // This is the Xbox Series X Share button
- Map.entry(KeyEvent.KEYCODE_MEDIA_RECORD, ControllerPacket.MISC_FLAG),
-
- // This is a weird one, but it's what Android does prior to 4.10 kernels
- // where DualShock/DualSense touchpads weren't mapped as separate devices.
- // https://android.googlesource.com/platform/frameworks/base/+/master/data/keyboards/Vendor_054c_Product_0ce6_fallback.kl
- // https://android.googlesource.com/platform/frameworks/base/+/master/data/keyboards/Vendor_054c_Product_09cc.kl
- Map.entry(KeyEvent.KEYCODE_BUTTON_1, ControllerPacket.TOUCHPAD_FLAG)
-
- // FIXME: Paddles?
- );
-
- private final Vector2d inputVector = new Vector2d();
-
- private final SparseArray inputDeviceContexts = new SparseArray<>();
- private final SparseArray usbDeviceContexts = new SparseArray<>();
-
- private final NvConnection conn;
- private final Activity activityContext;
- private final double stickDeadzone;
- private final InputDeviceContext defaultContext = new InputDeviceContext();
- private final GameGestures gestures;
- private final InputManager inputManager;
- private final Vibrator deviceVibrator;
- private final VibratorManager deviceVibratorManager;
- private final SensorManager deviceSensorManager;
- private final SceManager sceManager;
- private final Handler mainThreadHandler;
- private final HandlerThread backgroundHandlerThread;
- private final Handler backgroundThreadHandler;
- private boolean hasGameController;
- private boolean stopped = false;
-
- private final PreferenceConfiguration prefConfig;
- private short currentControllers, initialControllers;
-
- public ControllerHandler(Activity activityContext, NvConnection conn, GameGestures gestures, PreferenceConfiguration prefConfig) {
- this.activityContext = activityContext;
- this.conn = conn;
- this.gestures = gestures;
- this.prefConfig = prefConfig;
- this.deviceVibrator = (Vibrator) activityContext.getSystemService(Context.VIBRATOR_SERVICE);
- this.deviceSensorManager = (SensorManager) activityContext.getSystemService(Context.SENSOR_SERVICE);
- this.inputManager = (InputManager) activityContext.getSystemService(Context.INPUT_SERVICE);
- this.mainThreadHandler = new Handler(Looper.getMainLooper());
-
- // Create a HandlerThread to process battery state updates. These can be slow enough
- // that they lead to ANRs if we do them on the main thread.
- this.backgroundHandlerThread = new HandlerThread("ControllerHandler");
- this.backgroundHandlerThread.start();
- this.backgroundThreadHandler = new Handler(backgroundHandlerThread.getLooper());
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- this.deviceVibratorManager = (VibratorManager) activityContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE);
- }
- else {
- this.deviceVibratorManager = null;
- }
-
- this.sceManager = new SceManager(activityContext);
- this.sceManager.start();
-
- int deadzonePercentage = prefConfig.deadzonePercentage;
-
- int[] ids = InputDevice.getDeviceIds();
- for (int id : ids) {
- InputDevice dev = InputDevice.getDevice(id);
- if (dev == null) {
- // This device was removed during enumeration
- continue;
- }
- if ((dev.getSources() & InputDevice.SOURCE_JOYSTICK) != 0 ||
- (dev.getSources() & InputDevice.SOURCE_GAMEPAD) != 0) {
- // This looks like a gamepad, but we'll check X and Y to be sure
- if (getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_X) != null &&
- getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_Y) != null) {
- // This is a gamepad
- hasGameController = true;
- }
- }
- }
-
- // 1% is the lowest possible deadzone we support
- if (deadzonePercentage <= 0) {
- deadzonePercentage = 1;
- }
-
- this.stickDeadzone = (double)deadzonePercentage / 100.0;
-
- // Initialize the default context for events with no device
- defaultContext.leftStickXAxis = MotionEvent.AXIS_X;
- defaultContext.leftStickYAxis = MotionEvent.AXIS_Y;
- defaultContext.leftStickDeadzoneRadius = (float) stickDeadzone;
- defaultContext.rightStickXAxis = MotionEvent.AXIS_Z;
- defaultContext.rightStickYAxis = MotionEvent.AXIS_RZ;
- defaultContext.rightStickDeadzoneRadius = (float) stickDeadzone;
- defaultContext.leftTriggerAxis = MotionEvent.AXIS_BRAKE;
- defaultContext.rightTriggerAxis = MotionEvent.AXIS_GAS;
- defaultContext.hatXAxis = MotionEvent.AXIS_HAT_X;
- defaultContext.hatYAxis = MotionEvent.AXIS_HAT_Y;
- defaultContext.controllerNumber = (short) 0;
- defaultContext.assignedControllerNumber = true;
- defaultContext.external = false;
-
- // Some devices (GPD XD) have a back button which sends input events
- // with device ID == 0. This hits the default context which would normally
- // consume these. Instead, let's ignore them since that's probably the
- // most likely case.
- defaultContext.ignoreBack = true;
-
- // Get the initially attached set of gamepads. As each gamepad receives
- // its initial InputEvent, we will move these from this set onto the
- // currentControllers set which will allow them to properly unplug
- // if they are removed.
- initialControllers = getAttachedControllerMask(activityContext);
-
- // Register ourselves for input device notifications
- inputManager.registerInputDeviceListener(this, null);
- }
-
- private static InputDevice.MotionRange getMotionRangeForJoystickAxis(InputDevice dev, int axis) {
- InputDevice.MotionRange range;
-
- // First get the axis for SOURCE_JOYSTICK
- range = dev.getMotionRange(axis, InputDevice.SOURCE_JOYSTICK);
- if (range == null) {
- // Now try the axis for SOURCE_GAMEPAD
- range = dev.getMotionRange(axis, InputDevice.SOURCE_GAMEPAD);
- }
-
- return range;
- }
-
- @Override
- public void onInputDeviceAdded(int deviceId) {
- // Nothing happening here yet
- }
-
- @Override
- public void onInputDeviceRemoved(int deviceId) {
- InputDeviceContext context = inputDeviceContexts.get(deviceId);
- if (context != null) {
- LimeLog.info("Removed controller: "+context.name+" ("+deviceId+")");
- releaseControllerNumber(context);
- context.destroy();
- inputDeviceContexts.remove(deviceId);
- }
- }
-
- // This can happen when gaining/losing input focus with some devices.
- // Input devices that have a trackpad may gain/lose AXIS_RELATIVE_X/Y.
- @Override
- public void onInputDeviceChanged(int deviceId) {
- InputDevice device = InputDevice.getDevice(deviceId);
- if (device == null) {
- return;
- }
-
- // If we don't have a context for this device, we don't need to update anything
- InputDeviceContext existingContext = inputDeviceContexts.get(deviceId);
- if (existingContext == null) {
- return;
- }
-
- LimeLog.info("Device changed: "+existingContext.name+" ("+deviceId+")");
-
- // Migrate the existing context into this new one by moving any stateful elements
- InputDeviceContext newContext = createInputDeviceContextForDevice(device);
- newContext.migrateContext(existingContext);
- inputDeviceContexts.put(deviceId, newContext);
- }
-
- public void stop() {
- if (stopped) {
- return;
- }
-
- // Stop new device contexts from being created or used
- stopped = true;
-
- // Unregister our input device callbacks
- inputManager.unregisterInputDeviceListener(this);
-
- for (int i = 0; i < inputDeviceContexts.size(); i++) {
- InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i);
- deviceContext.destroy();
- }
-
- for (int i = 0; i < usbDeviceContexts.size(); i++) {
- UsbDeviceContext deviceContext = usbDeviceContexts.valueAt(i);
- deviceContext.destroy();
- }
-
- deviceVibrator.cancel();
- }
-
- public void destroy() {
- if (!stopped) {
- stop();
- }
-
- sceManager.stop();
- backgroundHandlerThread.quit();
- }
-
- public void disableSensors() {
- for (int i = 0; i < inputDeviceContexts.size(); i++) {
- InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i);
- deviceContext.disableSensors();
- }
- }
-
- public void enableSensors() {
- if (stopped) {
- return;
- }
-
- for (int i = 0; i < inputDeviceContexts.size(); i++) {
- InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i);
- deviceContext.enableSensors();
- }
- }
-
- private static boolean hasJoystickAxes(InputDevice device) {
- return (device.getSources() & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK &&
- getMotionRangeForJoystickAxis(device, MotionEvent.AXIS_X) != null &&
- getMotionRangeForJoystickAxis(device, MotionEvent.AXIS_Y) != null;
- }
-
- private static boolean hasGamepadButtons(InputDevice device) {
- return (device.getSources() & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD;
- }
-
- public static boolean isGameControllerDevice(InputDevice device) {
- if (device == null) {
- return true;
- }
-
- if (hasJoystickAxes(device) || hasGamepadButtons(device)) {
- // Has real joystick axes or gamepad buttons
- return true;
- }
-
- // HACK for https://issuetracker.google.com/issues/163120692
- if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) {
- if (device.getId() == -1) {
- // This "virtual" device could be input from any of the attached devices.
- // Look to see if any gamepads are connected.
- int[] ids = InputDevice.getDeviceIds();
- for (int id : ids) {
- InputDevice dev = InputDevice.getDevice(id);
- if (dev == null) {
- // This device was removed during enumeration
- continue;
- }
-
- // If there are any gamepad devices connected, we'll
- // report that this virtual device is a gamepad.
- if (hasJoystickAxes(dev) || hasGamepadButtons(dev)) {
- return true;
- }
- }
- }
- }
-
- // Otherwise, we'll try anything that claims to be a non-alphabetic keyboard
- return device.getKeyboardType() != InputDevice.KEYBOARD_TYPE_ALPHABETIC;
- }
-
- public static short getAttachedControllerMask(Context context) {
- int count = 0;
- short mask = 0;
-
- // Count all input devices that are gamepads
- InputManager im = (InputManager) context.getSystemService(Context.INPUT_SERVICE);
- for (int id : im.getInputDeviceIds()) {
- InputDevice dev = im.getInputDevice(id);
- if (dev == null) {
- continue;
- }
-
- if (hasJoystickAxes(dev)) {
- LimeLog.info("Counting InputDevice: "+dev.getName());
- mask |= 1 << count++;
- }
- }
-
- // Count all USB devices that match our drivers
- if (PreferenceConfiguration.readPreferences(context).usbDriver) {
- UsbManager usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE);
- if (usbManager != null) {
- for (UsbDevice dev : usbManager.getDeviceList().values()) {
- // We explicitly check not to claim devices that appear as InputDevices
- // otherwise we will double count them.
- if (UsbDriverService.shouldClaimDevice(dev, false) &&
- !UsbDriverService.isRecognizedInputDevice(dev)) {
- LimeLog.info("Counting UsbDevice: "+dev.getDeviceName());
- mask |= 1 << count++;
- }
- }
- }
- }
-
- if (PreferenceConfiguration.readPreferences(context).onscreenController) {
- LimeLog.info("Counting OSC gamepad");
- mask |= 1;
- }
-
- LimeLog.info("Enumerated "+count+" gamepads");
- return mask;
- }
-
- private void releaseControllerNumber(GenericControllerContext context) {
- // If we reserved a controller number, remove that reservation
- if (context.reservedControllerNumber) {
- LimeLog.info("Controller number "+context.controllerNumber+" is now available");
- currentControllers &= ~(1 << context.controllerNumber);
- }
-
- // If this device sent data as a gamepad, zero the values before removing.
- // We must do this after clearing the currentControllers entry so this
- // causes the device to be removed on the server PC.
- if (context.assignedControllerNumber) {
- conn.sendControllerInput(context.controllerNumber, getActiveControllerMask(),
- (short) 0,
- (byte) 0, (byte) 0,
- (short) 0, (short) 0,
- (short) 0, (short) 0);
- }
- }
-
- private boolean isAssociatedJoystick(InputDevice originalDevice, InputDevice possibleAssociatedJoystick) {
- if (possibleAssociatedJoystick == null) {
- return false;
- }
-
- // This can't be an associated joystick if it's not a joystick
- if ((possibleAssociatedJoystick.getSources() & InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK) {
- return false;
- }
-
- // Make sure the device names *don't* match in order to prevent us from accidentally matching
- // on another of the exact same device.
- if (possibleAssociatedJoystick.getName().equals(originalDevice.getName())) {
- return false;
- }
-
- // Make sure the descriptor matches. This can match in cases where two of the exact same
- // input device are connected, so we perform the name check to exclude that case.
- if (!possibleAssociatedJoystick.getDescriptor().equals(originalDevice.getDescriptor())) {
- return false;
- }
-
- return true;
- }
-
- // Called before sending input but after we've determined that this
- // is definitely a controller (not a keyboard, mouse, or something else)
- private void assignControllerNumberIfNeeded(GenericControllerContext context) {
- if (context.assignedControllerNumber) {
- return;
- }
-
- if (context instanceof InputDeviceContext) {
- InputDeviceContext devContext = (InputDeviceContext) context;
-
- LimeLog.info(devContext.name+" ("+context.id+") needs a controller number assigned");
- if (!devContext.external) {
- LimeLog.info("Built-in buttons hardcoded as controller 0");
- context.controllerNumber = 0;
- }
- else if (prefConfig.multiController && devContext.hasJoystickAxes) {
- context.controllerNumber = 0;
-
- LimeLog.info("Reserving the next available controller number");
- for (short i = 0; i < MAX_GAMEPADS; i++) {
- if ((currentControllers & (1 << i)) == 0) {
- // Found an unused controller value
- currentControllers |= (1 << i);
-
- // Take this value out of the initial gamepad set
- initialControllers &= ~(1 << i);
-
- context.controllerNumber = i;
- context.reservedControllerNumber = true;
- break;
- }
- }
- }
- else if (!devContext.hasJoystickAxes) {
- // If this device doesn't have joystick axes, it may be an input device associated
- // with another joystick (like a PS4 touchpad). We'll propagate that joystick's
- // controller number to this associated device.
-
- context.controllerNumber = 0;
-
- // For the DS4 case, the associated joystick is the next device after the touchpad.
- // We'll try the opposite case too, just to be a little future-proof.
- InputDevice associatedDevice = InputDevice.getDevice(devContext.id + 1);
- if (!isAssociatedJoystick(devContext.inputDevice, associatedDevice)) {
- associatedDevice = InputDevice.getDevice(devContext.id - 1);
- if (!isAssociatedJoystick(devContext.inputDevice, associatedDevice)) {
- LimeLog.info("No associated joystick device found");
- associatedDevice = null;
- }
- }
-
- if (associatedDevice != null) {
- InputDeviceContext associatedDeviceContext = inputDeviceContexts.get(associatedDevice.getId());
-
- // Create a new context for the associated device if one doesn't exist
- if (associatedDeviceContext == null) {
- associatedDeviceContext = createInputDeviceContextForDevice(associatedDevice);
- inputDeviceContexts.put(associatedDevice.getId(), associatedDeviceContext);
- }
-
- // Assign a controller number for the associated device if one isn't assigned
- if (!associatedDeviceContext.assignedControllerNumber) {
- assignControllerNumberIfNeeded(associatedDeviceContext);
- }
-
- // Propagate the associated controller number
- context.controllerNumber = associatedDeviceContext.controllerNumber;
-
- LimeLog.info("Propagated controller number from "+associatedDeviceContext.name);
- }
- }
- else {
- LimeLog.info("Not reserving a controller number");
- context.controllerNumber = 0;
- }
-
- // If the gamepad doesn't have motion sensors, use the on-device sensors as a fallback for player 1
- if (prefConfig.gamepadMotionSensorsFallbackToDevice && context.controllerNumber == 0 && devContext.sensorManager == null) {
- devContext.sensorManager = deviceSensorManager;
- }
- }
- else {
- if (prefConfig.multiController) {
- context.controllerNumber = 0;
-
- LimeLog.info("Reserving the next available controller number");
- for (short i = 0; i < MAX_GAMEPADS; i++) {
- if ((currentControllers & (1 << i)) == 0) {
- // Found an unused controller value
- currentControllers |= (1 << i);
-
- // Take this value out of the initial gamepad set
- initialControllers &= ~(1 << i);
-
- context.controllerNumber = i;
- context.reservedControllerNumber = true;
- break;
- }
- }
- }
- else {
- LimeLog.info("Not reserving a controller number");
- context.controllerNumber = 0;
- }
- }
-
- LimeLog.info("Assigned as controller "+context.controllerNumber);
- context.assignedControllerNumber = true;
-
- // Report attributes of this new controller to the host
- context.sendControllerArrival();
- }
-
- private UsbDeviceContext createUsbDeviceContextForDevice(AbstractController device) {
- UsbDeviceContext context = new UsbDeviceContext();
-
- context.id = device.getControllerId();
- context.device = device;
- context.external = true;
-
- context.vendorId = device.getVendorId();
- context.productId = device.getProductId();
-
- context.leftStickDeadzoneRadius = (float) stickDeadzone;
- context.rightStickDeadzoneRadius = (float) stickDeadzone;
- context.triggerDeadzone = 0.13f;
-
- return context;
- }
-
- private static boolean hasButtonUnderTouchpad(InputDevice dev, byte type) {
- // It has to have a touchpad to have a button under it
- if ((dev.getSources() & InputDevice.SOURCE_TOUCHPAD) != InputDevice.SOURCE_TOUCHPAD) {
- return false;
- }
-
- // Landroid/view/InputDevice;->hasButtonUnderPad()Z is blocked after O
- if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.O) {
- try {
- return (Boolean) dev.getClass().getMethod("hasButtonUnderPad").invoke(dev);
- } catch (NoSuchMethodException e) {
- e.printStackTrace();
- } catch (IllegalAccessException e) {
- e.printStackTrace();
- } catch (InvocationTargetException e) {
- e.printStackTrace();
- } catch (ClassCastException e) {
- e.printStackTrace();
- }
- }
-
- // We can't use the platform API, so we'll have to just guess based on the gamepad type.
- // If this is a PlayStation controller with a touchpad, we know it has a clickpad.
- return type == MoonBridge.LI_CTYPE_PS;
- }
-
- private static boolean isExternal(InputDevice dev) {
- // The ASUS Tinker Board inaccurately reports Bluetooth gamepads as internal,
- // causing shouldIgnoreBack() to believe it should pass through back as a
- // navigation event for any attached gamepads.
- if (Build.MODEL.equals("Tinker Board")) {
- return true;
- }
-
- String deviceName = dev.getName();
- if (deviceName.contains("gpio") || // This is the back button on Shield portable consoles
- deviceName.contains("joy_key") || // These are the gamepad buttons on the Archos Gamepad 2
- deviceName.contains("keypad") || // These are gamepad buttons on the XPERIA Play
- deviceName.equalsIgnoreCase("NVIDIA Corporation NVIDIA Controller v01.01") || // Gamepad on Shield Portable
- deviceName.equalsIgnoreCase("NVIDIA Corporation NVIDIA Controller v01.02") || // Gamepad on Shield Portable (?)
- deviceName.equalsIgnoreCase("GR0006") // Gamepad on Logitech G Cloud
- )
- {
- LimeLog.info(dev.getName()+" is internal by hardcoded mapping");
- return false;
- }
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- // Landroid/view/InputDevice;->isExternal()Z is officially public on Android Q
- return dev.isExternal();
- }
- else {
- try {
- // Landroid/view/InputDevice;->isExternal()Z is on the light graylist in Android P
- return (Boolean)dev.getClass().getMethod("isExternal").invoke(dev);
- } catch (NoSuchMethodException e) {
- e.printStackTrace();
- } catch (IllegalAccessException e) {
- e.printStackTrace();
- } catch (InvocationTargetException e) {
- e.printStackTrace();
- } catch (ClassCastException e) {
- e.printStackTrace();
- }
- }
-
- // Answer true if we don't know
- return true;
- }
-
- private boolean shouldIgnoreBack(InputDevice dev) {
- String devName = dev.getName();
-
- // The Serval has a Select button but the framework doesn't
- // know about that because it uses a non-standard scancode.
- if (devName.contains("Razer Serval")) {
- return true;
- }
-
- // Classify this device as a remote by name if it has no joystick axes
- if (!hasJoystickAxes(dev) && devName.toLowerCase().contains("remote")) {
- return true;
- }
-
- // Otherwise, dynamically try to determine whether we should allow this
- // back button to function for navigation.
- //
- // First, check if this is an internal device we're being called on.
- if (!isExternal(dev)) {
- InputManager im = (InputManager) activityContext.getSystemService(Context.INPUT_SERVICE);
-
- boolean foundInternalGamepad = false;
- boolean foundInternalSelect = false;
- for (int id : im.getInputDeviceIds()) {
- InputDevice currentDev = im.getInputDevice(id);
-
- // Ignore external devices
- if (currentDev == null || isExternal(currentDev)) {
- continue;
- }
-
- // Note that we are explicitly NOT excluding the current device we're examining here,
- // since the other gamepad buttons may be on our current device and that's fine.
- if (currentDev.hasKeys(KeyEvent.KEYCODE_BUTTON_SELECT)[0]) {
- foundInternalSelect = true;
- }
-
- // We don't check KEYCODE_BUTTON_A here, since the Shield Android TV has a
- // virtual mouse device that claims to have KEYCODE_BUTTON_A. Instead, we rely
- // on the SOURCE_GAMEPAD flag to be set on gamepad devices.
- if (hasGamepadButtons(currentDev)) {
- foundInternalGamepad = true;
- }
- }
-
- // Allow the back button to function for navigation if we either:
- // a) have no internal gamepad (most phones)
- // b) have an internal gamepad but also have an internal select button (GPD XD)
- // but not:
- // c) have an internal gamepad but no internal select button (NVIDIA SHIELD Portable)
- return !foundInternalGamepad || foundInternalSelect;
- }
- else {
- // For external devices, we want to pass through the back button if the device
- // has no gamepad axes or gamepad buttons.
- return !hasJoystickAxes(dev) && !hasGamepadButtons(dev);
- }
- }
-
- private InputDeviceContext createInputDeviceContextForDevice(InputDevice dev) {
- InputDeviceContext context = new InputDeviceContext();
- String devName = dev.getName();
-
- LimeLog.info("Creating controller context for device: "+devName);
- LimeLog.info("Vendor ID: " + dev.getVendorId());
- LimeLog.info("Product ID: "+dev.getProductId());
- LimeLog.info(dev.toString());
-
- context.inputDevice = dev;
- context.name = devName;
- context.id = dev.getId();
- context.external = isExternal(dev);
-
- context.vendorId = dev.getVendorId();
- context.productId = dev.getProductId();
-
- // These aren't always present in the Android key layout files, so they won't show up
- // in our normal InputDevice.hasKeys() probing.
- context.hasPaddles = MoonBridge.guessControllerHasPaddles(context.vendorId, context.productId);
- context.hasShare = MoonBridge.guessControllerHasShareButton(context.vendorId, context.productId);
-
- // Try to use the InputDevice's associated vibrators first
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && hasQuadAmplitudeControlledRumbleVibrators(dev.getVibratorManager())) {
- context.vibratorManager = dev.getVibratorManager();
- context.quadVibrators = true;
- }
- else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && hasDualAmplitudeControlledRumbleVibrators(dev.getVibratorManager())) {
- context.vibratorManager = dev.getVibratorManager();
- context.quadVibrators = false;
- }
- else if (dev.getVibrator().hasVibrator()) {
- context.vibrator = dev.getVibrator();
- }
- else if (!context.external) {
- // If this is an internal controller, try to use the device's vibrator
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && hasQuadAmplitudeControlledRumbleVibrators(deviceVibratorManager)) {
- context.vibratorManager = deviceVibratorManager;
- context.quadVibrators = true;
- }
- else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && hasDualAmplitudeControlledRumbleVibrators(deviceVibratorManager)) {
- context.vibratorManager = deviceVibratorManager;
- context.quadVibrators = false;
- }
- else if (deviceVibrator.hasVibrator()) {
- context.vibrator = deviceVibrator;
- }
- }
-
- // On Android 12, we can try to use the InputDevice's sensors. This may not work if the
- // Linux kernel version doesn't have motion sensor support, which is common for third-party
- // gamepads.
- //
- // Android 12 has a bug that causes InputDeviceSensorManager to cause a NPE on a background
- // thread due to bad error checking in InputListener callbacks. InputDeviceSensorManager is
- // created upon the first call to InputDevice.getSensorManager(), so we avoid calling this
- // on Android 12 unless we have a gamepad that could plausibly have motion sensors.
- // https://cs.android.com/android/_/android/platform/frameworks/base/+/8970010a5e9f3dc5c069f56b4147552accfcbbeb
- if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ||
- (Build.VERSION.SDK_INT == Build.VERSION_CODES.S &&
- (context.vendorId == 0x054c || context.vendorId == 0x057e))) && // Sony or Nintendo
- prefConfig.gamepadMotionSensors) {
- if (dev.getSensorManager().getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null || dev.getSensorManager().getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null) {
- context.sensorManager = dev.getSensorManager();
- }
- }
-
- // Check if this device has a usable RGB LED and cache that result
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- for (Light light : dev.getLightsManager().getLights()) {
- if (light.hasRgbControl()) {
- context.hasRgbLed = true;
- break;
- }
- }
- }
-
- // Detect if the gamepad has Mode and Select buttons according to the Android key layouts.
- // We do this first because other codepaths below may override these defaults.
- boolean[] buttons = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_MODE, KeyEvent.KEYCODE_BUTTON_SELECT, KeyEvent.KEYCODE_BACK, 0);
- context.hasMode = buttons[0];
- context.hasSelect = buttons[1] || buttons[2];
-
- context.touchpadXRange = dev.getMotionRange(MotionEvent.AXIS_X, InputDevice.SOURCE_TOUCHPAD);
- context.touchpadYRange = dev.getMotionRange(MotionEvent.AXIS_Y, InputDevice.SOURCE_TOUCHPAD);
- context.touchpadPressureRange = dev.getMotionRange(MotionEvent.AXIS_PRESSURE, InputDevice.SOURCE_TOUCHPAD);
-
- context.leftStickXAxis = MotionEvent.AXIS_X;
- context.leftStickYAxis = MotionEvent.AXIS_Y;
- if (getMotionRangeForJoystickAxis(dev, context.leftStickXAxis) != null &&
- getMotionRangeForJoystickAxis(dev, context.leftStickYAxis) != null) {
- // This is a gamepad
- hasGameController = true;
- context.hasJoystickAxes = true;
- }
-
- // This is hack to deal with the Nvidia Shield's modifications that causes the DS4 clickpad
- // to work as a duplicate Select button instead of a unique button we can handle separately.
- context.isDualShockStandaloneTouchpad =
- context.vendorId == 0x054c && // Sony
- devName.endsWith(" Touchpad") &&
- dev.getSources() == (InputDevice.SOURCE_KEYBOARD | InputDevice.SOURCE_MOUSE);
-
- InputDevice.MotionRange leftTriggerRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_LTRIGGER);
- InputDevice.MotionRange rightTriggerRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RTRIGGER);
- InputDevice.MotionRange brakeRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_BRAKE);
- InputDevice.MotionRange gasRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_GAS);
- InputDevice.MotionRange throttleRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_THROTTLE);
- if (leftTriggerRange != null && rightTriggerRange != null)
- {
- // Some controllers use LTRIGGER and RTRIGGER (like Ouya)
- context.leftTriggerAxis = MotionEvent.AXIS_LTRIGGER;
- context.rightTriggerAxis = MotionEvent.AXIS_RTRIGGER;
- }
- else if (brakeRange != null && gasRange != null)
- {
- // Others use GAS and BRAKE (like Moga)
- context.leftTriggerAxis = MotionEvent.AXIS_BRAKE;
- context.rightTriggerAxis = MotionEvent.AXIS_GAS;
- }
- else if (brakeRange != null && throttleRange != null)
- {
- // Others use THROTTLE and BRAKE (like Xiaomi)
- context.leftTriggerAxis = MotionEvent.AXIS_BRAKE;
- context.rightTriggerAxis = MotionEvent.AXIS_THROTTLE;
- }
- else
- {
- InputDevice.MotionRange rxRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RX);
- InputDevice.MotionRange ryRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RY);
- if (rxRange != null && ryRange != null && devName != null) {
- if (dev.getVendorId() == 0x054c) { // Sony
- if (dev.hasKeys(KeyEvent.KEYCODE_BUTTON_C)[0]) {
- LimeLog.info("Detected non-standard DualShock 4 mapping");
- context.isNonStandardDualShock4 = true;
- } else {
- LimeLog.info("Detected DualShock 4 (Linux standard mapping)");
- context.usesLinuxGamepadStandardFaceButtons = true;
- }
- }
-
- if (context.isNonStandardDualShock4) {
- // The old DS4 driver uses RX and RY for triggers
- context.leftTriggerAxis = MotionEvent.AXIS_RX;
- context.rightTriggerAxis = MotionEvent.AXIS_RY;
-
- // DS4 has Select and Mode buttons (possibly mapped non-standard)
- context.hasSelect = true;
- context.hasMode = true;
- }
- else {
- // If it's not a non-standard DS4 controller, it's probably an Xbox controller or
- // other sane controller that uses RX and RY for right stick and Z and RZ for triggers.
- context.rightStickXAxis = MotionEvent.AXIS_RX;
- context.rightStickYAxis = MotionEvent.AXIS_RY;
-
- // While it's likely that Z and RZ are triggers, we may have digital trigger buttons
- // instead. We must check that we actually have Z and RZ axes before assigning them.
- if (getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_Z) != null &&
- getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RZ) != null) {
- context.leftTriggerAxis = MotionEvent.AXIS_Z;
- context.rightTriggerAxis = MotionEvent.AXIS_RZ;
- }
- }
-
- // Triggers always idle negative on axes that are centered at zero
- context.triggersIdleNegative = true;
- }
- }
-
- if (context.rightStickXAxis == -1 && context.rightStickYAxis == -1) {
- InputDevice.MotionRange zRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_Z);
- InputDevice.MotionRange rzRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RZ);
-
- // Most other controllers use Z and RZ for the right stick
- if (zRange != null && rzRange != null) {
- context.rightStickXAxis = MotionEvent.AXIS_Z;
- context.rightStickYAxis = MotionEvent.AXIS_RZ;
- }
- else {
- InputDevice.MotionRange rxRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RX);
- InputDevice.MotionRange ryRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RY);
-
- // Try RX and RY now
- if (rxRange != null && ryRange != null) {
- context.rightStickXAxis = MotionEvent.AXIS_RX;
- context.rightStickYAxis = MotionEvent.AXIS_RY;
- }
- }
- }
-
- // Some devices have "hats" for d-pads
- InputDevice.MotionRange hatXRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_HAT_X);
- InputDevice.MotionRange hatYRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_HAT_Y);
- if (hatXRange != null && hatYRange != null) {
- context.hatXAxis = MotionEvent.AXIS_HAT_X;
- context.hatYAxis = MotionEvent.AXIS_HAT_Y;
- }
-
- if (context.leftStickXAxis != -1 && context.leftStickYAxis != -1) {
- context.leftStickDeadzoneRadius = (float) stickDeadzone;
- }
-
- if (context.rightStickXAxis != -1 && context.rightStickYAxis != -1) {
- context.rightStickDeadzoneRadius = (float) stickDeadzone;
- }
-
- if (context.leftTriggerAxis != -1 && context.rightTriggerAxis != -1) {
- InputDevice.MotionRange ltRange = getMotionRangeForJoystickAxis(dev, context.leftTriggerAxis);
- InputDevice.MotionRange rtRange = getMotionRangeForJoystickAxis(dev, context.rightTriggerAxis);
-
- // It's important to have a valid deadzone so controller packet batching works properly
- context.triggerDeadzone = Math.max(Math.abs(ltRange.getFlat()), Math.abs(rtRange.getFlat()));
-
- // For triggers without (valid) deadzones, we'll use 13% (around XInput's default)
- if (context.triggerDeadzone < 0.13f ||
- context.triggerDeadzone > 0.30f)
- {
- context.triggerDeadzone = 0.13f;
- }
- }
-
- // The ADT-1 controller needs a similar fixup to the ASUS Gamepad
- if (dev.getVendorId() == 0x18d1 && dev.getProductId() == 0x2c40) {
- context.backIsStart = true;
- context.modeIsSelect = true;
- context.triggerDeadzone = 0.30f;
- context.hasSelect = true;
- context.hasMode = false;
- }
-
- context.ignoreBack = shouldIgnoreBack(dev);
-
- if (devName != null) {
- // For the Nexus Player (and probably other ATV devices), we should
- // use the back button as start since it doesn't have a start/menu button
- // on the controller
- if (devName.contains("ASUS Gamepad")) {
- boolean[] hasStartKey = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_START, KeyEvent.KEYCODE_MENU, 0);
- if (!hasStartKey[0] && !hasStartKey[1]) {
- context.backIsStart = true;
- context.modeIsSelect = true;
- context.hasSelect = true;
- context.hasMode = false;
- }
-
- // The ASUS Gamepad has triggers that sit far forward and are prone to false presses
- // so we increase the deadzone on them to minimize this
- context.triggerDeadzone = 0.30f;
- }
- // SHIELD controllers will use small stick deadzones
- else if (devName.contains("SHIELD") || devName.contains("NVIDIA Controller")) {
- // The big Nvidia button on the Shield controllers acts like a Search button. It
- // summons the Google Assistant on the Shield TV. On my Pixel 4, it seems to do
- // nothing, so we can hijack it to act like a mode button.
- if (devName.contains("NVIDIA Controller v01.03") || devName.contains("NVIDIA Controller v01.04")) {
- context.searchIsMode = true;
- context.hasMode = true;
- }
- }
- // The Serval has a couple of unknown buttons that are start and select. It also has
- // a back button which we want to ignore since there's already a select button.
- else if (devName.contains("Razer Serval")) {
- context.isServal = true;
-
- // Serval has Select and Mode buttons (possibly mapped non-standard)
- context.hasMode = true;
- context.hasSelect = true;
- }
- // The Xbox One S Bluetooth controller has some mappings that need fixing up.
- // However, Microsoft released a firmware update with no change to VID/PID
- // or device name that fixed the mappings for Android. Since there's
- // no good way to detect this, we'll use the presence of GAS/BRAKE axes
- // that were added in the latest firmware. If those are present, the only
- // required fixup is ignoring the select button.
- else if (devName.equals("Xbox Wireless Controller")) {
- if (gasRange == null) {
- context.isNonStandardXboxBtController = true;
-
- // Xbox One S has Select and Mode buttons (possibly mapped non-standard)
- context.hasMode = true;
- context.hasSelect = true;
- }
- }
- }
-
- // Thrustmaster Score A gamepad home button reports directly to android as
- // KEY_HOMEPAGE event on another event channel
- if (dev.getVendorId() == 0x044f && dev.getProductId() == 0xb328) {
- context.hasMode = false;
- }
-
- LimeLog.info("Analog stick deadzone: "+context.leftStickDeadzoneRadius+" "+context.rightStickDeadzoneRadius);
- LimeLog.info("Trigger deadzone: "+context.triggerDeadzone);
-
- return context;
- }
-
- private InputDeviceContext getContextForEvent(InputEvent event) {
- // Don't return a context if we're stopped
- if (stopped) {
- return null;
- }
- else if (event.getDeviceId() == 0) {
- // Unknown devices use the default context
- return defaultContext;
- }
- else if (event.getDevice() == null) {
- // During device removal, sometimes we can get events after the
- // input device has been destroyed. In this case we'll see a
- // != 0 device ID but no device attached.
- return null;
- }
-
- // HACK for https://issuetracker.google.com/issues/163120692
- if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) {
- if (event.getDeviceId() == -1) {
- return defaultContext;
- }
- }
-
- // Return the existing context if it exists
- InputDeviceContext context = inputDeviceContexts.get(event.getDeviceId());
- if (context != null) {
- return context;
- }
-
- // Otherwise create a new context
- context = createInputDeviceContextForDevice(event.getDevice());
- inputDeviceContexts.put(event.getDeviceId(), context);
-
- return context;
- }
-
- private byte maxByMagnitude(byte a, byte b) {
- int absA = Math.abs(a);
- int absB = Math.abs(b);
- if (absA > absB) {
- return a;
- }
- else {
- return b;
- }
- }
-
- private short maxByMagnitude(short a, short b) {
- int absA = Math.abs(a);
- int absB = Math.abs(b);
- if (absA > absB) {
- return a;
- }
- else {
- return b;
- }
- }
-
- private short getActiveControllerMask() {
- if (prefConfig.multiController) {
- return (short)(currentControllers | initialControllers | (prefConfig.onscreenController ? 1 : 0));
- }
- else {
- // Only Player 1 is active with multi-controller disabled
- return 1;
- }
- }
-
- private static boolean areBatteryCapacitiesEqual(float first, float second) {
- // With no NaNs involved, it is a simple equality comparison.
- if (!Float.isNaN(first) && !Float.isNaN(second)) {
- return first == second;
- }
- else {
- // If we have a NaN in one or both positions, compare NaN-ness instead.
- // Equality comparisons will always return false for NaN.
- return Float.isNaN(first) == Float.isNaN(second);
- }
- }
-
- // This must not be called on the main thread due to risk of ANRs!
- private void sendControllerBatteryPacket(InputDeviceContext context) {
- int currentBatteryStatus;
- float currentBatteryCapacity;
-
- // Use the BatteryState object introduced in Android S, if it's available and present.
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && context.inputDevice.getBatteryState().isPresent()) {
- currentBatteryStatus = context.inputDevice.getBatteryState().getStatus();
- currentBatteryCapacity = context.inputDevice.getBatteryState().getCapacity();
- }
- else if (sceManager.isRecognizedDevice(context.inputDevice)) {
- // On the SHIELD Android TV, we can use a proprietary API to access battery/charge state.
- // We will convert it to the same form used by BatteryState to share code.
- int batteryPercentage = sceManager.getBatteryPercentage(context.inputDevice);
- if (batteryPercentage < 0) {
- currentBatteryCapacity = Float.NaN;
- }
- else {
- currentBatteryCapacity = batteryPercentage / 100.f;
- }
-
- SceConnectionType connectionType = sceManager.getConnectionType(context.inputDevice);
- SceChargingState chargingState = sceManager.getChargingState(context.inputDevice);
-
- // We can make some assumptions about charge state based on the connection type
- if (connectionType == SceConnectionType.WIRED || connectionType == SceConnectionType.BOTH) {
- if (batteryPercentage == 100) {
- currentBatteryStatus = BatteryState.STATUS_FULL;
- }
- else if (chargingState == SceChargingState.NOT_CHARGING) {
- currentBatteryStatus = BatteryState.STATUS_NOT_CHARGING;
- }
- else {
- currentBatteryStatus = BatteryState.STATUS_CHARGING;
- }
- }
- else if (connectionType == SceConnectionType.WIRELESS) {
- if (chargingState == SceChargingState.CHARGING) {
- currentBatteryStatus = BatteryState.STATUS_CHARGING;
- }
- else {
- currentBatteryStatus = BatteryState.STATUS_DISCHARGING;
- }
- }
- else {
- // If connection type is unknown, just use the charge state
- if (batteryPercentage == 100) {
- currentBatteryStatus = BatteryState.STATUS_FULL;
- }
- else if (chargingState == SceChargingState.NOT_CHARGING) {
- currentBatteryStatus = BatteryState.STATUS_DISCHARGING;
- }
- else if (chargingState == SceChargingState.CHARGING) {
- currentBatteryStatus = BatteryState.STATUS_CHARGING;
- }
- else {
- currentBatteryStatus = BatteryState.STATUS_UNKNOWN;
- }
- }
- }
- else {
- return;
- }
-
- if (currentBatteryStatus != context.lastReportedBatteryStatus ||
- !areBatteryCapacitiesEqual(currentBatteryCapacity, context.lastReportedBatteryCapacity)) {
- byte state;
- byte percentage;
-
- switch (currentBatteryStatus) {
- case BatteryState.STATUS_UNKNOWN:
- state = MoonBridge.LI_BATTERY_STATE_UNKNOWN;
- break;
-
- case BatteryState.STATUS_CHARGING:
- state = MoonBridge.LI_BATTERY_STATE_CHARGING;
- break;
-
- case BatteryState.STATUS_DISCHARGING:
- state = MoonBridge.LI_BATTERY_STATE_DISCHARGING;
- break;
-
- case BatteryState.STATUS_NOT_CHARGING:
- state = MoonBridge.LI_BATTERY_STATE_NOT_CHARGING;
- break;
-
- case BatteryState.STATUS_FULL:
- state = MoonBridge.LI_BATTERY_STATE_FULL;
- break;
-
- default:
- return;
- }
-
- if (Float.isNaN(currentBatteryCapacity)) {
- percentage = MoonBridge.LI_BATTERY_PERCENTAGE_UNKNOWN;
- }
- else {
- percentage = (byte)(currentBatteryCapacity * 100);
- }
-
- conn.sendControllerBatteryEvent((byte)context.controllerNumber, state, percentage);
-
- context.lastReportedBatteryStatus = currentBatteryStatus;
- context.lastReportedBatteryCapacity = currentBatteryCapacity;
- }
- }
-
- private void sendControllerInputPacket(GenericControllerContext originalContext) {
- assignControllerNumberIfNeeded(originalContext);
-
- // Take the context's controller number and fuse all inputs with the same number
- short controllerNumber = originalContext.controllerNumber;
- int inputMap = 0;
- byte leftTrigger = 0;
- byte rightTrigger = 0;
- short leftStickX = 0;
- short leftStickY = 0;
- short rightStickX = 0;
- short rightStickY = 0;
-
- // In order to properly handle controllers that are split into multiple devices,
- // we must aggregate all controllers with the same controller number into a single
- // device before we send it.
- for (int i = 0; i < inputDeviceContexts.size(); i++) {
- GenericControllerContext context = inputDeviceContexts.valueAt(i);
- if (context.assignedControllerNumber &&
- context.controllerNumber == controllerNumber &&
- context.mouseEmulationActive == originalContext.mouseEmulationActive) {
- inputMap |= context.inputMap;
- leftTrigger |= maxByMagnitude(leftTrigger, context.leftTrigger);
- rightTrigger |= maxByMagnitude(rightTrigger, context.rightTrigger);
- leftStickX |= maxByMagnitude(leftStickX, context.leftStickX);
- leftStickY |= maxByMagnitude(leftStickY, context.leftStickY);
- rightStickX |= maxByMagnitude(rightStickX, context.rightStickX);
- rightStickY |= maxByMagnitude(rightStickY, context.rightStickY);
- }
- }
- for (int i = 0; i < usbDeviceContexts.size(); i++) {
- GenericControllerContext context = usbDeviceContexts.valueAt(i);
- if (context.assignedControllerNumber &&
- context.controllerNumber == controllerNumber &&
- context.mouseEmulationActive == originalContext.mouseEmulationActive) {
- inputMap |= context.inputMap;
- leftTrigger |= maxByMagnitude(leftTrigger, context.leftTrigger);
- rightTrigger |= maxByMagnitude(rightTrigger, context.rightTrigger);
- leftStickX |= maxByMagnitude(leftStickX, context.leftStickX);
- leftStickY |= maxByMagnitude(leftStickY, context.leftStickY);
- rightStickX |= maxByMagnitude(rightStickX, context.rightStickX);
- rightStickY |= maxByMagnitude(rightStickY, context.rightStickY);
- }
- }
- if (defaultContext.controllerNumber == controllerNumber) {
- inputMap |= defaultContext.inputMap;
- leftTrigger |= maxByMagnitude(leftTrigger, defaultContext.leftTrigger);
- rightTrigger |= maxByMagnitude(rightTrigger, defaultContext.rightTrigger);
- leftStickX |= maxByMagnitude(leftStickX, defaultContext.leftStickX);
- leftStickY |= maxByMagnitude(leftStickY, defaultContext.leftStickY);
- rightStickX |= maxByMagnitude(rightStickX, defaultContext.rightStickX);
- rightStickY |= maxByMagnitude(rightStickY, defaultContext.rightStickY);
- }
-
- if (originalContext.mouseEmulationActive) {
- int changedMask = inputMap ^ originalContext.mouseEmulationLastInputMap;
-
- boolean aDown = (inputMap & ControllerPacket.A_FLAG) != 0;
- boolean bDown = (inputMap & ControllerPacket.B_FLAG) != 0;
-
- originalContext.mouseEmulationLastInputMap = inputMap;
-
- if ((changedMask & ControllerPacket.A_FLAG) != 0) {
- if (aDown) {
- conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_LEFT);
- }
- else {
- conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT);
- }
- }
- if ((changedMask & ControllerPacket.B_FLAG) != 0) {
- if (bDown) {
- conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_RIGHT);
- }
- else {
- conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT);
- }
- }
- if ((changedMask & ControllerPacket.UP_FLAG) != 0) {
- if ((inputMap & ControllerPacket.UP_FLAG) != 0) {
- conn.sendMouseScroll((byte) 1);
- }
- }
- if ((changedMask & ControllerPacket.DOWN_FLAG) != 0) {
- if ((inputMap & ControllerPacket.DOWN_FLAG) != 0) {
- conn.sendMouseScroll((byte) -1);
- }
- }
- if ((changedMask & ControllerPacket.RIGHT_FLAG) != 0) {
- if ((inputMap & ControllerPacket.RIGHT_FLAG) != 0) {
- conn.sendMouseHScroll((byte) 1);
- }
- }
- if ((changedMask & ControllerPacket.LEFT_FLAG) != 0) {
- if ((inputMap & ControllerPacket.LEFT_FLAG) != 0) {
- conn.sendMouseHScroll((byte) -1);
- }
- }
-
- conn.sendControllerInput(controllerNumber, getActiveControllerMask(),
- (short)0, (byte)0, (byte)0, (short)0, (short)0, (short)0, (short)0);
- }
- else {
- conn.sendControllerInput(controllerNumber, getActiveControllerMask(),
- inputMap,
- leftTrigger, rightTrigger,
- leftStickX, leftStickY,
- rightStickX, rightStickY);
- }
- }
-
- private final int REMAP_IGNORE = -1;
- private final int REMAP_CONSUME = -2;
-
- // Return a valid keycode, -2 to consume, or -1 to not consume the event
- // Device MAY BE NULL
- private int handleRemapping(InputDeviceContext context, KeyEvent event) {
- // Don't capture the back button if configured
- if (context.ignoreBack) {
- if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) {
- return REMAP_IGNORE;
- }
- }
-
- // If we know this gamepad has a share button and receive an unmapped
- // KEY_RECORD event, report that as a share button press.
- if (context.hasShare) {
- if (event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN &&
- event.getScanCode() == 167) {
- return KeyEvent.KEYCODE_MEDIA_RECORD;
- }
- }
-
- // The Shield's key layout files map the DualShock 4 clickpad button to
- // BUTTON_SELECT instead of something sane like BUTTON_1 as the standard AOSP
- // mapping does. If we get a button from a Sony device reported as BUTTON_SELECT
- // that matches the keycode used by hid-sony for the clickpad or it's from the
- // separate touchpad input device, remap it to BUTTON_1 to match the current AOSP
- // layout and trigger our touchpad button logic.
- if (context.vendorId == 0x054c &&
- event.getKeyCode() == KeyEvent.KEYCODE_BUTTON_SELECT &&
- (event.getScanCode() == 317 || context.isDualShockStandaloneTouchpad)) {
- return KeyEvent.KEYCODE_BUTTON_1;
- }
-
- // Override mode button for 8BitDo controllers
- if (context.vendorId == 0x2dc8 && event.getScanCode() == 306) {
- return KeyEvent.KEYCODE_BUTTON_MODE;
- }
-
- // This mapping was adding in Android 10, then changed based on
- // kernel changes (adding hid-nintendo) in Android 11. If we're
- // on anything newer than Pie, just use the built-in mapping.
- if ((context.vendorId == 0x057e && context.productId == 0x2009 && Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) || // Switch Pro controller
- (context.vendorId == 0x0f0d && context.productId == 0x00c1)) { // HORIPAD for Switch
- switch (event.getScanCode()) {
- case 0x130:
- return KeyEvent.KEYCODE_BUTTON_A;
- case 0x131:
- return KeyEvent.KEYCODE_BUTTON_B;
- case 0x132:
- return KeyEvent.KEYCODE_BUTTON_X;
- case 0x133:
- return KeyEvent.KEYCODE_BUTTON_Y;
- case 0x134:
- return KeyEvent.KEYCODE_BUTTON_L1;
- case 0x135:
- return KeyEvent.KEYCODE_BUTTON_R1;
- case 0x136:
- return KeyEvent.KEYCODE_BUTTON_L2;
- case 0x137:
- return KeyEvent.KEYCODE_BUTTON_R2;
- case 0x138:
- return KeyEvent.KEYCODE_BUTTON_SELECT;
- case 0x139:
- return KeyEvent.KEYCODE_BUTTON_START;
- case 0x13A:
- return KeyEvent.KEYCODE_BUTTON_THUMBL;
- case 0x13B:
- return KeyEvent.KEYCODE_BUTTON_THUMBR;
- case 0x13D:
- return KeyEvent.KEYCODE_BUTTON_MODE;
- }
- }
-
- if (context.usesLinuxGamepadStandardFaceButtons) {
- // Android's Generic.kl swaps BTN_NORTH and BTN_WEST
- switch (event.getScanCode()) {
- case 304:
- return KeyEvent.KEYCODE_BUTTON_A;
- case 305:
- return KeyEvent.KEYCODE_BUTTON_B;
- case 307:
- return KeyEvent.KEYCODE_BUTTON_Y;
- case 308:
- return KeyEvent.KEYCODE_BUTTON_X;
- }
- }
-
- if (context.isNonStandardDualShock4) {
- switch (event.getScanCode()) {
- case 304:
- return KeyEvent.KEYCODE_BUTTON_X;
- case 305:
- return KeyEvent.KEYCODE_BUTTON_A;
- case 306:
- return KeyEvent.KEYCODE_BUTTON_B;
- case 307:
- return KeyEvent.KEYCODE_BUTTON_Y;
- case 308:
- return KeyEvent.KEYCODE_BUTTON_L1;
- case 309:
- return KeyEvent.KEYCODE_BUTTON_R1;
- /*
- **** Using analog triggers instead ****
- case 310:
- return KeyEvent.KEYCODE_BUTTON_L2;
- case 311:
- return KeyEvent.KEYCODE_BUTTON_R2;
- */
- case 312:
- return KeyEvent.KEYCODE_BUTTON_SELECT;
- case 313:
- return KeyEvent.KEYCODE_BUTTON_START;
- case 314:
- return KeyEvent.KEYCODE_BUTTON_THUMBL;
- case 315:
- return KeyEvent.KEYCODE_BUTTON_THUMBR;
- case 316:
- return KeyEvent.KEYCODE_BUTTON_MODE;
- default:
- return REMAP_CONSUME;
- }
- }
- // If this is a Serval controller sending an unknown key code, it's probably
- // the start and select buttons
- else if (context.isServal && event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN) {
- switch (event.getScanCode()) {
- case 314:
- return KeyEvent.KEYCODE_BUTTON_SELECT;
- case 315:
- return KeyEvent.KEYCODE_BUTTON_START;
- }
- }
- else if (context.isNonStandardXboxBtController) {
- switch (event.getScanCode()) {
- case 306:
- return KeyEvent.KEYCODE_BUTTON_X;
- case 307:
- return KeyEvent.KEYCODE_BUTTON_Y;
- case 308:
- return KeyEvent.KEYCODE_BUTTON_L1;
- case 309:
- return KeyEvent.KEYCODE_BUTTON_R1;
- case 310:
- return KeyEvent.KEYCODE_BUTTON_SELECT;
- case 311:
- return KeyEvent.KEYCODE_BUTTON_START;
- case 312:
- return KeyEvent.KEYCODE_BUTTON_THUMBL;
- case 313:
- return KeyEvent.KEYCODE_BUTTON_THUMBR;
- case 139:
- return KeyEvent.KEYCODE_BUTTON_MODE;
- default:
- // Other buttons are mapped correctly
- }
-
- // The Xbox button is sent as MENU
- if (event.getKeyCode() == KeyEvent.KEYCODE_MENU) {
- return KeyEvent.KEYCODE_BUTTON_MODE;
- }
- }
- else if (context.vendorId == 0x0b05 && // ASUS
- (context.productId == 0x7900 || // Kunai - USB
- context.productId == 0x7902)) // Kunai - Bluetooth
- {
- // ROG Kunai has special M1-M4 buttons that are accessible via the
- // joycon-style detachable controllers that we should map to Start
- // and Select.
- switch (event.getScanCode()) {
- case 264:
- case 266:
- return KeyEvent.KEYCODE_BUTTON_START;
-
- case 265:
- case 267:
- return KeyEvent.KEYCODE_BUTTON_SELECT;
- }
- }
-
- if (context.hatXAxis == -1 &&
- context.hatYAxis == -1 &&
- /* FIXME: There's no good way to know for sure if xpad is bound
- to this device, so we won't use the name to validate if these
- scancodes should be mapped to DPAD
-
- context.isXboxController &&
- */
- event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN) {
- // If there's not a proper Xbox controller mapping, we'll translate the raw d-pad
- // scan codes into proper key codes
- switch (event.getScanCode())
- {
- case 704:
- return KeyEvent.KEYCODE_DPAD_LEFT;
- case 705:
- return KeyEvent.KEYCODE_DPAD_RIGHT;
- case 706:
- return KeyEvent.KEYCODE_DPAD_UP;
- case 707:
- return KeyEvent.KEYCODE_DPAD_DOWN;
- }
- }
-
- // Past here we can fixup the keycode and potentially trigger
- // another special case so we need to remember what keycode we're using
- int keyCode = event.getKeyCode();
-
- // This is a hack for (at least) the "Tablet Remote" app
- // which sends BACK with META_ALT_ON instead of KEYCODE_BUTTON_B
- if (keyCode == KeyEvent.KEYCODE_BACK &&
- !event.hasNoModifiers() &&
- (event.getFlags() & KeyEvent.FLAG_SOFT_KEYBOARD) != 0)
- {
- keyCode = KeyEvent.KEYCODE_BUTTON_B;
- }
-
- if (keyCode == KeyEvent.KEYCODE_BUTTON_START ||
- keyCode == KeyEvent.KEYCODE_MENU) {
- // Ensure that we never use back as start if we have a real start
- context.backIsStart = false;
- }
- else if (keyCode == KeyEvent.KEYCODE_BUTTON_SELECT) {
- // Don't use mode as select if we have a select
- context.modeIsSelect = false;
- }
- else if (context.backIsStart && keyCode == KeyEvent.KEYCODE_BACK) {
- // Emulate the start button with back
- return KeyEvent.KEYCODE_BUTTON_START;
- }
- else if (context.modeIsSelect && keyCode == KeyEvent.KEYCODE_BUTTON_MODE) {
- // Emulate the select button with mode
- return KeyEvent.KEYCODE_BUTTON_SELECT;
- }
- else if (context.searchIsMode && keyCode == KeyEvent.KEYCODE_SEARCH) {
- // Emulate the mode button with search
- return KeyEvent.KEYCODE_BUTTON_MODE;
- }
-
- return keyCode;
- }
-
- private int handleFlipFaceButtons(int keyCode) {
- switch (keyCode) {
- case KeyEvent.KEYCODE_BUTTON_A:
- return KeyEvent.KEYCODE_BUTTON_B;
- case KeyEvent.KEYCODE_BUTTON_B:
- return KeyEvent.KEYCODE_BUTTON_A;
- case KeyEvent.KEYCODE_BUTTON_X:
- return KeyEvent.KEYCODE_BUTTON_Y;
- case KeyEvent.KEYCODE_BUTTON_Y:
- return KeyEvent.KEYCODE_BUTTON_X;
- default:
- return keyCode;
- }
- }
-
- private Vector2d populateCachedVector(float x, float y) {
- // Reinitialize our cached Vector2d object
- inputVector.initialize(x, y);
- return inputVector;
- }
-
- private void handleDeadZone(Vector2d stickVector, float deadzoneRadius) {
- if (stickVector.getMagnitude() <= deadzoneRadius) {
- // Deadzone
- stickVector.initialize(0, 0);
- }
-
- // We're not normalizing here because we let the computer handle the deadzones.
- // Normalizing can make the deadzones larger than they should be after the computer also
- // evaluates the deadzone.
- }
-
- private void handleAxisSet(InputDeviceContext context, float lsX, float lsY, float rsX,
- float rsY, float lt, float rt, float hatX, float hatY) {
-
- if (context.leftStickXAxis != -1 && context.leftStickYAxis != -1) {
- Vector2d leftStickVector = populateCachedVector(lsX, lsY);
-
- handleDeadZone(leftStickVector, context.leftStickDeadzoneRadius);
-
- context.leftStickX = (short) (leftStickVector.getX() * 0x7FFE);
- context.leftStickY = (short) (-leftStickVector.getY() * 0x7FFE);
- }
-
- if (context.rightStickXAxis != -1 && context.rightStickYAxis != -1) {
- Vector2d rightStickVector = populateCachedVector(rsX, rsY);
-
- handleDeadZone(rightStickVector, context.rightStickDeadzoneRadius);
-
- context.rightStickX = (short) (rightStickVector.getX() * 0x7FFE);
- context.rightStickY = (short) (-rightStickVector.getY() * 0x7FFE);
- }
-
- if (context.leftTriggerAxis != -1 && context.rightTriggerAxis != -1) {
- // Android sends an initial 0 value for trigger axes even if the trigger
- // should be negative when idle. After the first touch, the axes will go back
- // to normal behavior, so ignore triggersIdleNegative for each trigger until
- // first touch.
- if (lt != 0) {
- context.leftTriggerAxisUsed = true;
- }
- if (rt != 0) {
- context.rightTriggerAxisUsed = true;
- }
- if (context.triggersIdleNegative) {
- if (context.leftTriggerAxisUsed) {
- lt = (lt + 1) / 2;
- }
- if (context.rightTriggerAxisUsed) {
- rt = (rt + 1) / 2;
- }
- }
-
- if (lt <= context.triggerDeadzone) {
- lt = 0;
- }
- if (rt <= context.triggerDeadzone) {
- rt = 0;
- }
-
- context.leftTrigger = (byte)(lt * 0xFF);
- context.rightTrigger = (byte)(rt * 0xFF);
- }
-
- if (context.hatXAxis != -1 && context.hatYAxis != -1) {
- context.inputMap &= ~(ControllerPacket.LEFT_FLAG | ControllerPacket.RIGHT_FLAG);
- if (hatX < -0.5) {
- context.inputMap |= ControllerPacket.LEFT_FLAG;
- context.hatXAxisUsed = true;
- }
- else if (hatX > 0.5) {
- context.inputMap |= ControllerPacket.RIGHT_FLAG;
- context.hatXAxisUsed = true;
- }
-
- context.inputMap &= ~(ControllerPacket.UP_FLAG | ControllerPacket.DOWN_FLAG);
- if (hatY < -0.5) {
- context.inputMap |= ControllerPacket.UP_FLAG;
- context.hatYAxisUsed = true;
- }
- else if (hatY > 0.5) {
- context.inputMap |= ControllerPacket.DOWN_FLAG;
- context.hatYAxisUsed = true;
- }
- }
-
- sendControllerInputPacket(context);
- }
-
- // Normalize the given raw float value into a 0.0-1.0f range
- private float normalizeRawValueWithRange(float value, InputDevice.MotionRange range) {
- value = Math.max(value, range.getMin());
- value = Math.min(value, range.getMax());
-
- value -= range.getMin();
-
- return value / range.getRange();
- }
-
- private boolean sendTouchpadEventForPointer(InputDeviceContext context, MotionEvent event, byte touchType, int pointerIndex) {
- float normalizedX = normalizeRawValueWithRange(event.getX(pointerIndex), context.touchpadXRange);
- float normalizedY = normalizeRawValueWithRange(event.getY(pointerIndex), context.touchpadYRange);
- float normalizedPressure = context.touchpadPressureRange != null ?
- normalizeRawValueWithRange(event.getPressure(pointerIndex), context.touchpadPressureRange)
- : 0;
-
- return conn.sendControllerTouchEvent((byte)context.controllerNumber, touchType,
- event.getPointerId(pointerIndex),
- normalizedX, normalizedY, normalizedPressure) != MoonBridge.LI_ERR_UNSUPPORTED;
- }
-
- public boolean tryHandleTouchpadEvent(MotionEvent event) {
- // Bail if this is not a touchpad or mouse event
- if (event.getSource() != InputDevice.SOURCE_TOUCHPAD &&
- event.getSource() != InputDevice.SOURCE_MOUSE) {
- return false;
- }
-
- // Only get a context if one already exists. We want to ensure we don't report non-gamepads.
- InputDeviceContext context = inputDeviceContexts.get(event.getDeviceId());
- if (context == null) {
- return false;
- }
-
- // When we're working with a mouse source instead of a touchpad, we're quite limited in
- // what useful input we can provide via the controller API. The ABS_X/ABS_Y values are
- // screen coordinates rather than touchpad coordinates. For now, we will just support
- // the clickpad button and nothing else.
- if (event.getSource() == InputDevice.SOURCE_MOUSE) {
- // Unlike the touchpad where down and up refer to individual touches on the touchpad,
- // down and up on a mouse indicates the state of the left mouse button.
- switch (event.getActionMasked()) {
- case MotionEvent.ACTION_DOWN:
- context.inputMap |= ControllerPacket.TOUCHPAD_FLAG;
- sendControllerInputPacket(context);
- break;
- case MotionEvent.ACTION_UP:
- case MotionEvent.ACTION_CANCEL:
- context.inputMap &= ~ControllerPacket.TOUCHPAD_FLAG;
- sendControllerInputPacket(context);
- break;
- default:
- break;
- }
-
- return !prefConfig.gamepadTouchpadAsMouse;
- }
-
- byte touchType;
- switch (event.getActionMasked()) {
- case MotionEvent.ACTION_DOWN:
- case MotionEvent.ACTION_POINTER_DOWN:
- touchType = MoonBridge.LI_TOUCH_EVENT_DOWN;
- break;
-
- case MotionEvent.ACTION_UP:
- case MotionEvent.ACTION_POINTER_UP:
- if ((event.getFlags() & MotionEvent.FLAG_CANCELED) != 0) {
- touchType = MoonBridge.LI_TOUCH_EVENT_CANCEL;
- }
- else {
- touchType = MoonBridge.LI_TOUCH_EVENT_UP;
- }
- break;
-
- case MotionEvent.ACTION_MOVE:
- touchType = MoonBridge.LI_TOUCH_EVENT_MOVE;
- break;
-
- case MotionEvent.ACTION_CANCEL:
- // ACTION_CANCEL applies to *all* pointers in the gesture, so it maps to CANCEL_ALL
- // rather than CANCEL. For a single pointer cancellation, that's indicated via
- // FLAG_CANCELED on a ACTION_POINTER_UP.
- // https://developer.android.com/develop/ui/views/touch-and-input/gestures/multi
- touchType = MoonBridge.LI_TOUCH_EVENT_CANCEL_ALL;
- break;
-
- case MotionEvent.ACTION_BUTTON_PRESS:
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && event.getActionButton() == MotionEvent.BUTTON_PRIMARY) {
- context.inputMap |= ControllerPacket.TOUCHPAD_FLAG;
- sendControllerInputPacket(context);
- return !prefConfig.gamepadTouchpadAsMouse; // Report as unhandled event to trigger mouse handling
- }
- return false;
-
- case MotionEvent.ACTION_BUTTON_RELEASE:
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && event.getActionButton() == MotionEvent.BUTTON_PRIMARY) {
- context.inputMap &= ~ControllerPacket.TOUCHPAD_FLAG;
- sendControllerInputPacket(context);
- return !prefConfig.gamepadTouchpadAsMouse; // Report as unhandled event to trigger mouse handling
- }
- return false;
-
- default:
- return false;
- }
-
- // Bail if the user wants gamepad touchpads to control the mouse
- //
- // NB: We do this after processing ACTION_BUTTON_PRESS and ACTION_BUTTON_RELEASE
- // because we want to still send the touchpad button via the gamepad even when
- // configured to use the touchpad for mouse control.
- if (prefConfig.gamepadTouchpadAsMouse) {
- return false;
- }
-
- // If we don't have X and Y ranges, we can't process this event
- if (context.touchpadXRange == null || context.touchpadYRange == null) {
- return false;
- }
-
- if (event.getActionMasked() == MotionEvent.ACTION_MOVE) {
- // Move events may impact all active pointers
- for (int i = 0; i < event.getPointerCount(); i++) {
- if (!sendTouchpadEventForPointer(context, event, touchType, i)) {
- // Controller touch events are not supported by the host
- return false;
- }
- }
- return true;
- }
- else if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) {
- // Cancel impacts all active pointers
- return conn.sendControllerTouchEvent((byte)context.controllerNumber, MoonBridge.LI_TOUCH_EVENT_CANCEL_ALL,
- 0, 0, 0, 0) != MoonBridge.LI_ERR_UNSUPPORTED;
- }
- else {
- // Down and Up events impact the action index pointer
- return sendTouchpadEventForPointer(context, event, touchType, event.getActionIndex());
- }
- }
-
- public boolean handleMotionEvent(MotionEvent event) {
- InputDeviceContext context = getContextForEvent(event);
- if (context == null) {
- return true;
- }
-
- float lsX = 0, lsY = 0, rsX = 0, rsY = 0, rt = 0, lt = 0, hatX = 0, hatY = 0;
-
- // We purposefully ignore the historical values in the motion event as it makes
- // the controller feel sluggish for some users.
-
- if (context.leftStickXAxis != -1 && context.leftStickYAxis != -1) {
- lsX = event.getAxisValue(context.leftStickXAxis);
- lsY = event.getAxisValue(context.leftStickYAxis);
- }
-
- if (context.rightStickXAxis != -1 && context.rightStickYAxis != -1) {
- rsX = event.getAxisValue(context.rightStickXAxis);
- rsY = event.getAxisValue(context.rightStickYAxis);
- }
-
- if (context.leftTriggerAxis != -1 && context.rightTriggerAxis != -1) {
- lt = event.getAxisValue(context.leftTriggerAxis);
- rt = event.getAxisValue(context.rightTriggerAxis);
- }
-
- if (context.hatXAxis != -1 && context.hatYAxis != -1) {
- hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
- hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y);
- }
-
- handleAxisSet(context, lsX, lsY, rsX, rsY, lt, rt, hatX, hatY);
-
- return true;
- }
-
- private Vector2d convertRawStickAxisToPixelMovement(short stickX, short stickY) {
- Vector2d vector = new Vector2d();
- vector.initialize(stickX, stickY);
- vector.scalarMultiply(1 / 32766.0f);
- vector.scalarMultiply(4);
- if (vector.getMagnitude() > 0) {
- // Move faster as the stick is pressed further from center
- vector.scalarMultiply(Math.pow(vector.getMagnitude(), 2));
- }
- return vector;
- }
-
- private void sendEmulatedMouseMove(short x, short y) {
- Vector2d vector = convertRawStickAxisToPixelMovement(x, y);
- if (vector.getMagnitude() >= 1) {
- conn.sendMouseMove((short)vector.getX(), (short)-vector.getY());
- }
- }
-
- private void sendEmulatedMouseScroll(short x, short y) {
- Vector2d vector = convertRawStickAxisToPixelMovement(x, y);
- if (vector.getMagnitude() >= 1) {
- conn.sendMouseHighResScroll((short)vector.getY());
- conn.sendMouseHighResHScroll((short)vector.getX());
- }
- }
-
- @TargetApi(31)
- private boolean hasDualAmplitudeControlledRumbleVibrators(VibratorManager vm) {
- int[] vibratorIds = vm.getVibratorIds();
-
- // There must be exactly 2 vibrators on this device
- if (vibratorIds.length != 2) {
- return false;
- }
-
- // Both vibrators must have amplitude control
- for (int vid : vibratorIds) {
- if (!vm.getVibrator(vid).hasAmplitudeControl()) {
- return false;
- }
- }
-
- return true;
- }
-
- // This must only be called if hasDualAmplitudeControlledRumbleVibrators() is true!
- @TargetApi(31)
- private void rumbleDualVibrators(VibratorManager vm, short lowFreqMotor, short highFreqMotor) {
- // Normalize motor values to 0-255 amplitudes for VibrationManager
- highFreqMotor = (short)((highFreqMotor >> 8) & 0xFF);
- lowFreqMotor = (short)((lowFreqMotor >> 8) & 0xFF);
-
- // If they're both zero, we can just call cancel().
- if (lowFreqMotor == 0 && highFreqMotor == 0) {
- vm.cancel();
- return;
- }
-
- // There's no documentation that states that vibrators for FF_RUMBLE input devices will
- // always be enumerated in this order, but it seems consistent between Xbox Series X (USB),
- // PS3 (USB), and PS4 (USB+BT) controllers on Android 12 Beta 3.
- int[] vibratorIds = vm.getVibratorIds();
- int[] vibratorAmplitudes = new int[] { highFreqMotor, lowFreqMotor };
-
- CombinedVibration.ParallelCombination combo = CombinedVibration.startParallel();
-
- for (int i = 0; i < vibratorIds.length; i++) {
- // It's illegal to create a VibrationEffect with an amplitude of 0.
- // Simply excluding that vibrator from our ParallelCombination will turn it off.
- if (vibratorAmplitudes[i] != 0) {
- combo.addVibrator(vibratorIds[i], VibrationEffect.createOneShot(60000, vibratorAmplitudes[i]));
- }
- }
-
- VibrationAttributes.Builder vibrationAttributes = new VibrationAttributes.Builder();
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- vibrationAttributes.setUsage(VibrationAttributes.USAGE_MEDIA);
- }
-
- vm.vibrate(combo.combine(), vibrationAttributes.build());
- }
-
- @TargetApi(31)
- private boolean hasQuadAmplitudeControlledRumbleVibrators(VibratorManager vm) {
- int[] vibratorIds = vm.getVibratorIds();
-
- // There must be exactly 4 vibrators on this device
- if (vibratorIds.length != 4) {
- return false;
- }
-
- // All vibrators must have amplitude control
- for (int vid : vibratorIds) {
- if (!vm.getVibrator(vid).hasAmplitudeControl()) {
- return false;
- }
- }
-
- return true;
- }
-
- // This must only be called if hasQuadAmplitudeControlledRumbleVibrators() is true!
- @TargetApi(31)
- private void rumbleQuadVibrators(VibratorManager vm, short lowFreqMotor, short highFreqMotor, short leftTrigger, short rightTrigger) {
- // Normalize motor values to 0-255 amplitudes for VibrationManager
- highFreqMotor = (short)((highFreqMotor >> 8) & 0xFF);
- lowFreqMotor = (short)((lowFreqMotor >> 8) & 0xFF);
- leftTrigger = (short)((leftTrigger >> 8) & 0xFF);
- rightTrigger = (short)((rightTrigger >> 8) & 0xFF);
-
- // If they're all zero, we can just call cancel().
- if (lowFreqMotor == 0 && highFreqMotor == 0 && leftTrigger == 0 && rightTrigger == 0) {
- vm.cancel();
- return;
- }
-
- // This is a guess based upon the behavior of FF_RUMBLE, but untested due to lack of Linux
- // support for trigger rumble!
- int[] vibratorIds = vm.getVibratorIds();
- int[] vibratorAmplitudes = new int[] { highFreqMotor, lowFreqMotor, leftTrigger, rightTrigger };
-
- CombinedVibration.ParallelCombination combo = CombinedVibration.startParallel();
-
- for (int i = 0; i < vibratorIds.length; i++) {
- // It's illegal to create a VibrationEffect with an amplitude of 0.
- // Simply excluding that vibrator from our ParallelCombination will turn it off.
- if (vibratorAmplitudes[i] != 0) {
- combo.addVibrator(vibratorIds[i], VibrationEffect.createOneShot(60000, vibratorAmplitudes[i]));
- }
- }
-
- VibrationAttributes.Builder vibrationAttributes = new VibrationAttributes.Builder();
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- vibrationAttributes.setUsage(VibrationAttributes.USAGE_MEDIA);
- }
-
- vm.vibrate(combo.combine(), vibrationAttributes.build());
- }
-
- private void rumbleSingleVibrator(Vibrator vibrator, short lowFreqMotor, short highFreqMotor) {
- // Since we can only use a single amplitude value, compute the desired amplitude
- // by taking 80% of the big motor and 33% of the small motor, then capping to 255.
- // NB: This value is now 0-255 as required by VibrationEffect.
- short lowFreqMotorMSB = (short)((lowFreqMotor >> 8) & 0xFF);
- short highFreqMotorMSB = (short)((highFreqMotor >> 8) & 0xFF);
- int simulatedAmplitude = Math.min(255, (int)((lowFreqMotorMSB * 0.80) + (highFreqMotorMSB * 0.33)));
-
- if (simulatedAmplitude == 0) {
- // This case is easy - just cancel the current effect and get out.
- // NB: We cannot simply check lowFreqMotor == highFreqMotor == 0
- // because our simulatedAmplitude could be 0 even though our inputs
- // are not (ex: lowFreqMotor == 0 && highFreqMotor == 1).
- vibrator.cancel();
- return;
- }
-
- // Attempt to use amplitude-based control if we're on Oreo and the device
- // supports amplitude-based vibration control.
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- if (vibrator.hasAmplitudeControl()) {
- VibrationEffect effect = VibrationEffect.createOneShot(60000, simulatedAmplitude);
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- VibrationAttributes vibrationAttributes = new VibrationAttributes.Builder()
- .setUsage(VibrationAttributes.USAGE_MEDIA)
- .build();
- vibrator.vibrate(effect, vibrationAttributes);
- }
- else {
- AudioAttributes audioAttributes = new AudioAttributes.Builder()
- .setUsage(AudioAttributes.USAGE_GAME)
- .build();
- vibrator.vibrate(effect, audioAttributes);
- }
- return;
- }
- }
-
- // If we reach this point, we don't have amplitude controls available, so
- // we must emulate it by PWMing the vibration. Ick.
- long pwmPeriod = 20;
- long onTime = (long)((simulatedAmplitude / 255.0) * pwmPeriod);
- long offTime = pwmPeriod - onTime;
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- VibrationAttributes vibrationAttributes = new VibrationAttributes.Builder()
- .setUsage(VibrationAttributes.USAGE_MEDIA)
- .build();
- vibrator.vibrate(VibrationEffect.createWaveform(new long[]{0, onTime, offTime}, 0), vibrationAttributes);
- }
- else {
- AudioAttributes audioAttributes = new AudioAttributes.Builder()
- .setUsage(AudioAttributes.USAGE_GAME)
- .build();
- vibrator.vibrate(new long[]{0, onTime, offTime}, 0, audioAttributes);
- }
- }
-
- public void handleRumble(short controllerNumber, short lowFreqMotor, short highFreqMotor) {
- boolean foundMatchingDevice = false;
- boolean vibrated = false;
-
- if (stopped) {
- return;
- }
-
- for (int i = 0; i < inputDeviceContexts.size(); i++) {
- InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i);
-
- if (deviceContext.controllerNumber == controllerNumber) {
- foundMatchingDevice = true;
-
- deviceContext.lowFreqMotor = lowFreqMotor;
- deviceContext.highFreqMotor = highFreqMotor;
-
- // Prefer the documented Android 12 rumble API which can handle dual vibrators on PS/Xbox controllers
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && deviceContext.vibratorManager != null) {
- vibrated = true;
- if (deviceContext.quadVibrators) {
- rumbleQuadVibrators(deviceContext.vibratorManager,
- deviceContext.lowFreqMotor, deviceContext.highFreqMotor,
- deviceContext.leftTriggerMotor, deviceContext.rightTriggerMotor);
- }
- else {
- rumbleDualVibrators(deviceContext.vibratorManager,
- deviceContext.lowFreqMotor, deviceContext.highFreqMotor);
- }
- }
- // On Shield devices, we can use their special API to rumble Shield controllers
- else if (sceManager.rumble(deviceContext.inputDevice, deviceContext.lowFreqMotor, deviceContext.highFreqMotor)) {
- vibrated = true;
- }
- // If all else fails, we have to try the old Vibrator API
- else if (deviceContext.vibrator != null) {
- vibrated = true;
- rumbleSingleVibrator(deviceContext.vibrator, deviceContext.lowFreqMotor, deviceContext.highFreqMotor);
- }
- }
- }
-
- for (int i = 0; i < usbDeviceContexts.size(); i++) {
- UsbDeviceContext deviceContext = usbDeviceContexts.valueAt(i);
-
- if (deviceContext.controllerNumber == controllerNumber) {
- foundMatchingDevice = vibrated = true;
- deviceContext.device.rumble(lowFreqMotor, highFreqMotor);
- }
- }
-
- // We may decide to rumble the device for player 1
- if (controllerNumber == 0) {
- // If we didn't find a matching device, it must be the on-screen
- // controls that triggered the rumble. Vibrate the device if
- // the user has requested that behavior.
- if (!foundMatchingDevice && prefConfig.onscreenController && !prefConfig.onlyL3R3 && prefConfig.vibrateOsc) {
- rumbleSingleVibrator(deviceVibrator, lowFreqMotor, highFreqMotor);
- }
- else if (foundMatchingDevice && !vibrated && prefConfig.vibrateFallbackToDevice) {
- // We found a device to vibrate but it didn't have rumble support. The user
- // has requested us to vibrate the device in this case.
-
- // We cast the unsigned short value to a signed int before multiplying by
- // the preferred strength. The resulting value is capped at 65534 before
- // we cast it back to a short so it doesn't go above 100%.
- short lowFreqMotorAdjusted = (short)(Math.min((((lowFreqMotor & 0xffff)
- * prefConfig.vibrateFallbackToDeviceStrength) / 100), Short.MAX_VALUE*2));
- short highFreqMotorAdjusted = (short)(Math.min((((highFreqMotor & 0xffff)
- * prefConfig.vibrateFallbackToDeviceStrength) / 100), Short.MAX_VALUE*2));
-
- rumbleSingleVibrator(deviceVibrator, lowFreqMotorAdjusted, highFreqMotorAdjusted);
- }
- }
- }
-
- public void handleRumbleTriggers(short controllerNumber, short leftTrigger, short rightTrigger) {
- if (stopped) {
- return;
- }
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- for (int i = 0; i < inputDeviceContexts.size(); i++) {
- InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i);
-
- if (deviceContext.controllerNumber == controllerNumber) {
- deviceContext.leftTriggerMotor = leftTrigger;
- deviceContext.rightTriggerMotor = rightTrigger;
-
- if (deviceContext.quadVibrators) {
- rumbleQuadVibrators(deviceContext.vibratorManager,
- deviceContext.lowFreqMotor, deviceContext.highFreqMotor,
- deviceContext.leftTriggerMotor, deviceContext.rightTriggerMotor);
- }
- }
- }
- }
-
- for (int i = 0; i < usbDeviceContexts.size(); i++) {
- UsbDeviceContext deviceContext = usbDeviceContexts.valueAt(i);
-
- if (deviceContext.controllerNumber == controllerNumber) {
- deviceContext.device.rumbleTriggers(leftTrigger, rightTrigger);
- }
- }
- }
-
- private SensorEventListener createSensorListener(final short controllerNumber, final byte motionType, final boolean needsDeviceOrientationCorrection) {
- return new SensorEventListener() {
- private float[] lastValues = new float[3];
-
- @Override
- public void onSensorChanged(SensorEvent sensorEvent) {
- // Android will invoke our callback any time we get a new reading,
- // even if the values are the same as last time. Don't report a
- // duplicate set of values to save bandwidth.
- if (sensorEvent.values[0] == lastValues[0] &&
- sensorEvent.values[1] == lastValues[1] &&
- sensorEvent.values[2] == lastValues[2]) {
- return;
- }
- else {
- lastValues[0] = sensorEvent.values[0];
- lastValues[1] = sensorEvent.values[1];
- lastValues[2] = sensorEvent.values[2];
- }
-
- int x = 0;
- int y = 1;
- int z = 2;
- int xFactor = 1;
- int yFactor = 1;
- int zFactor = 1;
-
- if (needsDeviceOrientationCorrection) {
- int deviceRotation = activityContext.getWindowManager().getDefaultDisplay().getRotation();
- switch (deviceRotation) {
- case Surface.ROTATION_0:
- case Surface.ROTATION_180:
- x = 0;
- y = 2;
- z = 1;
- break;
-
- case Surface.ROTATION_90:
- case Surface.ROTATION_270:
- x = 1;
- y = 2;
- z = 0;
- break;
- }
-
- switch (deviceRotation) {
- case Surface.ROTATION_0:
- zFactor = -1;
- break;
- case Surface.ROTATION_90:
- xFactor = -1;
- zFactor = -1;
- break;
- case Surface.ROTATION_180:
- xFactor = -1;
- break;
- case Surface.ROTATION_270:
- break;
- }
- }
-
- if (motionType == MoonBridge.LI_MOTION_TYPE_GYRO) {
- // Convert from rad/s to deg/s
- conn.sendControllerMotionEvent((byte) controllerNumber,
- motionType,
- sensorEvent.values[x] * xFactor * 57.2957795f,
- sensorEvent.values[y] * yFactor * 57.2957795f,
- sensorEvent.values[z] * zFactor * 57.2957795f);
- }
- else {
- // Pass m/s^2 directly without conversion
- conn.sendControllerMotionEvent((byte) controllerNumber,
- motionType,
- sensorEvent.values[x] * xFactor,
- sensorEvent.values[y] * yFactor,
- sensorEvent.values[z] * zFactor);
- }
- }
-
- @Override
- public void onAccuracyChanged(Sensor sensor, int accuracy) {}
- };
- }
-
- public void handleSetMotionEventState(final short controllerNumber, final byte motionType, short reportRateHz) {
- if (stopped) {
- return;
- }
-
- // Report rate is restricted to <= 200 Hz without the HIGH_SAMPLING_RATE_SENSORS permission
- reportRateHz = (short) Math.min(200, reportRateHz);
-
- for (int i = 0; i < inputDeviceContexts.size(); i++) {
- InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i);
-
- if (deviceContext.controllerNumber == controllerNumber) {
- // Store the desired report rate even if we don't have sensors. In some cases,
- // input devices can be reconfigured at runtime which results in a change where
- // sensors disappear and reappear. By storing the desired report rate, we can
- // reapply the desired motion sensor configuration after they reappear.
- switch (motionType) {
- case MoonBridge.LI_MOTION_TYPE_ACCEL:
- deviceContext.accelReportRateHz = reportRateHz;
- break;
- case MoonBridge.LI_MOTION_TYPE_GYRO:
- deviceContext.gyroReportRateHz = reportRateHz;
- break;
- }
-
- backgroundThreadHandler.removeCallbacks(deviceContext.enableSensorRunnable);
-
- SensorManager sm = deviceContext.sensorManager;
- if (sm == null) {
- continue;
- }
-
- switch (motionType) {
- case MoonBridge.LI_MOTION_TYPE_ACCEL:
- if (deviceContext.accelListener != null) {
- sm.unregisterListener(deviceContext.accelListener);
- deviceContext.accelListener = null;
- }
-
- // Enable the accelerometer if requested
- Sensor accelSensor = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
- if (reportRateHz != 0 && accelSensor != null) {
- deviceContext.accelListener = createSensorListener(controllerNumber, motionType, sm == deviceSensorManager);
- sm.registerListener(deviceContext.accelListener, accelSensor, 1000000 / reportRateHz);
- }
- break;
- case MoonBridge.LI_MOTION_TYPE_GYRO:
- if (deviceContext.gyroListener != null) {
- sm.unregisterListener(deviceContext.gyroListener);
- deviceContext.gyroListener = null;
- }
-
- // Enable the gyroscope if requested
- Sensor gyroSensor = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE);
- if (reportRateHz != 0 && gyroSensor != null) {
- deviceContext.gyroListener = createSensorListener(controllerNumber, motionType, sm == deviceSensorManager);
- sm.registerListener(deviceContext.gyroListener, gyroSensor, 1000000 / reportRateHz);
- }
- break;
- }
- break;
- }
- }
- }
-
- public void handleSetControllerLED(short controllerNumber, byte r, byte g, byte b) {
- if (stopped) {
- return;
- }
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- for (int i = 0; i < inputDeviceContexts.size(); i++) {
- InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i);
-
- // Ignore input devices without an RGB LED
- if (deviceContext.controllerNumber == controllerNumber && deviceContext.hasRgbLed) {
- // Create a new light session if one doesn't already exist
- if (deviceContext.lightsSession == null) {
- deviceContext.lightsSession = deviceContext.inputDevice.getLightsManager().openSession();
- }
-
- // Convert the RGB components into the integer value that LightState uses
- int argbValue = 0xFF000000 | ((r << 16) & 0xFF0000) | ((g << 8) & 0xFF00) | (b & 0xFF);
- LightState lightState = new LightState.Builder().setColor(argbValue).build();
-
- // Set the RGB value for each RGB-controllable LED on the device
- LightsRequest.Builder lightsRequestBuilder = new LightsRequest.Builder();
- for (Light light : deviceContext.inputDevice.getLightsManager().getLights()) {
- if (light.hasRgbControl()) {
- lightsRequestBuilder.addLight(light, lightState);
- }
- }
-
- // Apply the LED changes
- deviceContext.lightsSession.requestLights(lightsRequestBuilder.build());
- }
- }
- }
- }
-
- public boolean handleButtonUp(KeyEvent event) {
- InputDeviceContext context = getContextForEvent(event);
- if (context == null) {
- return true;
- }
-
- int keyCode = handleRemapping(context, event);
- if (keyCode < 0) {
- return (keyCode == REMAP_CONSUME);
- }
-
- if (prefConfig.flipFaceButtons) {
- keyCode = handleFlipFaceButtons(keyCode);
- }
-
- // If the button hasn't been down long enough, sleep for a bit before sending the up event
- // This allows "instant" button presses (like OUYA's virtual menu button) to work. This
- // path should not be triggered during normal usage.
- int buttonDownTime = (int)(event.getEventTime() - event.getDownTime());
- if (buttonDownTime < ControllerHandler.MINIMUM_BUTTON_DOWN_TIME_MS)
- {
- // Since our sleep time is so short (<= 25 ms), it shouldn't cause a problem doing this
- // in the UI thread.
- try {
- Thread.sleep(ControllerHandler.MINIMUM_BUTTON_DOWN_TIME_MS - buttonDownTime);
- } catch (InterruptedException e) {
- e.printStackTrace();
-
- // InterruptedException clears the thread's interrupt status. Since we can't
- // handle that here, we will re-interrupt the thread to set the interrupt
- // status back to true.
- Thread.currentThread().interrupt();
- }
- }
-
- switch (keyCode) {
- case KeyEvent.KEYCODE_BUTTON_MODE:
- context.inputMap &= ~ControllerPacket.SPECIAL_BUTTON_FLAG;
- break;
- case KeyEvent.KEYCODE_BUTTON_START:
- case KeyEvent.KEYCODE_MENU:
- // Sometimes we'll get a spurious key up event on controller disconnect.
- // Make sure it's real by checking that the key is actually down before taking
- // any action.
- if ((context.inputMap & ControllerPacket.PLAY_FLAG) != 0 &&
- event.getEventTime() - context.startDownTime > ControllerHandler.START_DOWN_TIME_MOUSE_MODE_MS &&
- prefConfig.mouseEmulation) {
- context.toggleMouseEmulation();
- }
- context.inputMap &= ~ControllerPacket.PLAY_FLAG;
- break;
- case KeyEvent.KEYCODE_BACK:
- case KeyEvent.KEYCODE_BUTTON_SELECT:
- context.inputMap &= ~ControllerPacket.BACK_FLAG;
- break;
- case KeyEvent.KEYCODE_DPAD_LEFT:
- if (context.hatXAxisUsed) {
- // Suppress this duplicate event if we have a hat
- return true;
- }
- context.inputMap &= ~ControllerPacket.LEFT_FLAG;
- break;
- case KeyEvent.KEYCODE_DPAD_RIGHT:
- if (context.hatXAxisUsed) {
- // Suppress this duplicate event if we have a hat
- return true;
- }
- context.inputMap &= ~ControllerPacket.RIGHT_FLAG;
- break;
- case KeyEvent.KEYCODE_DPAD_UP:
- if (context.hatYAxisUsed) {
- // Suppress this duplicate event if we have a hat
- return true;
- }
- context.inputMap &= ~ControllerPacket.UP_FLAG;
- break;
- case KeyEvent.KEYCODE_DPAD_DOWN:
- if (context.hatYAxisUsed) {
- // Suppress this duplicate event if we have a hat
- return true;
- }
- context.inputMap &= ~ControllerPacket.DOWN_FLAG;
- break;
- case KeyEvent.KEYCODE_DPAD_UP_LEFT:
- if (context.hatXAxisUsed && context.hatYAxisUsed) {
- // Suppress this duplicate event if we have a hat
- return true;
- }
- context.inputMap &= ~(ControllerPacket.UP_FLAG | ControllerPacket.LEFT_FLAG);
- break;
- case KeyEvent.KEYCODE_DPAD_UP_RIGHT:
- if (context.hatXAxisUsed && context.hatYAxisUsed) {
- // Suppress this duplicate event if we have a hat
- return true;
- }
- context.inputMap &= ~(ControllerPacket.UP_FLAG | ControllerPacket.RIGHT_FLAG);
- break;
- case KeyEvent.KEYCODE_DPAD_DOWN_LEFT:
- if (context.hatXAxisUsed && context.hatYAxisUsed) {
- // Suppress this duplicate event if we have a hat
- return true;
- }
- context.inputMap &= ~(ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG);
- break;
- case KeyEvent.KEYCODE_DPAD_DOWN_RIGHT:
- if (context.hatXAxisUsed && context.hatYAxisUsed) {
- // Suppress this duplicate event if we have a hat
- return true;
- }
- context.inputMap &= ~(ControllerPacket.DOWN_FLAG | ControllerPacket.RIGHT_FLAG);
- break;
- case KeyEvent.KEYCODE_BUTTON_B:
- context.inputMap &= ~ControllerPacket.B_FLAG;
- break;
- case KeyEvent.KEYCODE_DPAD_CENTER:
- case KeyEvent.KEYCODE_BUTTON_A:
- context.inputMap &= ~ControllerPacket.A_FLAG;
- break;
- case KeyEvent.KEYCODE_BUTTON_X:
- context.inputMap &= ~ControllerPacket.X_FLAG;
- break;
- case KeyEvent.KEYCODE_BUTTON_Y:
- context.inputMap &= ~ControllerPacket.Y_FLAG;
- break;
- case KeyEvent.KEYCODE_BUTTON_L1:
- context.inputMap &= ~ControllerPacket.LB_FLAG;
- context.lastLbUpTime = event.getEventTime();
- break;
- case KeyEvent.KEYCODE_BUTTON_R1:
- context.inputMap &= ~ControllerPacket.RB_FLAG;
- context.lastRbUpTime = event.getEventTime();
- break;
- case KeyEvent.KEYCODE_BUTTON_THUMBL:
- context.inputMap &= ~ControllerPacket.LS_CLK_FLAG;
- break;
- case KeyEvent.KEYCODE_BUTTON_THUMBR:
- context.inputMap &= ~ControllerPacket.RS_CLK_FLAG;
- break;
- case KeyEvent.KEYCODE_MEDIA_RECORD: // Xbox Series X Share button
- context.inputMap &= ~ControllerPacket.MISC_FLAG;
- break;
- case KeyEvent.KEYCODE_BUTTON_1: // PS4/PS5 touchpad button (prior to 4.10)
- context.inputMap &= ~ControllerPacket.TOUCHPAD_FLAG;
- break;
- case KeyEvent.KEYCODE_BUTTON_L2:
- if (context.leftTriggerAxisUsed) {
- // Suppress this digital event if an analog trigger is active
- return true;
- }
- context.leftTrigger = 0;
- break;
- case KeyEvent.KEYCODE_BUTTON_R2:
- if (context.rightTriggerAxisUsed) {
- // Suppress this digital event if an analog trigger is active
- return true;
- }
- context.rightTrigger = 0;
- break;
- case KeyEvent.KEYCODE_UNKNOWN:
- // Paddles aren't mapped in any of the Android key layout files,
- // so we need to handle the evdev key codes directly.
- if (context.hasPaddles) {
- switch (event.getScanCode()) {
- case 0x2c4: // BTN_TRIGGER_HAPPY5
- context.inputMap &= ~ControllerPacket.PADDLE1_FLAG;
- break;
- case 0x2c5: // BTN_TRIGGER_HAPPY6
- context.inputMap &= ~ControllerPacket.PADDLE2_FLAG;
- break;
- case 0x2c6: // BTN_TRIGGER_HAPPY7
- context.inputMap &= ~ControllerPacket.PADDLE3_FLAG;
- break;
- case 0x2c7: // BTN_TRIGGER_HAPPY8
- context.inputMap &= ~ControllerPacket.PADDLE4_FLAG;
- break;
- default:
- return false;
- }
- }
- else {
- return false;
- }
- break;
- default:
- return false;
- }
-
- // Check if we're emulating the select button
- if ((context.emulatingButtonFlags & ControllerHandler.EMULATING_SELECT) != 0)
- {
- // If either start or LB is up, select comes up too
- if ((context.inputMap & ControllerPacket.PLAY_FLAG) == 0 ||
- (context.inputMap & ControllerPacket.LB_FLAG) == 0)
- {
- context.inputMap &= ~ControllerPacket.BACK_FLAG;
-
- context.emulatingButtonFlags &= ~ControllerHandler.EMULATING_SELECT;
- }
- }
-
- // Check if we're emulating the special button
- if ((context.emulatingButtonFlags & ControllerHandler.EMULATING_SPECIAL) != 0)
- {
- // If either start or select and RB is up, the special button comes up too
- if ((context.inputMap & ControllerPacket.PLAY_FLAG) == 0 ||
- ((context.inputMap & ControllerPacket.BACK_FLAG) == 0 &&
- (context.inputMap & ControllerPacket.RB_FLAG) == 0))
- {
- context.inputMap &= ~ControllerPacket.SPECIAL_BUTTON_FLAG;
-
- context.emulatingButtonFlags &= ~ControllerHandler.EMULATING_SPECIAL;
- }
- }
-
- // Check if we're emulating the touchpad button
- if ((context.emulatingButtonFlags & ControllerHandler.EMULATING_TOUCHPAD) != 0)
- {
- // If either select or LB is up, touchpad comes up too
- if ((context.inputMap & ControllerPacket.BACK_FLAG) == 0 ||
- (context.inputMap & ControllerPacket.LB_FLAG) == 0)
- {
- context.inputMap &= ~ControllerPacket.TOUCHPAD_FLAG;
-
- context.emulatingButtonFlags &= ~ControllerHandler.EMULATING_TOUCHPAD;
- }
- }
-
- sendControllerInputPacket(context);
-
- if (context.pendingExit && context.inputMap == 0) {
- // All buttons from the quit combo are lifted. Finish the activity now.
- activityContext.finish();
- }
-
- return true;
- }
-
- public boolean handleButtonDown(KeyEvent event) {
- InputDeviceContext context = getContextForEvent(event);
- if (context == null) {
- return true;
- }
-
- int keyCode = handleRemapping(context, event);
- if (keyCode < 0) {
- return (keyCode == REMAP_CONSUME);
- }
-
- if (prefConfig.flipFaceButtons) {
- keyCode = handleFlipFaceButtons(keyCode);
- }
-
- switch (keyCode) {
- case KeyEvent.KEYCODE_BUTTON_MODE:
- context.hasMode = true;
- context.inputMap |= ControllerPacket.SPECIAL_BUTTON_FLAG;
- break;
- case KeyEvent.KEYCODE_BUTTON_START:
- case KeyEvent.KEYCODE_MENU:
- if (event.getRepeatCount() == 0) {
- context.startDownTime = event.getEventTime();
- }
- context.inputMap |= ControllerPacket.PLAY_FLAG;
- break;
- case KeyEvent.KEYCODE_BACK:
- case KeyEvent.KEYCODE_BUTTON_SELECT:
- context.hasSelect = true;
- context.inputMap |= ControllerPacket.BACK_FLAG;
- break;
- case KeyEvent.KEYCODE_DPAD_LEFT:
- if (context.hatXAxisUsed) {
- // Suppress this duplicate event if we have a hat
- return true;
- }
- context.inputMap |= ControllerPacket.LEFT_FLAG;
- break;
- case KeyEvent.KEYCODE_DPAD_RIGHT:
- if (context.hatXAxisUsed) {
- // Suppress this duplicate event if we have a hat
- return true;
- }
- context.inputMap |= ControllerPacket.RIGHT_FLAG;
- break;
- case KeyEvent.KEYCODE_DPAD_UP:
- if (context.hatYAxisUsed) {
- // Suppress this duplicate event if we have a hat
- return true;
- }
- context.inputMap |= ControllerPacket.UP_FLAG;
- break;
- case KeyEvent.KEYCODE_DPAD_DOWN:
- if (context.hatYAxisUsed) {
- // Suppress this duplicate event if we have a hat
- return true;
- }
- context.inputMap |= ControllerPacket.DOWN_FLAG;
- break;
- case KeyEvent.KEYCODE_DPAD_UP_LEFT:
- if (context.hatXAxisUsed && context.hatYAxisUsed) {
- // Suppress this duplicate event if we have a hat
- return true;
- }
- context.inputMap |= ControllerPacket.UP_FLAG | ControllerPacket.LEFT_FLAG;
- break;
- case KeyEvent.KEYCODE_DPAD_UP_RIGHT:
- if (context.hatXAxisUsed && context.hatYAxisUsed) {
- // Suppress this duplicate event if we have a hat
- return true;
- }
- context.inputMap |= ControllerPacket.UP_FLAG | ControllerPacket.RIGHT_FLAG;
- break;
- case KeyEvent.KEYCODE_DPAD_DOWN_LEFT:
- if (context.hatXAxisUsed && context.hatYAxisUsed) {
- // Suppress this duplicate event if we have a hat
- return true;
- }
- context.inputMap |= ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG;
- break;
- case KeyEvent.KEYCODE_DPAD_DOWN_RIGHT:
- if (context.hatXAxisUsed && context.hatYAxisUsed) {
- // Suppress this duplicate event if we have a hat
- return true;
- }
- context.inputMap |= ControllerPacket.DOWN_FLAG | ControllerPacket.RIGHT_FLAG;
- break;
- case KeyEvent.KEYCODE_BUTTON_B:
- context.inputMap |= ControllerPacket.B_FLAG;
- break;
- case KeyEvent.KEYCODE_DPAD_CENTER:
- case KeyEvent.KEYCODE_BUTTON_A:
- context.inputMap |= ControllerPacket.A_FLAG;
- break;
- case KeyEvent.KEYCODE_BUTTON_X:
- context.inputMap |= ControllerPacket.X_FLAG;
- break;
- case KeyEvent.KEYCODE_BUTTON_Y:
- context.inputMap |= ControllerPacket.Y_FLAG;
- break;
- case KeyEvent.KEYCODE_BUTTON_L1:
- context.inputMap |= ControllerPacket.LB_FLAG;
- break;
- case KeyEvent.KEYCODE_BUTTON_R1:
- context.inputMap |= ControllerPacket.RB_FLAG;
- break;
- case KeyEvent.KEYCODE_BUTTON_THUMBL:
- context.inputMap |= ControllerPacket.LS_CLK_FLAG;
- break;
- case KeyEvent.KEYCODE_BUTTON_THUMBR:
- context.inputMap |= ControllerPacket.RS_CLK_FLAG;
- break;
- case KeyEvent.KEYCODE_MEDIA_RECORD: // Xbox Series X Share button
- context.inputMap |= ControllerPacket.MISC_FLAG;
- break;
- case KeyEvent.KEYCODE_BUTTON_1: // PS4/PS5 touchpad button (prior to 4.10)
- context.inputMap |= ControllerPacket.TOUCHPAD_FLAG;
- break;
- case KeyEvent.KEYCODE_BUTTON_L2:
- if (context.leftTriggerAxisUsed) {
- // Suppress this digital event if an analog trigger is active
- return true;
- }
- context.leftTrigger = (byte)0xFF;
- break;
- case KeyEvent.KEYCODE_BUTTON_R2:
- if (context.rightTriggerAxisUsed) {
- // Suppress this digital event if an analog trigger is active
- return true;
- }
- context.rightTrigger = (byte)0xFF;
- break;
- case KeyEvent.KEYCODE_UNKNOWN:
- // Paddles aren't mapped in any of the Android key layout files,
- // so we need to handle the evdev key codes directly.
- if (context.hasPaddles) {
- switch (event.getScanCode()) {
- case 0x2c4: // BTN_TRIGGER_HAPPY5
- context.inputMap |= ControllerPacket.PADDLE1_FLAG;
- break;
- case 0x2c5: // BTN_TRIGGER_HAPPY6
- context.inputMap |= ControllerPacket.PADDLE2_FLAG;
- break;
- case 0x2c6: // BTN_TRIGGER_HAPPY7
- context.inputMap |= ControllerPacket.PADDLE3_FLAG;
- break;
- case 0x2c7: // BTN_TRIGGER_HAPPY8
- context.inputMap |= ControllerPacket.PADDLE4_FLAG;
- break;
- default:
- return false;
- }
- }
- else {
- return false;
- }
- break;
- default:
- return false;
- }
-
- // Start+Back+LB+RB is the quit combo
- if (context.inputMap == (ControllerPacket.BACK_FLAG | ControllerPacket.PLAY_FLAG |
- ControllerPacket.LB_FLAG | ControllerPacket.RB_FLAG)) {
- // Wait for the combo to lift and then finish the activity
- context.pendingExit = true;
- }
-
- // Start+LB acts like select for controllers with one button
- if (!context.hasSelect) {
- if (context.inputMap == (ControllerPacket.PLAY_FLAG | ControllerPacket.LB_FLAG) ||
- (context.inputMap == ControllerPacket.PLAY_FLAG &&
- event.getEventTime() - context.lastLbUpTime <= MAXIMUM_BUMPER_UP_DELAY_MS))
- {
- context.inputMap &= ~(ControllerPacket.PLAY_FLAG | ControllerPacket.LB_FLAG);
- context.inputMap |= ControllerPacket.BACK_FLAG;
-
- context.emulatingButtonFlags |= ControllerHandler.EMULATING_SELECT;
- }
- }
- else if (context.needsClickpadEmulation) {
- // Select+LB acts like the clickpad when we're faking a PS4 controller for motion support
- if (context.inputMap == (ControllerPacket.BACK_FLAG | ControllerPacket.LB_FLAG) ||
- (context.inputMap == ControllerPacket.BACK_FLAG &&
- event.getEventTime() - context.lastLbUpTime <= MAXIMUM_BUMPER_UP_DELAY_MS))
- {
- context.inputMap &= ~(ControllerPacket.BACK_FLAG | ControllerPacket.LB_FLAG);
- context.inputMap |= ControllerPacket.TOUCHPAD_FLAG;
-
- context.emulatingButtonFlags |= ControllerHandler.EMULATING_TOUCHPAD;
- }
- }
-
- // If there is a physical select button, we'll use Start+Select as the special button combo
- // otherwise we'll use Start+RB.
- if (!context.hasMode) {
- if (context.hasSelect) {
- if (context.inputMap == (ControllerPacket.PLAY_FLAG | ControllerPacket.BACK_FLAG)) {
- context.inputMap &= ~(ControllerPacket.PLAY_FLAG | ControllerPacket.BACK_FLAG);
- context.inputMap |= ControllerPacket.SPECIAL_BUTTON_FLAG;
-
- context.emulatingButtonFlags |= ControllerHandler.EMULATING_SPECIAL;
- }
- }
- else {
- if (context.inputMap == (ControllerPacket.PLAY_FLAG | ControllerPacket.RB_FLAG) ||
- (context.inputMap == ControllerPacket.PLAY_FLAG &&
- event.getEventTime() - context.lastRbUpTime <= MAXIMUM_BUMPER_UP_DELAY_MS))
- {
- context.inputMap &= ~(ControllerPacket.PLAY_FLAG | ControllerPacket.RB_FLAG);
- context.inputMap |= ControllerPacket.SPECIAL_BUTTON_FLAG;
-
- context.emulatingButtonFlags |= ControllerHandler.EMULATING_SPECIAL;
- }
- }
- }
-
- // We don't need to send repeat key down events, but the platform
- // sends us events that claim to be repeats but they're from different
- // devices, so we just send them all and deal with some duplicates.
- sendControllerInputPacket(context);
- return true;
- }
-
- public void reportOscState(int buttonFlags,
- short leftStickX, short leftStickY,
- short rightStickX, short rightStickY,
- byte leftTrigger, byte rightTrigger) {
- defaultContext.leftStickX = leftStickX;
- defaultContext.leftStickY = leftStickY;
-
- defaultContext.rightStickX = rightStickX;
- defaultContext.rightStickY = rightStickY;
-
- defaultContext.leftTrigger = leftTrigger;
- defaultContext.rightTrigger = rightTrigger;
-
- defaultContext.inputMap = buttonFlags;
-
- sendControllerInputPacket(defaultContext);
- }
-
- @Override
- public void reportControllerState(int controllerId, int buttonFlags,
- float leftStickX, float leftStickY,
- float rightStickX, float rightStickY,
- float leftTrigger, float rightTrigger) {
- GenericControllerContext context = usbDeviceContexts.get(controllerId);
- if (context == null) {
- return;
- }
-
- Vector2d leftStickVector = populateCachedVector(leftStickX, leftStickY);
-
- handleDeadZone(leftStickVector, context.leftStickDeadzoneRadius);
-
- context.leftStickX = (short) (leftStickVector.getX() * 0x7FFE);
- context.leftStickY = (short) (-leftStickVector.getY() * 0x7FFE);
-
- Vector2d rightStickVector = populateCachedVector(rightStickX, rightStickY);
-
- handleDeadZone(rightStickVector, context.rightStickDeadzoneRadius);
-
- context.rightStickX = (short) (rightStickVector.getX() * 0x7FFE);
- context.rightStickY = (short) (-rightStickVector.getY() * 0x7FFE);
-
- if (leftTrigger <= context.triggerDeadzone) {
- leftTrigger = 0;
- }
- if (rightTrigger <= context.triggerDeadzone) {
- rightTrigger = 0;
- }
-
- context.leftTrigger = (byte)(leftTrigger * 0xFF);
- context.rightTrigger = (byte)(rightTrigger * 0xFF);
-
- context.inputMap = buttonFlags;
-
- sendControllerInputPacket(context);
- }
-
- @Override
- public void deviceRemoved(AbstractController controller) {
- UsbDeviceContext context = usbDeviceContexts.get(controller.getControllerId());
- if (context != null) {
- LimeLog.info("Removed controller: "+controller.getControllerId());
- releaseControllerNumber(context);
- context.destroy();
- usbDeviceContexts.remove(controller.getControllerId());
- }
- }
-
- @Override
- public void deviceAdded(AbstractController controller) {
- if (stopped) {
- return;
- }
-
- UsbDeviceContext context = createUsbDeviceContextForDevice(controller);
- usbDeviceContexts.put(controller.getControllerId(), context);
- }
-
- class GenericControllerContext {
- public int id;
- public boolean external;
-
- public int vendorId;
- public int productId;
-
- public float leftStickDeadzoneRadius;
- public float rightStickDeadzoneRadius;
- public float triggerDeadzone;
-
- public boolean assignedControllerNumber;
- public boolean reservedControllerNumber;
- public short controllerNumber;
-
- public int inputMap = 0;
- public byte leftTrigger = 0x00;
- public byte rightTrigger = 0x00;
- public short rightStickX = 0x0000;
- public short rightStickY = 0x0000;
- public short leftStickX = 0x0000;
- public short leftStickY = 0x0000;
-
- public boolean mouseEmulationActive;
- public int mouseEmulationLastInputMap;
- public final int mouseEmulationReportPeriod = 50;
-
- public final Runnable mouseEmulationRunnable = new Runnable() {
- @Override
- public void run() {
- if (!mouseEmulationActive) {
- return;
- }
-
- // Send mouse events from analog sticks
- if (prefConfig.analogStickForScrolling == PreferenceConfiguration.AnalogStickForScrolling.RIGHT) {
- sendEmulatedMouseMove(leftStickX, leftStickY);
- sendEmulatedMouseScroll(rightStickX, rightStickY);
- }
- else if (prefConfig.analogStickForScrolling == PreferenceConfiguration.AnalogStickForScrolling.LEFT) {
- sendEmulatedMouseMove(rightStickX, rightStickY);
- sendEmulatedMouseScroll(leftStickX, leftStickY);
- }
- else {
- sendEmulatedMouseMove(leftStickX, leftStickY);
- sendEmulatedMouseMove(rightStickX, rightStickY);
- }
-
- // Requeue the callback
- mainThreadHandler.postDelayed(this, mouseEmulationReportPeriod);
- }
- };
-
- public void toggleMouseEmulation() {
- mainThreadHandler.removeCallbacks(mouseEmulationRunnable);
- mouseEmulationActive = !mouseEmulationActive;
- Toast.makeText(activityContext, "Mouse emulation is: " + (mouseEmulationActive ? "ON" : "OFF"), Toast.LENGTH_SHORT).show();
-
- if (mouseEmulationActive) {
- mainThreadHandler.postDelayed(mouseEmulationRunnable, mouseEmulationReportPeriod);
- }
- }
-
- public void destroy() {
- mouseEmulationActive = false;
- mainThreadHandler.removeCallbacks(mouseEmulationRunnable);
- }
-
- public void sendControllerArrival() {}
- }
-
- class InputDeviceContext extends GenericControllerContext {
- public String name;
- public VibratorManager vibratorManager;
- public Vibrator vibrator;
- public boolean quadVibrators;
- public short lowFreqMotor, highFreqMotor;
- public short leftTriggerMotor, rightTriggerMotor;
-
- public SensorManager sensorManager;
- public SensorEventListener gyroListener;
- public short gyroReportRateHz;
- public SensorEventListener accelListener;
- public short accelReportRateHz;
-
- public InputDevice inputDevice;
-
- public boolean hasRgbLed;
- public LightsManager.LightsSession lightsSession;
-
- // These are BatteryState values, not Moonlight values
- public int lastReportedBatteryStatus;
- public float lastReportedBatteryCapacity;
-
- public int leftStickXAxis = -1;
- public int leftStickYAxis = -1;
-
- public int rightStickXAxis = -1;
- public int rightStickYAxis = -1;
-
- public int leftTriggerAxis = -1;
- public int rightTriggerAxis = -1;
- public boolean triggersIdleNegative;
- public boolean leftTriggerAxisUsed, rightTriggerAxisUsed;
-
- public int hatXAxis = -1;
- public int hatYAxis = -1;
- public boolean hatXAxisUsed, hatYAxisUsed;
-
- InputDevice.MotionRange touchpadXRange;
- InputDevice.MotionRange touchpadYRange;
- InputDevice.MotionRange touchpadPressureRange;
-
- public boolean isNonStandardDualShock4;
- public boolean usesLinuxGamepadStandardFaceButtons;
- public boolean isNonStandardXboxBtController;
- public boolean isServal;
- public boolean backIsStart;
- public boolean modeIsSelect;
- public boolean searchIsMode;
- public boolean ignoreBack;
- public boolean hasJoystickAxes;
- public boolean pendingExit;
- public boolean isDualShockStandaloneTouchpad;
-
- public int emulatingButtonFlags = 0;
- public boolean hasSelect;
- public boolean hasMode;
- public boolean hasPaddles;
- public boolean hasShare;
- public boolean needsClickpadEmulation;
-
- // Used for OUYA bumper state tracking since they force all buttons
- // up when the OUYA button goes down. We watch the last time we get
- // a bumper up and compare that to our maximum delay when we receive
- // a Start button press to see if we should activate one of our
- // emulated button combos.
- public long lastLbUpTime = 0;
- public long lastRbUpTime = 0;
-
- public long startDownTime = 0;
-
- public final Runnable batteryStateUpdateRunnable = new Runnable() {
- @Override
- public void run() {
- sendControllerBatteryPacket(InputDeviceContext.this);
-
- // Requeue the callback
- backgroundThreadHandler.postDelayed(this, BATTERY_RECHECK_INTERVAL_MS);
- }
- };
-
- public final Runnable enableSensorRunnable = new Runnable() {
- @Override
- public void run() {
- // Turn back on any sensors that should be reporting but are currently unregistered
- if (accelReportRateHz != 0 && accelListener == null) {
- handleSetMotionEventState(controllerNumber, MoonBridge.LI_MOTION_TYPE_ACCEL, accelReportRateHz);
- }
- if (gyroReportRateHz != 0 && gyroListener == null) {
- handleSetMotionEventState(controllerNumber, MoonBridge.LI_MOTION_TYPE_GYRO, gyroReportRateHz);
- }
- }
- };
-
- @Override
- public void destroy() {
- super.destroy();
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && vibratorManager != null) {
- vibratorManager.cancel();
- }
- else if (vibrator != null) {
- vibrator.cancel();
- }
-
- backgroundThreadHandler.removeCallbacks(enableSensorRunnable);
-
- if (gyroListener != null) {
- sensorManager.unregisterListener(gyroListener);
- }
- if (accelListener != null) {
- sensorManager.unregisterListener(accelListener);
- }
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- if (lightsSession != null) {
- lightsSession.close();
- }
- }
-
- backgroundThreadHandler.removeCallbacks(batteryStateUpdateRunnable);
- }
-
- @Override
- public void sendControllerArrival() {
- byte type;
- switch (inputDevice.getVendorId()) {
- case 0x045e: // Microsoft
- type = MoonBridge.LI_CTYPE_XBOX;
- break;
- case 0x054c: // Sony
- type = MoonBridge.LI_CTYPE_PS;
- break;
- case 0x057e: // Nintendo
- type = MoonBridge.LI_CTYPE_NINTENDO;
- break;
- default:
- // Consult SDL's controller type list to see if it knows
- type = MoonBridge.guessControllerType(inputDevice.getVendorId(), inputDevice.getProductId());
- break;
- }
-
- int supportedButtonFlags = 0;
- for (Map.Entry entry : ANDROID_TO_LI_BUTTON_MAP.entrySet()) {
- if (inputDevice.hasKeys(entry.getKey())[0]) {
- supportedButtonFlags |= entry.getValue();
- }
- }
-
- // Add non-standard button flags that may not be mapped in the Android kl file
- if (hasPaddles) {
- supportedButtonFlags |=
- ControllerPacket.PADDLE1_FLAG |
- ControllerPacket.PADDLE2_FLAG |
- ControllerPacket.PADDLE3_FLAG |
- ControllerPacket.PADDLE4_FLAG;
- }
- if (hasShare) {
- supportedButtonFlags |= ControllerPacket.MISC_FLAG;
- }
-
- if (getMotionRangeForJoystickAxis(inputDevice, MotionEvent.AXIS_HAT_X) != null) {
- supportedButtonFlags |= ControllerPacket.LEFT_FLAG | ControllerPacket.RIGHT_FLAG;
- }
- if (getMotionRangeForJoystickAxis(inputDevice, MotionEvent.AXIS_HAT_Y) != null) {
- supportedButtonFlags |= ControllerPacket.UP_FLAG | ControllerPacket.DOWN_FLAG;
- }
-
- short capabilities = 0;
-
- // Most of the advanced InputDevice capabilities came in Android S
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- if (quadVibrators) {
- capabilities |= MoonBridge.LI_CCAP_RUMBLE | MoonBridge.LI_CCAP_TRIGGER_RUMBLE;
- }
- else if (vibratorManager != null || vibrator != null) {
- capabilities |= MoonBridge.LI_CCAP_RUMBLE;
- }
-
- // Calling InputDevice.getBatteryState() to see if a battery is present
- // performs a Binder transaction that can cause ANRs on some devices.
- // To avoid this, we will just claim we can report battery state for all
- // external gamepad devices on Android S. If it turns out that no battery
- // is actually present, we'll just report unknown battery state to the host.
- if (external) {
- capabilities |= MoonBridge.LI_CCAP_BATTERY_STATE;
- }
-
- // Light.hasRgbControl() was totally broken prior to Android 14.
- // It always returned true because LIGHT_CAPABILITY_RGB was defined as 0,
- // so we will just guess RGB is supported if it's a PlayStation controller.
- if (hasRgbLed && (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE || type == MoonBridge.LI_CTYPE_PS)) {
- capabilities |= MoonBridge.LI_CCAP_RGB_LED;
- }
- }
-
- // Report analog triggers if we have at least one trigger axis
- if (leftTriggerAxis != -1 || rightTriggerAxis != -1) {
- capabilities |= MoonBridge.LI_CCAP_ANALOG_TRIGGERS;
- }
-
- // Report sensors if the input device has them or we're using built-in sensors for a built-in controller
- if (sensorManager != null && sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null) {
- capabilities |= MoonBridge.LI_CCAP_ACCEL;
- }
- if (sensorManager != null && sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null) {
- capabilities |= MoonBridge.LI_CCAP_GYRO;
- }
-
- byte reportedType;
- if (type != MoonBridge.LI_CTYPE_PS && sensorManager != null) {
- // Override the detected controller type if we're emulating motion sensors on an Xbox controller
- Toast.makeText(activityContext, activityContext.getResources().getText(R.string.toast_controller_type_changed), Toast.LENGTH_LONG).show();
- reportedType = MoonBridge.LI_CTYPE_UNKNOWN;
-
- // Remember that we should enable the clickpad emulation combo (Select+LB) for this device
- needsClickpadEmulation = true;
- }
- else {
- // Report the true type to the host PC if we're not emulating motion sensors
- reportedType = type;
- }
-
- // We can perform basic rumble with any vibrator
- if (vibrator != null) {
- capabilities |= MoonBridge.LI_CCAP_RUMBLE;
- }
-
- // Shield controllers use special APIs for rumble and battery state
- if (sceManager.isRecognizedDevice(inputDevice)) {
- capabilities |= MoonBridge.LI_CCAP_RUMBLE | MoonBridge.LI_CCAP_BATTERY_STATE;
- }
-
- if ((inputDevice.getSources() & InputDevice.SOURCE_TOUCHPAD) == InputDevice.SOURCE_TOUCHPAD) {
- capabilities |= MoonBridge.LI_CCAP_TOUCHPAD;
-
- // Use the platform API or internal heuristics to determine if this has a clickpad
- if (hasButtonUnderTouchpad(inputDevice, type)) {
- supportedButtonFlags |= ControllerPacket.TOUCHPAD_FLAG;
- }
- }
-
- conn.sendControllerArrivalEvent((byte)controllerNumber, getActiveControllerMask(),
- reportedType, supportedButtonFlags, capabilities);
-
- // After reporting arrival to the host, send initial battery state and begin monitoring
- backgroundThreadHandler.post(batteryStateUpdateRunnable);
- }
-
- public void migrateContext(InputDeviceContext oldContext) {
- // Take ownership of the sensor and light sessions
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- this.lightsSession = oldContext.lightsSession;
- oldContext.lightsSession = null;
- }
- this.gyroReportRateHz = oldContext.gyroReportRateHz;
- this.accelReportRateHz = oldContext.accelReportRateHz;
-
- // Don't release the controller number, because we will carry it over if it is present.
- // We also want to make sure the change is invisible to the host PC to avoid an add/remove
- // cycle for the gamepad which may break some games.
- oldContext.destroy();
-
- // Copy over existing controller number state
- this.assignedControllerNumber = oldContext.assignedControllerNumber;
- this.reservedControllerNumber = oldContext.reservedControllerNumber;
- this.controllerNumber = oldContext.controllerNumber;
-
- // We may have set this device to use the built-in sensor manager. If so, do that again.
- if (oldContext.sensorManager == deviceSensorManager) {
- this.sensorManager = deviceSensorManager;
- }
-
- // Copy state initialized in reportControllerArrival()
- this.needsClickpadEmulation = oldContext.needsClickpadEmulation;
-
- // Re-enable sensors on the new context
- enableSensors();
-
- // Refresh battery state and start the battery state polling again
- backgroundThreadHandler.post(batteryStateUpdateRunnable);
- }
-
- public void disableSensors() {
- // Stop any pending enablement
- backgroundThreadHandler.removeCallbacks(enableSensorRunnable);
-
- // Unregister all sensor listeners
- if (gyroListener != null) {
- sensorManager.unregisterListener(gyroListener);
- gyroListener = null;
-
- // Send a gyro event to ensure the virtual controller is stationary
- conn.sendControllerMotionEvent((byte) controllerNumber, MoonBridge.LI_MOTION_TYPE_GYRO, 0.f, 0.f, 0.f);
- }
- if (accelListener != null) {
- sensorManager.unregisterListener(accelListener);
- accelListener = null;
-
- // We leave the acceleration as-is to preserve the attitude of the controller
- }
- }
-
- public void enableSensors() {
- // We allow 1 second for the input device to settle before re-enabling sensors.
- // Pointer capture can cause the input device to change, which can cause
- // InputDeviceSensorManager to crash due to missing null checks on the InputDevice.
- backgroundThreadHandler.postDelayed(enableSensorRunnable, 1000);
- }
- }
-
- class UsbDeviceContext extends GenericControllerContext {
- public AbstractController device;
-
- @Override
- public void destroy() {
- super.destroy();
-
- // Nothing for now
- }
-
- @Override
- public void sendControllerArrival() {
- conn.sendControllerArrivalEvent((byte)controllerNumber, getActiveControllerMask(),
- device.getType(), device.getSupportedButtonFlags(), device.getCapabilities());
- }
- }
-}
+package com.limelight.binding.input;
+
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.content.Context;
+import android.hardware.BatteryState;
+import android.hardware.Sensor;
+import android.hardware.SensorEvent;
+import android.hardware.SensorEventListener;
+import android.hardware.SensorManager;
+import android.hardware.input.InputManager;
+import android.hardware.lights.Light;
+import android.hardware.lights.LightState;
+import android.hardware.lights.LightsManager;
+import android.hardware.lights.LightsRequest;
+import android.hardware.usb.UsbDevice;
+import android.hardware.usb.UsbManager;
+import android.media.AudioAttributes;
+import android.os.Build;
+import android.os.CombinedVibration;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.VibrationAttributes;
+import android.os.VibrationEffect;
+import android.os.Vibrator;
+import android.os.VibratorManager;
+import android.util.SparseArray;
+import android.view.InputDevice;
+import android.view.InputEvent;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.Surface;
+import android.widget.Toast;
+
+import com.limelight.GameMenu;
+import com.limelight.LimeLog;
+import com.limelight.R;
+import com.limelight.binding.input.driver.AbstractController;
+import com.limelight.binding.input.driver.UsbDriverListener;
+import com.limelight.binding.input.driver.UsbDriverService;
+import com.limelight.nvstream.NvConnection;
+import com.limelight.nvstream.input.ControllerPacket;
+import com.limelight.nvstream.input.MouseButtonPacket;
+import com.limelight.nvstream.jni.MoonBridge;
+import com.limelight.preferences.PreferenceConfiguration;
+import com.limelight.ui.GameGestures;
+import com.limelight.utils.Vector2d;
+
+import org.cgutman.shieldcontrollerextensions.SceChargingState;
+import org.cgutman.shieldcontrollerextensions.SceConnectionType;
+import org.cgutman.shieldcontrollerextensions.SceManager;
+
+import java.lang.reflect.InvocationTargetException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+public class ControllerHandler implements InputManager.InputDeviceListener, UsbDriverListener {
+
+ private static final int MAXIMUM_BUMPER_UP_DELAY_MS = 100;
+
+ private static final int START_DOWN_TIME_MOUSE_MODE_MS = 750;
+
+ private static final int MINIMUM_BUTTON_DOWN_TIME_MS = 25;
+
+ private static final int QUICK_MENU_FIRST_STAGE_MS = 200;
+
+ private static final int EMULATING_SPECIAL = 0x1;
+ private static final int EMULATING_SELECT = 0x2;
+ private static final int EMULATING_TOUCHPAD = 0x4;
+
+ private static final short MAX_GAMEPADS = 16; // Limited by bits in activeGamepadMask
+
+ private static final int BATTERY_RECHECK_INTERVAL_MS = 120 * 1000;
+
+ private static final Map ANDROID_TO_LI_BUTTON_MAP = Map.ofEntries(
+ Map.entry(KeyEvent.KEYCODE_BUTTON_A, ControllerPacket.A_FLAG),
+ Map.entry(KeyEvent.KEYCODE_BUTTON_B, ControllerPacket.B_FLAG),
+ Map.entry(KeyEvent.KEYCODE_BUTTON_X, ControllerPacket.X_FLAG),
+ Map.entry(KeyEvent.KEYCODE_BUTTON_Y, ControllerPacket.Y_FLAG),
+ Map.entry(KeyEvent.KEYCODE_DPAD_UP, ControllerPacket.UP_FLAG),
+ Map.entry(KeyEvent.KEYCODE_DPAD_DOWN, ControllerPacket.DOWN_FLAG),
+ Map.entry(KeyEvent.KEYCODE_DPAD_LEFT, ControllerPacket.LEFT_FLAG),
+ Map.entry(KeyEvent.KEYCODE_DPAD_RIGHT, ControllerPacket.RIGHT_FLAG),
+ Map.entry(KeyEvent.KEYCODE_DPAD_UP_LEFT, ControllerPacket.UP_FLAG | ControllerPacket.LEFT_FLAG),
+ Map.entry(KeyEvent.KEYCODE_DPAD_UP_RIGHT, ControllerPacket.UP_FLAG | ControllerPacket.RIGHT_FLAG),
+ Map.entry(KeyEvent.KEYCODE_DPAD_DOWN_LEFT, ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG),
+ Map.entry(KeyEvent.KEYCODE_DPAD_DOWN_RIGHT, ControllerPacket.DOWN_FLAG | ControllerPacket.RIGHT_FLAG),
+ Map.entry(KeyEvent.KEYCODE_BUTTON_L1, ControllerPacket.LB_FLAG),
+ Map.entry(KeyEvent.KEYCODE_BUTTON_R1, ControllerPacket.RB_FLAG),
+ Map.entry(KeyEvent.KEYCODE_BUTTON_THUMBL, ControllerPacket.LS_CLK_FLAG),
+ Map.entry(KeyEvent.KEYCODE_BUTTON_THUMBR, ControllerPacket.RS_CLK_FLAG),
+ Map.entry(KeyEvent.KEYCODE_BUTTON_START, ControllerPacket.PLAY_FLAG),
+ Map.entry(KeyEvent.KEYCODE_MENU, ControllerPacket.PLAY_FLAG),
+ Map.entry(KeyEvent.KEYCODE_BUTTON_SELECT, ControllerPacket.BACK_FLAG),
+ Map.entry(KeyEvent.KEYCODE_BACK, ControllerPacket.BACK_FLAG),
+ Map.entry(KeyEvent.KEYCODE_BUTTON_MODE, ControllerPacket.SPECIAL_BUTTON_FLAG),
+
+ // This is the Xbox Series X Share button
+ Map.entry(KeyEvent.KEYCODE_MEDIA_RECORD, ControllerPacket.MISC_FLAG),
+
+ // This is a weird one, but it's what Android does prior to 4.10 kernels
+ // where DualShock/DualSense touchpads weren't mapped as separate devices.
+ // https://android.googlesource.com/platform/frameworks/base/+/master/data/keyboards/Vendor_054c_Product_0ce6_fallback.kl
+ // https://android.googlesource.com/platform/frameworks/base/+/master/data/keyboards/Vendor_054c_Product_09cc.kl
+ Map.entry(KeyEvent.KEYCODE_BUTTON_1, ControllerPacket.TOUCHPAD_FLAG)
+
+ // FIXME: Paddles?
+ );
+
+ private final Vector2d inputVector = new Vector2d();
+
+ private final SparseArray inputDeviceContexts = new SparseArray<>();
+ private final SparseArray usbDeviceContexts = new SparseArray<>();
+
+ private final NvConnection conn;
+ private final Activity activityContext;
+ private final double stickDeadzone;
+ private final InputDeviceContext defaultContext = new InputDeviceContext();
+ private final GameGestures gestures;
+ private final InputManager inputManager;
+ private final Vibrator deviceVibrator;
+ private final VibratorManager deviceVibratorManager;
+ private final SensorManager deviceSensorManager;
+ private final SceManager sceManager;
+ private final Handler mainThreadHandler;
+ private final HandlerThread backgroundHandlerThread;
+ private final Handler backgroundThreadHandler;
+ private boolean hasGameController;
+ private boolean stopped = false;
+
+ private final PreferenceConfiguration prefConfig;
+ private short currentControllers, initialControllers;
+
+ public ControllerHandler(Activity activityContext, NvConnection conn, GameGestures gestures, PreferenceConfiguration prefConfig) {
+ this.activityContext = activityContext;
+ this.conn = conn;
+ this.gestures = gestures;
+ this.prefConfig = prefConfig;
+ this.deviceVibrator = (Vibrator) activityContext.getSystemService(Context.VIBRATOR_SERVICE);
+ this.deviceSensorManager = (SensorManager) activityContext.getSystemService(Context.SENSOR_SERVICE);
+ this.inputManager = (InputManager) activityContext.getSystemService(Context.INPUT_SERVICE);
+ this.mainThreadHandler = new Handler(Looper.getMainLooper());
+
+ // Create a HandlerThread to process battery state updates. These can be slow enough
+ // that they lead to ANRs if we do them on the main thread.
+ this.backgroundHandlerThread = new HandlerThread("ControllerHandler");
+ this.backgroundHandlerThread.start();
+ this.backgroundThreadHandler = new Handler(backgroundHandlerThread.getLooper());
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ this.deviceVibratorManager = (VibratorManager) activityContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE);
+ }
+ else {
+ this.deviceVibratorManager = null;
+ }
+
+ this.sceManager = new SceManager(activityContext);
+ this.sceManager.start();
+
+ int deadzonePercentage = prefConfig.deadzonePercentage;
+
+ int[] ids = InputDevice.getDeviceIds();
+ for (int id : ids) {
+ InputDevice dev = InputDevice.getDevice(id);
+ if (dev == null) {
+ // This device was removed during enumeration
+ continue;
+ }
+ if ((dev.getSources() & InputDevice.SOURCE_JOYSTICK) != 0 ||
+ (dev.getSources() & InputDevice.SOURCE_GAMEPAD) != 0) {
+ // This looks like a gamepad, but we'll check X and Y to be sure
+ if (getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_X) != null &&
+ getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_Y) != null) {
+ // This is a gamepad
+ hasGameController = true;
+ }
+ }
+ }
+
+ this.stickDeadzone = (double)deadzonePercentage / 100.0;
+
+ // Initialize the default context for events with no device
+ defaultContext.leftStickXAxis = MotionEvent.AXIS_X;
+ defaultContext.leftStickYAxis = MotionEvent.AXIS_Y;
+ defaultContext.leftStickDeadzoneRadius = (float) stickDeadzone;
+ defaultContext.rightStickXAxis = MotionEvent.AXIS_Z;
+ defaultContext.rightStickYAxis = MotionEvent.AXIS_RZ;
+ defaultContext.rightStickDeadzoneRadius = (float) stickDeadzone;
+ defaultContext.leftTriggerAxis = MotionEvent.AXIS_BRAKE;
+ defaultContext.rightTriggerAxis = MotionEvent.AXIS_GAS;
+ defaultContext.hatXAxis = MotionEvent.AXIS_HAT_X;
+ defaultContext.hatYAxis = MotionEvent.AXIS_HAT_Y;
+ defaultContext.controllerNumber = (short) 0;
+ defaultContext.assignedControllerNumber = true;
+ defaultContext.external = false;
+
+ // Some devices (GPD XD) have a back button which sends input events
+ // with device ID == 0. This hits the default context which would normally
+ // consume these. Instead, let's ignore them since that's probably the
+ // most likely case.
+ defaultContext.ignoreBack = true;
+
+ // Get the initially attached set of gamepads. As each gamepad receives
+ // its initial InputEvent, we will move these from this set onto the
+ // currentControllers set which will allow them to properly unplug
+ // if they are removed.
+ initialControllers = getAttachedControllerMask(activityContext);
+
+ // Register ourselves for input device notifications
+ inputManager.registerInputDeviceListener(this, null);
+ }
+
+ private static InputDevice.MotionRange getMotionRangeForJoystickAxis(InputDevice dev, int axis) {
+ InputDevice.MotionRange range;
+
+ // First get the axis for SOURCE_JOYSTICK
+ range = dev.getMotionRange(axis, InputDevice.SOURCE_JOYSTICK);
+ if (range == null) {
+ // Now try the axis for SOURCE_GAMEPAD
+ range = dev.getMotionRange(axis, InputDevice.SOURCE_GAMEPAD);
+ }
+
+ return range;
+ }
+
+ public boolean hasController() {
+ return hasGameController;
+ }
+
+ @Override
+ public void onInputDeviceAdded(int deviceId) {
+ // Nothing happening here yet
+ }
+
+ @Override
+ public void onInputDeviceRemoved(int deviceId) {
+ InputDeviceContext context = inputDeviceContexts.get(deviceId);
+ if (context != null) {
+ LimeLog.info("Removed controller: "+context.name+" ("+deviceId+")");
+ releaseControllerNumber(context);
+ context.destroy();
+ inputDeviceContexts.remove(deviceId);
+ }
+ }
+
+ // This can happen when gaining/losing input focus with some devices.
+ // Input devices that have a trackpad may gain/lose AXIS_RELATIVE_X/Y.
+ @Override
+ public void onInputDeviceChanged(int deviceId) {
+ InputDevice device = InputDevice.getDevice(deviceId);
+ if (device == null) {
+ return;
+ }
+
+ // If we don't have a context for this device, we don't need to update anything
+ InputDeviceContext existingContext = inputDeviceContexts.get(deviceId);
+ if (existingContext == null) {
+ return;
+ }
+
+ LimeLog.info("Device changed: "+existingContext.name+" ("+deviceId+")");
+
+ // Migrate the existing context into this new one by moving any stateful elements
+ InputDeviceContext newContext = createInputDeviceContextForDevice(device);
+ newContext.migrateContext(existingContext);
+ inputDeviceContexts.put(deviceId, newContext);
+ }
+
+ public void stop() {
+ if (stopped) {
+ return;
+ }
+
+ // Stop new device contexts from being created or used
+ stopped = true;
+
+ // Unregister our input device callbacks
+ inputManager.unregisterInputDeviceListener(this);
+
+ for (int i = 0; i < inputDeviceContexts.size(); i++) {
+ InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i);
+ deviceContext.destroy();
+ }
+
+ for (int i = 0; i < usbDeviceContexts.size(); i++) {
+ UsbDeviceContext deviceContext = usbDeviceContexts.valueAt(i);
+ deviceContext.destroy();
+ }
+
+ deviceVibrator.cancel();
+ }
+
+ public void destroy() {
+ if (!stopped) {
+ stop();
+ }
+
+ sceManager.stop();
+ backgroundHandlerThread.quit();
+ }
+
+ public void disableSensors() {
+ for (int i = 0; i < inputDeviceContexts.size(); i++) {
+ InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i);
+ deviceContext.disableSensors();
+ }
+ }
+
+ public void enableSensors() {
+ if (stopped) {
+ return;
+ }
+
+ for (int i = 0; i < inputDeviceContexts.size(); i++) {
+ InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i);
+ deviceContext.enableSensors();
+ }
+ }
+
+ private static boolean hasJoystickAxes(InputDevice device) {
+ return (device.getSources() & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK &&
+ getMotionRangeForJoystickAxis(device, MotionEvent.AXIS_X) != null &&
+ getMotionRangeForJoystickAxis(device, MotionEvent.AXIS_Y) != null;
+ }
+
+ private static boolean hasGamepadButtons(InputDevice device) {
+ return (device.getSources() & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD;
+ }
+
+ public static boolean isGameControllerDevice(InputDevice device) {
+ if (device == null) {
+ return true;
+ }
+
+ if (hasJoystickAxes(device) || hasGamepadButtons(device)) {
+ // Has real joystick axes or gamepad buttons
+ return true;
+ }
+
+ // HACK for https://issuetracker.google.com/issues/163120692
+ if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) {
+ if (device.getId() == -1) {
+ // This "virtual" device could be input from any of the attached devices.
+ // Look to see if any gamepads are connected.
+ int[] ids = InputDevice.getDeviceIds();
+ for (int id : ids) {
+ InputDevice dev = InputDevice.getDevice(id);
+ if (dev == null) {
+ // This device was removed during enumeration
+ continue;
+ }
+
+ // If there are any gamepad devices connected, we'll
+ // report that this virtual device is a gamepad.
+ if (hasJoystickAxes(dev) || hasGamepadButtons(dev)) {
+ return true;
+ }
+ }
+ }
+ }
+
+ // Otherwise, we'll try anything that claims to be a non-alphabetic keyboard
+ return device.getKeyboardType() != InputDevice.KEYBOARD_TYPE_ALPHABETIC;
+ }
+
+ public static short getAttachedControllerMask(Context context) {
+ int count = 0;
+ short mask = 0;
+
+ // Count all input devices that are gamepads
+ InputManager im = (InputManager) context.getSystemService(Context.INPUT_SERVICE);
+ for (int id : im.getInputDeviceIds()) {
+ InputDevice dev = im.getInputDevice(id);
+ if (dev == null) {
+ continue;
+ }
+
+ if (hasJoystickAxes(dev)) {
+ LimeLog.info("Counting InputDevice: "+dev.getName());
+ mask |= 1 << count++;
+ }
+ }
+
+ // Count all USB devices that match our drivers
+ if (PreferenceConfiguration.readPreferences(context).usbDriver) {
+ UsbManager usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE);
+ if (usbManager != null) {
+ for (UsbDevice dev : usbManager.getDeviceList().values()) {
+ // We explicitly check not to claim devices that appear as InputDevices
+ // otherwise we will double count them.
+ if (UsbDriverService.shouldClaimDevice(dev, false) &&
+ !UsbDriverService.isRecognizedInputDevice(dev)) {
+ LimeLog.info("Counting UsbDevice: "+dev.getDeviceName());
+ mask |= 1 << count++;
+ }
+ }
+ }
+ }
+
+ if (PreferenceConfiguration.readPreferences(context).onscreenController) {
+ LimeLog.info("Counting OSC gamepad");
+ mask |= 1;
+ }
+
+ LimeLog.info("Enumerated "+count+" gamepads");
+ return mask;
+ }
+
+ private void releaseControllerNumber(GenericControllerContext context) {
+ // If we reserved a controller number, remove that reservation
+ if (context.reservedControllerNumber) {
+ LimeLog.info("Controller number "+context.controllerNumber+" is now available");
+ currentControllers &= ~(1 << context.controllerNumber);
+ }
+
+ // If this device sent data as a gamepad, zero the values before removing.
+ // We must do this after clearing the currentControllers entry so this
+ // causes the device to be removed on the server PC.
+ if (context.assignedControllerNumber) {
+ conn.sendControllerInput(context.controllerNumber, getActiveControllerMask(),
+ (short) 0,
+ (byte) 0, (byte) 0,
+ (short) 0, (short) 0,
+ (short) 0, (short) 0);
+ }
+ }
+
+ private boolean isAssociatedJoystick(InputDevice originalDevice, InputDevice possibleAssociatedJoystick) {
+ if (possibleAssociatedJoystick == null) {
+ return false;
+ }
+
+ // This can't be an associated joystick if it's not a joystick
+ if ((possibleAssociatedJoystick.getSources() & InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK) {
+ return false;
+ }
+
+ // Make sure the device names *don't* match in order to prevent us from accidentally matching
+ // on another of the exact same device.
+ if (possibleAssociatedJoystick.getName().equals(originalDevice.getName())) {
+ return false;
+ }
+
+ // Make sure the descriptor matches. This can match in cases where two of the exact same
+ // input device are connected, so we perform the name check to exclude that case.
+ if (!possibleAssociatedJoystick.getDescriptor().equals(originalDevice.getDescriptor())) {
+ return false;
+ }
+
+ return true;
+ }
+
+ // Called before sending input but after we've determined that this
+ // is definitely a controller (not a keyboard, mouse, or something else)
+ private void assignControllerNumberIfNeeded(GenericControllerContext context) {
+ if (context.assignedControllerNumber) {
+ return;
+ }
+
+ if (context instanceof InputDeviceContext) {
+ InputDeviceContext devContext = (InputDeviceContext) context;
+
+ LimeLog.info(devContext.name+" ("+context.id+") needs a controller number assigned");
+ if (!devContext.external) {
+ LimeLog.info("Built-in buttons hardcoded as controller 0");
+ context.controllerNumber = 0;
+ }
+ else if (prefConfig.multiController && devContext.hasJoystickAxes) {
+ context.controllerNumber = 0;
+
+ LimeLog.info("Reserving the next available controller number");
+ for (short i = 0; i < MAX_GAMEPADS; i++) {
+ if ((currentControllers & (1 << i)) == 0) {
+ // Found an unused controller value
+ currentControllers |= (1 << i);
+
+ // Take this value out of the initial gamepad set
+ initialControllers &= ~(1 << i);
+
+ context.controllerNumber = i;
+ context.reservedControllerNumber = true;
+ break;
+ }
+ }
+ }
+ else if (!devContext.hasJoystickAxes) {
+ // If this device doesn't have joystick axes, it may be an input device associated
+ // with another joystick (like a PS4 touchpad). We'll propagate that joystick's
+ // controller number to this associated device.
+
+ context.controllerNumber = 0;
+
+ // For the DS4 case, the associated joystick is the next device after the touchpad.
+ // We'll try the opposite case too, just to be a little future-proof.
+ InputDevice associatedDevice = InputDevice.getDevice(devContext.id + 1);
+ if (!isAssociatedJoystick(devContext.inputDevice, associatedDevice)) {
+ associatedDevice = InputDevice.getDevice(devContext.id - 1);
+ if (!isAssociatedJoystick(devContext.inputDevice, associatedDevice)) {
+ LimeLog.info("No associated joystick device found");
+ associatedDevice = null;
+ }
+ }
+
+ if (associatedDevice != null) {
+ InputDeviceContext associatedDeviceContext = inputDeviceContexts.get(associatedDevice.getId());
+
+ // Create a new context for the associated device if one doesn't exist
+ if (associatedDeviceContext == null) {
+ associatedDeviceContext = createInputDeviceContextForDevice(associatedDevice);
+ inputDeviceContexts.put(associatedDevice.getId(), associatedDeviceContext);
+ }
+
+ // Assign a controller number for the associated device if one isn't assigned
+ if (!associatedDeviceContext.assignedControllerNumber) {
+ assignControllerNumberIfNeeded(associatedDeviceContext);
+ }
+
+ // Propagate the associated controller number
+ context.controllerNumber = associatedDeviceContext.controllerNumber;
+
+ LimeLog.info("Propagated controller number from "+associatedDeviceContext.name);
+ }
+ }
+ else {
+ LimeLog.info("Not reserving a controller number");
+ context.controllerNumber = 0;
+ }
+
+ // If the gamepad doesn't have motion sensors, use the on-device sensors as a fallback for player 1
+ if (prefConfig.gamepadMotionSensorsFallbackToDevice && context.controllerNumber == 0 && (prefConfig.forceMotionSensorsFallbackToDevice || devContext.sensorManager == null)) {
+ devContext.sensorManager = deviceSensorManager;
+ }
+ }
+ else {
+ if (prefConfig.multiController) {
+ context.controllerNumber = 0;
+
+ LimeLog.info("Reserving the next available controller number");
+ for (short i = 0; i < MAX_GAMEPADS; i++) {
+ if ((currentControllers & (1 << i)) == 0) {
+ // Found an unused controller value
+ currentControllers |= (1 << i);
+
+ // Take this value out of the initial gamepad set
+ initialControllers &= ~(1 << i);
+
+ context.controllerNumber = i;
+ context.reservedControllerNumber = true;
+ break;
+ }
+ }
+ }
+ else {
+ LimeLog.info("Not reserving a controller number");
+ context.controllerNumber = 0;
+ }
+ }
+
+ LimeLog.info("Assigned as controller "+context.controllerNumber);
+ context.assignedControllerNumber = true;
+
+ // Report attributes of this new controller to the host
+ context.sendControllerArrival();
+ }
+
+ private UsbDeviceContext createUsbDeviceContextForDevice(AbstractController device) {
+ UsbDeviceContext context = new UsbDeviceContext();
+
+ context.id = device.getControllerId();
+ context.device = device;
+ context.external = true;
+
+ context.vendorId = device.getVendorId();
+ context.productId = device.getProductId();
+
+ context.leftStickDeadzoneRadius = (float) stickDeadzone;
+ context.rightStickDeadzoneRadius = (float) stickDeadzone;
+ context.triggerDeadzone = 0.13f;
+
+ return context;
+ }
+
+ private static boolean hasButtonUnderTouchpad(InputDevice dev, byte type) {
+ // It has to have a touchpad to have a button under it
+ if ((dev.getSources() & InputDevice.SOURCE_TOUCHPAD) != InputDevice.SOURCE_TOUCHPAD) {
+ return false;
+ }
+
+ // Landroid/view/InputDevice;->hasButtonUnderPad()Z is blocked after O
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.O) {
+ try {
+ return (Boolean) dev.getClass().getMethod("hasButtonUnderPad").invoke(dev);
+ } catch (NoSuchMethodException e) {
+ e.printStackTrace();
+ } catch (IllegalAccessException e) {
+ e.printStackTrace();
+ } catch (InvocationTargetException e) {
+ e.printStackTrace();
+ } catch (ClassCastException e) {
+ e.printStackTrace();
+ }
+ }
+
+ // We can't use the platform API, so we'll have to just guess based on the gamepad type.
+ // If this is a PlayStation controller with a touchpad, we know it has a clickpad.
+ return type == MoonBridge.LI_CTYPE_PS;
+ }
+
+ private static boolean isExternal(InputDevice dev) {
+ // The ASUS Tinker Board inaccurately reports Bluetooth gamepads as internal,
+ // causing shouldIgnoreBack() to believe it should pass through back as a
+ // navigation event for any attached gamepads.
+ if (Build.MODEL.equals("Tinker Board")) {
+ return true;
+ }
+
+ String deviceName = dev.getName();
+ if (deviceName.contains("gpio") || // This is the back button on Shield portable consoles
+ deviceName.contains("joy_key") || // These are the gamepad buttons on the Archos Gamepad 2
+ deviceName.contains("keypad") || // These are gamepad buttons on the XPERIA Play
+ deviceName.equalsIgnoreCase("NVIDIA Corporation NVIDIA Controller v01.01") || // Gamepad on Shield Portable
+ deviceName.equalsIgnoreCase("NVIDIA Corporation NVIDIA Controller v01.02") || // Gamepad on Shield Portable (?)
+ deviceName.equalsIgnoreCase("GR0006") // Gamepad on Logitech G Cloud
+ )
+ {
+ LimeLog.info(dev.getName()+" is internal by hardcoded mapping");
+ return false;
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ // Landroid/view/InputDevice;->isExternal()Z is officially public on Android Q
+ return dev.isExternal();
+ }
+ else {
+ try {
+ // Landroid/view/InputDevice;->isExternal()Z is on the light graylist in Android P
+ return (Boolean)dev.getClass().getMethod("isExternal").invoke(dev);
+ } catch (NoSuchMethodException e) {
+ e.printStackTrace();
+ } catch (IllegalAccessException e) {
+ e.printStackTrace();
+ } catch (InvocationTargetException e) {
+ e.printStackTrace();
+ } catch (ClassCastException e) {
+ e.printStackTrace();
+ }
+ }
+
+ // Answer true if we don't know
+ return true;
+ }
+
+ private boolean shouldIgnoreBack(InputDevice dev) {
+ String devName = dev.getName();
+
+ // The Serval has a Select button but the framework doesn't
+ // know about that because it uses a non-standard scancode.
+ if (devName.contains("Razer Serval")) {
+ return true;
+ }
+
+ // Classify this device as a remote by name if it has no joystick axes
+ if (!hasJoystickAxes(dev) && devName.toLowerCase().contains("remote")) {
+ return true;
+ }
+
+ // Otherwise, dynamically try to determine whether we should allow this
+ // back button to function for navigation.
+ //
+ // First, check if this is an internal device we're being called on.
+ if (!isExternal(dev)) {
+ InputManager im = (InputManager) activityContext.getSystemService(Context.INPUT_SERVICE);
+
+ boolean foundInternalGamepad = false;
+ boolean foundInternalSelect = false;
+ for (int id : im.getInputDeviceIds()) {
+ InputDevice currentDev = im.getInputDevice(id);
+
+ // Ignore external devices
+ if (currentDev == null || isExternal(currentDev)) {
+ continue;
+ }
+
+ // Note that we are explicitly NOT excluding the current device we're examining here,
+ // since the other gamepad buttons may be on our current device and that's fine.
+ if (currentDev.hasKeys(KeyEvent.KEYCODE_BUTTON_SELECT)[0]) {
+ foundInternalSelect = true;
+ }
+
+ // We don't check KEYCODE_BUTTON_A here, since the Shield Android TV has a
+ // virtual mouse device that claims to have KEYCODE_BUTTON_A. Instead, we rely
+ // on the SOURCE_GAMEPAD flag to be set on gamepad devices.
+ if (hasGamepadButtons(currentDev)) {
+ foundInternalGamepad = true;
+ }
+ }
+
+ // Allow the back button to function for navigation if we either:
+ // a) have no internal gamepad (most phones)
+ // b) have an internal gamepad but also have an internal select button (GPD XD)
+ // but not:
+ // c) have an internal gamepad but no internal select button (NVIDIA SHIELD Portable)
+ return !foundInternalGamepad || foundInternalSelect;
+ }
+ else {
+ // For external devices, we want to pass through the back button if the device
+ // has no gamepad axes or gamepad buttons.
+ return !hasJoystickAxes(dev) && !hasGamepadButtons(dev);
+ }
+ }
+
+ private InputDeviceContext createInputDeviceContextForDevice(InputDevice dev) {
+ InputDeviceContext context = new InputDeviceContext();
+ String devName = dev.getName();
+
+ LimeLog.info("Creating controller context for device: "+devName);
+ LimeLog.info("Vendor ID: " + dev.getVendorId());
+ LimeLog.info("Product ID: "+dev.getProductId());
+ LimeLog.info(dev.toString());
+
+ context.inputDevice = dev;
+ context.name = devName;
+ context.id = dev.getId();
+ context.external = isExternal(dev);
+
+ context.vendorId = dev.getVendorId();
+ context.productId = dev.getProductId();
+
+ // These aren't always present in the Android key layout files, so they won't show up
+ // in our normal InputDevice.hasKeys() probing.
+ context.hasPaddles = MoonBridge.guessControllerHasPaddles(context.vendorId, context.productId);
+ context.hasShare = MoonBridge.guessControllerHasShareButton(context.vendorId, context.productId);
+
+ if (prefConfig.enableDeviceRumble) {
+ context.vibrator = deviceVibrator;
+ } else {
+ // Try to use the InputDevice's associated vibrators first
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && hasQuadAmplitudeControlledRumbleVibrators(dev.getVibratorManager())) {
+ context.vibratorManager = dev.getVibratorManager();
+ context.quadVibrators = true;
+ }
+ else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && hasDualAmplitudeControlledRumbleVibrators(dev.getVibratorManager())) {
+ context.vibratorManager = dev.getVibratorManager();
+ context.quadVibrators = false;
+ }
+ else if (dev.getVibrator().hasVibrator()) {
+ context.vibrator = dev.getVibrator();
+ }
+ else if (!context.external) {
+ // If this is an internal controller, try to use the device's vibrator
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && hasQuadAmplitudeControlledRumbleVibrators(deviceVibratorManager)) {
+ context.vibratorManager = deviceVibratorManager;
+ context.quadVibrators = true;
+ }
+ else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && hasDualAmplitudeControlledRumbleVibrators(deviceVibratorManager)) {
+ context.vibratorManager = deviceVibratorManager;
+ context.quadVibrators = false;
+ }
+ else if (deviceVibrator.hasVibrator()) {
+ context.vibrator = deviceVibrator;
+ }
+ }
+ }
+ // On Android 12, we can try to use the InputDevice's sensors. This may not work if the
+ // Linux kernel version doesn't have motion sensor support, which is common for third-party
+ // gamepads.
+ //
+ // Android 12 has a bug that causes InputDeviceSensorManager to cause a NPE on a background
+ // thread due to bad error checking in InputListener callbacks. InputDeviceSensorManager is
+ // created upon the first call to InputDevice.getSensorManager(), so we avoid calling this
+ // on Android 12 unless we have a gamepad that could plausibly have motion sensors.
+ // https://cs.android.com/android/_/android/platform/frameworks/base/+/8970010a5e9f3dc5c069f56b4147552accfcbbeb
+ if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ||
+ (Build.VERSION.SDK_INT == Build.VERSION_CODES.S &&
+ (context.vendorId == 0x054c || context.vendorId == 0x057e))) && // Sony or Nintendo
+ prefConfig.gamepadMotionSensors) {
+ if (dev.getSensorManager().getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null || dev.getSensorManager().getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null) {
+ context.sensorManager = dev.getSensorManager();
+ }
+ }
+
+ // Check if this device has a usable RGB LED and cache that result
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ for (Light light : dev.getLightsManager().getLights()) {
+ if (light.hasRgbControl()) {
+ context.hasRgbLed = true;
+ break;
+ }
+ }
+ }
+
+ // Detect if the gamepad has Mode and Select buttons according to the Android key layouts.
+ // We do this first because other codepaths below may override these defaults.
+ boolean[] buttons = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_MODE, KeyEvent.KEYCODE_BUTTON_SELECT, KeyEvent.KEYCODE_BACK, 0);
+ context.hasMode = buttons[0];
+ context.hasSelect = buttons[1] || buttons[2];
+
+ context.touchpadXRange = dev.getMotionRange(MotionEvent.AXIS_X, InputDevice.SOURCE_TOUCHPAD);
+ context.touchpadYRange = dev.getMotionRange(MotionEvent.AXIS_Y, InputDevice.SOURCE_TOUCHPAD);
+ context.touchpadPressureRange = dev.getMotionRange(MotionEvent.AXIS_PRESSURE, InputDevice.SOURCE_TOUCHPAD);
+
+ context.leftStickXAxis = MotionEvent.AXIS_X;
+ context.leftStickYAxis = MotionEvent.AXIS_Y;
+ if (getMotionRangeForJoystickAxis(dev, context.leftStickXAxis) != null &&
+ getMotionRangeForJoystickAxis(dev, context.leftStickYAxis) != null) {
+ // This is a gamepad
+ hasGameController = true;
+ context.hasJoystickAxes = true;
+ }
+
+ // This is hack to deal with the Nvidia Shield's modifications that causes the DS4 clickpad
+ // to work as a duplicate Select button instead of a unique button we can handle separately.
+ context.isDualShockStandaloneTouchpad =
+ context.vendorId == 0x054c && // Sony
+ devName.endsWith(" Touchpad") &&
+ dev.getSources() == (InputDevice.SOURCE_KEYBOARD | InputDevice.SOURCE_MOUSE);
+
+ InputDevice.MotionRange leftTriggerRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_LTRIGGER);
+ InputDevice.MotionRange rightTriggerRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RTRIGGER);
+ InputDevice.MotionRange brakeRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_BRAKE);
+ InputDevice.MotionRange gasRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_GAS);
+ InputDevice.MotionRange throttleRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_THROTTLE);
+ if (leftTriggerRange != null && rightTriggerRange != null)
+ {
+ // Some controllers use LTRIGGER and RTRIGGER (like Ouya)
+ context.leftTriggerAxis = MotionEvent.AXIS_LTRIGGER;
+ context.rightTriggerAxis = MotionEvent.AXIS_RTRIGGER;
+ }
+ else if (brakeRange != null && gasRange != null)
+ {
+ // Others use GAS and BRAKE (like Moga)
+ context.leftTriggerAxis = MotionEvent.AXIS_BRAKE;
+ context.rightTriggerAxis = MotionEvent.AXIS_GAS;
+ }
+ else if (brakeRange != null && throttleRange != null)
+ {
+ // Others use THROTTLE and BRAKE (like Xiaomi)
+ context.leftTriggerAxis = MotionEvent.AXIS_BRAKE;
+ context.rightTriggerAxis = MotionEvent.AXIS_THROTTLE;
+ }
+ else
+ {
+ InputDevice.MotionRange rxRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RX);
+ InputDevice.MotionRange ryRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RY);
+ if (rxRange != null && ryRange != null && devName != null) {
+ if (dev.getVendorId() == 0x054c) { // Sony
+ if (dev.hasKeys(KeyEvent.KEYCODE_BUTTON_C)[0]) {
+ LimeLog.info("Detected non-standard DualShock 4 mapping");
+ context.isNonStandardDualShock4 = true;
+ } else {
+ LimeLog.info("Detected DualShock 4 (Linux standard mapping)");
+ context.usesLinuxGamepadStandardFaceButtons = true;
+ }
+ }
+
+ if (context.isNonStandardDualShock4) {
+ // The old DS4 driver uses RX and RY for triggers
+ context.leftTriggerAxis = MotionEvent.AXIS_RX;
+ context.rightTriggerAxis = MotionEvent.AXIS_RY;
+
+ // DS4 has Select and Mode buttons (possibly mapped non-standard)
+ context.hasSelect = true;
+ context.hasMode = true;
+ }
+ else {
+ // If it's not a non-standard DS4 controller, it's probably an Xbox controller or
+ // other sane controller that uses RX and RY for right stick and Z and RZ for triggers.
+ context.rightStickXAxis = MotionEvent.AXIS_RX;
+ context.rightStickYAxis = MotionEvent.AXIS_RY;
+
+ // While it's likely that Z and RZ are triggers, we may have digital trigger buttons
+ // instead. We must check that we actually have Z and RZ axes before assigning them.
+ if (getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_Z) != null &&
+ getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RZ) != null) {
+ context.leftTriggerAxis = MotionEvent.AXIS_Z;
+ context.rightTriggerAxis = MotionEvent.AXIS_RZ;
+ }
+ }
+
+ // Triggers always idle negative on axes that are centered at zero
+ context.triggersIdleNegative = true;
+ }
+ }
+
+ if (context.rightStickXAxis == -1 && context.rightStickYAxis == -1) {
+ InputDevice.MotionRange zRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_Z);
+ InputDevice.MotionRange rzRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RZ);
+
+ // Most other controllers use Z and RZ for the right stick
+ if (zRange != null && rzRange != null) {
+ context.rightStickXAxis = MotionEvent.AXIS_Z;
+ context.rightStickYAxis = MotionEvent.AXIS_RZ;
+ }
+ else {
+ InputDevice.MotionRange rxRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RX);
+ InputDevice.MotionRange ryRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RY);
+
+ // Try RX and RY now
+ if (rxRange != null && ryRange != null) {
+ context.rightStickXAxis = MotionEvent.AXIS_RX;
+ context.rightStickYAxis = MotionEvent.AXIS_RY;
+ }
+ }
+ }
+
+ // Some devices have "hats" for d-pads
+ InputDevice.MotionRange hatXRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_HAT_X);
+ InputDevice.MotionRange hatYRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_HAT_Y);
+ if (hatXRange != null && hatYRange != null) {
+ context.hatXAxis = MotionEvent.AXIS_HAT_X;
+ context.hatYAxis = MotionEvent.AXIS_HAT_Y;
+ }
+
+ if (context.leftStickXAxis != -1 && context.leftStickYAxis != -1) {
+ context.leftStickDeadzoneRadius = (float) stickDeadzone;
+ }
+
+ if (context.rightStickXAxis != -1 && context.rightStickYAxis != -1) {
+ context.rightStickDeadzoneRadius = (float) stickDeadzone;
+ }
+
+ if (context.leftTriggerAxis != -1 && context.rightTriggerAxis != -1) {
+ InputDevice.MotionRange ltRange = getMotionRangeForJoystickAxis(dev, context.leftTriggerAxis);
+ InputDevice.MotionRange rtRange = getMotionRangeForJoystickAxis(dev, context.rightTriggerAxis);
+
+ // It's important to have a valid deadzone so controller packet batching works properly
+ context.triggerDeadzone = Math.max(Math.abs(ltRange.getFlat()), Math.abs(rtRange.getFlat()));
+
+ // For triggers without (valid) deadzones, we'll use 13% (around XInput's default)
+ if (context.triggerDeadzone < 0.13f ||
+ context.triggerDeadzone > 0.30f)
+ {
+ context.triggerDeadzone = 0.13f;
+ }
+ }
+
+ // The ADT-1 controller needs a similar fixup to the ASUS Gamepad
+ if (dev.getVendorId() == 0x18d1 && dev.getProductId() == 0x2c40) {
+ context.backIsStart = true;
+ context.modeIsSelect = true;
+ context.triggerDeadzone = 0.30f;
+ context.hasSelect = true;
+ context.hasMode = false;
+ }
+
+ context.ignoreBack = shouldIgnoreBack(dev);
+
+ if (devName != null) {
+ // For the Nexus Player (and probably other ATV devices), we should
+ // use the back button as start since it doesn't have a start/menu button
+ // on the controller
+ if (devName.contains("ASUS Gamepad")) {
+ boolean[] hasStartKey = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_START, KeyEvent.KEYCODE_MENU, 0);
+ if (!hasStartKey[0] && !hasStartKey[1]) {
+ context.backIsStart = true;
+ context.modeIsSelect = true;
+ context.hasSelect = true;
+ context.hasMode = false;
+ }
+
+ // The ASUS Gamepad has triggers that sit far forward and are prone to false presses
+ // so we increase the deadzone on them to minimize this
+ context.triggerDeadzone = 0.30f;
+ }
+ // SHIELD controllers will use small stick deadzones
+ else if (devName.contains("SHIELD") || devName.contains("NVIDIA Controller")) {
+ // The big Nvidia button on the Shield controllers acts like a Search button. It
+ // summons the Google Assistant on the Shield TV. On my Pixel 4, it seems to do
+ // nothing, so we can hijack it to act like a mode button.
+ if (devName.contains("NVIDIA Controller v01.03") || devName.contains("NVIDIA Controller v01.04")) {
+ context.searchIsMode = true;
+ context.hasMode = true;
+ }
+ }
+ // The Serval has a couple of unknown buttons that are start and select. It also has
+ // a back button which we want to ignore since there's already a select button.
+ else if (devName.contains("Razer Serval")) {
+ context.isServal = true;
+
+ // Serval has Select and Mode buttons (possibly mapped non-standard)
+ context.hasMode = true;
+ context.hasSelect = true;
+ }
+ // The Xbox One S Bluetooth controller has some mappings that need fixing up.
+ // However, Microsoft released a firmware update with no change to VID/PID
+ // or device name that fixed the mappings for Android. Since there's
+ // no good way to detect this, we'll use the presence of GAS/BRAKE axes
+ // that were added in the latest firmware. If those are present, the only
+ // required fixup is ignoring the select button.
+ else if (devName.equals("Xbox Wireless Controller")) {
+ if (gasRange == null) {
+ context.isNonStandardXboxBtController = true;
+
+ // Xbox One S has Select and Mode buttons (possibly mapped non-standard)
+ context.hasMode = true;
+ context.hasSelect = true;
+ }
+ }
+ }
+
+ // Thrustmaster Score A gamepad home button reports directly to android as
+ // KEY_HOMEPAGE event on another event channel
+ if (dev.getVendorId() == 0x044f && dev.getProductId() == 0xb328) {
+ context.hasMode = false;
+ }
+
+ LimeLog.info("Analog stick deadzone: "+context.leftStickDeadzoneRadius+" "+context.rightStickDeadzoneRadius);
+ LimeLog.info("Trigger deadzone: "+context.triggerDeadzone);
+
+ return context;
+ }
+
+ private InputDeviceContext getContextForEvent(InputEvent event) {
+ // Don't return a context if we're stopped
+ if (stopped) {
+ return null;
+ }
+ else if (event.getDeviceId() == 0) {
+ // Unknown devices use the default context
+ return defaultContext;
+ }
+ else if (event.getDevice() == null) {
+ // During device removal, sometimes we can get events after the
+ // input device has been destroyed. In this case we'll see a
+ // != 0 device ID but no device attached.
+ return null;
+ }
+
+ // HACK for https://issuetracker.google.com/issues/163120692
+ if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) {
+ if (event.getDeviceId() == -1) {
+ return defaultContext;
+ }
+ }
+
+ // Return the existing context if it exists
+ InputDeviceContext context = inputDeviceContexts.get(event.getDeviceId());
+ if (context != null) {
+ return context;
+ }
+
+ // Otherwise create a new context
+ context = createInputDeviceContextForDevice(event.getDevice());
+ inputDeviceContexts.put(event.getDeviceId(), context);
+
+ return context;
+ }
+
+ private byte maxByMagnitude(byte a, byte b) {
+ int absA = Math.abs(a);
+ int absB = Math.abs(b);
+ if (absA > absB) {
+ return a;
+ }
+ else {
+ return b;
+ }
+ }
+
+ private short maxByMagnitude(short a, short b) {
+ int absA = Math.abs(a);
+ int absB = Math.abs(b);
+ if (absA > absB) {
+ return a;
+ }
+ else {
+ return b;
+ }
+ }
+
+ private short getActiveControllerMask() {
+ if (prefConfig.multiController) {
+ return (short)(currentControllers | initialControllers | (prefConfig.onscreenController ? 1 : 0));
+ }
+ else {
+ // Only Player 1 is active with multi-controller disabled
+ return 1;
+ }
+ }
+
+ private static boolean areBatteryCapacitiesEqual(float first, float second) {
+ // With no NaNs involved, it is a simple equality comparison.
+ if (!Float.isNaN(first) && !Float.isNaN(second)) {
+ return first == second;
+ }
+ else {
+ // If we have a NaN in one or both positions, compare NaN-ness instead.
+ // Equality comparisons will always return false for NaN.
+ return Float.isNaN(first) == Float.isNaN(second);
+ }
+ }
+
+ // This must not be called on the main thread due to risk of ANRs!
+ private void sendControllerBatteryPacket(InputDeviceContext context) {
+ int currentBatteryStatus = BatteryState.STATUS_FULL;
+ float currentBatteryCapacity = 0;
+
+ boolean batteryPresent = false;
+
+ // Use the BatteryState object introduced in Android S, if it's available and present.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ BatteryState batteryState = context.inputDevice.getBatteryState();
+ batteryPresent = batteryState.isPresent();
+ if (batteryPresent) {
+ currentBatteryStatus = batteryState.getStatus();
+ currentBatteryCapacity = batteryState.getCapacity();
+ }
+ }
+
+ if (!batteryPresent) {
+ if (sceManager.isRecognizedDevice(context.inputDevice)) {
+ // On the SHIELD Android TV, we can use a proprietary API to access battery/charge state.
+ // We will convert it to the same form used by BatteryState to share code.
+ int batteryPercentage = sceManager.getBatteryPercentage(context.inputDevice);
+ if (batteryPercentage < 0) {
+ currentBatteryCapacity = Float.NaN;
+ }
+ else {
+ currentBatteryCapacity = batteryPercentage / 100.f;
+ }
+
+ SceConnectionType connectionType = sceManager.getConnectionType(context.inputDevice);
+ SceChargingState chargingState = sceManager.getChargingState(context.inputDevice);
+
+ // We can make some assumptions about charge state based on the connection type
+ if (connectionType == SceConnectionType.WIRED || connectionType == SceConnectionType.BOTH) {
+ if (batteryPercentage == 100) {
+ currentBatteryStatus = BatteryState.STATUS_FULL;
+ }
+ else if (chargingState == SceChargingState.NOT_CHARGING) {
+ currentBatteryStatus = BatteryState.STATUS_NOT_CHARGING;
+ }
+ else {
+ currentBatteryStatus = BatteryState.STATUS_CHARGING;
+ }
+ }
+ else if (connectionType == SceConnectionType.WIRELESS) {
+ if (chargingState == SceChargingState.CHARGING) {
+ currentBatteryStatus = BatteryState.STATUS_CHARGING;
+ }
+ else {
+ currentBatteryStatus = BatteryState.STATUS_DISCHARGING;
+ }
+ }
+ else {
+ // If connection type is unknown, just use the charge state
+ if (batteryPercentage == 100) {
+ currentBatteryStatus = BatteryState.STATUS_FULL;
+ }
+ else if (chargingState == SceChargingState.NOT_CHARGING) {
+ currentBatteryStatus = BatteryState.STATUS_DISCHARGING;
+ }
+ else if (chargingState == SceChargingState.CHARGING) {
+ currentBatteryStatus = BatteryState.STATUS_CHARGING;
+ }
+ else {
+ currentBatteryStatus = BatteryState.STATUS_UNKNOWN;
+ }
+ }
+ }
+ else {
+ return;
+ }
+ }
+
+ if (currentBatteryStatus != context.lastReportedBatteryStatus ||
+ !areBatteryCapacitiesEqual(currentBatteryCapacity, context.lastReportedBatteryCapacity)) {
+ byte state;
+ byte percentage;
+
+ switch (currentBatteryStatus) {
+ case BatteryState.STATUS_UNKNOWN:
+ state = MoonBridge.LI_BATTERY_STATE_UNKNOWN;
+ break;
+
+ case BatteryState.STATUS_CHARGING:
+ state = MoonBridge.LI_BATTERY_STATE_CHARGING;
+ break;
+
+ case BatteryState.STATUS_DISCHARGING:
+ state = MoonBridge.LI_BATTERY_STATE_DISCHARGING;
+ break;
+
+ case BatteryState.STATUS_NOT_CHARGING:
+ state = MoonBridge.LI_BATTERY_STATE_NOT_CHARGING;
+ break;
+
+ case BatteryState.STATUS_FULL:
+ state = MoonBridge.LI_BATTERY_STATE_FULL;
+ break;
+
+ default:
+ return;
+ }
+
+ if (Float.isNaN(currentBatteryCapacity)) {
+ percentage = MoonBridge.LI_BATTERY_PERCENTAGE_UNKNOWN;
+ }
+ else {
+ percentage = (byte)(currentBatteryCapacity * 100);
+ }
+
+ conn.sendControllerBatteryEvent((byte)context.controllerNumber, state, percentage);
+
+ context.lastReportedBatteryStatus = currentBatteryStatus;
+ context.lastReportedBatteryCapacity = currentBatteryCapacity;
+ }
+ }
+
+ private void sendControllerInputPacket(GenericControllerContext originalContext) {
+ assignControllerNumberIfNeeded(originalContext);
+
+ // Take the context's controller number and fuse all inputs with the same number
+ short controllerNumber = originalContext.controllerNumber;
+ int inputMap = 0;
+ byte leftTrigger = 0;
+ byte rightTrigger = 0;
+ short leftStickX = 0;
+ short leftStickY = 0;
+ short rightStickX = 0;
+ short rightStickY = 0;
+
+ // In order to properly handle controllers that are split into multiple devices,
+ // we must aggregate all controllers with the same controller number into a single
+ // device before we send it.
+ for (int i = 0; i < inputDeviceContexts.size(); i++) {
+ GenericControllerContext context = inputDeviceContexts.valueAt(i);
+ if (context.assignedControllerNumber &&
+ context.controllerNumber == controllerNumber &&
+ context.mouseEmulationActive == originalContext.mouseEmulationActive) {
+ inputMap |= context.inputMap;
+ leftTrigger |= maxByMagnitude(leftTrigger, context.leftTrigger);
+ rightTrigger |= maxByMagnitude(rightTrigger, context.rightTrigger);
+ leftStickX |= maxByMagnitude(leftStickX, context.leftStickX);
+ leftStickY |= maxByMagnitude(leftStickY, context.leftStickY);
+ rightStickX |= maxByMagnitude(rightStickX, context.rightStickX);
+ rightStickY |= maxByMagnitude(rightStickY, context.rightStickY);
+ }
+ }
+ for (int i = 0; i < usbDeviceContexts.size(); i++) {
+ GenericControllerContext context = usbDeviceContexts.valueAt(i);
+ if (context.assignedControllerNumber &&
+ context.controllerNumber == controllerNumber &&
+ context.mouseEmulationActive == originalContext.mouseEmulationActive) {
+ inputMap |= context.inputMap;
+ leftTrigger |= maxByMagnitude(leftTrigger, context.leftTrigger);
+ rightTrigger |= maxByMagnitude(rightTrigger, context.rightTrigger);
+ leftStickX |= maxByMagnitude(leftStickX, context.leftStickX);
+ leftStickY |= maxByMagnitude(leftStickY, context.leftStickY);
+ rightStickX |= maxByMagnitude(rightStickX, context.rightStickX);
+ rightStickY |= maxByMagnitude(rightStickY, context.rightStickY);
+ }
+ }
+ if (defaultContext.controllerNumber == controllerNumber) {
+ inputMap |= defaultContext.inputMap;
+ leftTrigger |= maxByMagnitude(leftTrigger, defaultContext.leftTrigger);
+ rightTrigger |= maxByMagnitude(rightTrigger, defaultContext.rightTrigger);
+ leftStickX |= maxByMagnitude(leftStickX, defaultContext.leftStickX);
+ leftStickY |= maxByMagnitude(leftStickY, defaultContext.leftStickY);
+ rightStickX |= maxByMagnitude(rightStickX, defaultContext.rightStickX);
+ rightStickY |= maxByMagnitude(rightStickY, defaultContext.rightStickY);
+ }
+
+ if (originalContext.mouseEmulationActive) {
+ int changedMask = inputMap ^ originalContext.mouseEmulationLastInputMap;
+
+ boolean aDown = (inputMap & ControllerPacket.A_FLAG) != 0;
+ boolean bDown = (inputMap & ControllerPacket.B_FLAG) != 0;
+
+ boolean xDown = (inputMap & ControllerPacket.X_FLAG) != 0;
+ boolean yDown = (inputMap & ControllerPacket.Y_FLAG) != 0;
+
+ originalContext.mouseEmulationLastInputMap = inputMap;
+
+ // Set the flag for the fixed pixel mouse movement while X_FLAG button is pressed
+ if((changedMask & ControllerPacket.X_FLAG) != 0)
+ {
+ // Set true when pressed
+ if( xDown ) {
+ originalContext.mouseEmulationXDown = true;
+ }
+ // Set false when released
+ else
+ {
+ originalContext.mouseEmulationXDown = false;
+ }
+ }
+
+ if((changedMask & ControllerPacket.Y_FLAG) != 0)
+ {
+ if( yDown )
+ {
+ // Double the pixel multiplier every button press
+ originalContext.mouseEmulationPixelMultiplier *= 2;
+ if( originalContext.mouseEmulationPixelMultiplier > 255 )
+ {
+ // Reset the multiplier back to 1 if it gets too big
+ originalContext.mouseEmulationPixelMultiplier = 1;
+ }
+ }
+ else {
+ // Do nothing as this is when the button is released; Holding the button will not continuously increase the pixel multiplier
+ }
+ }
+ if ((changedMask & ControllerPacket.A_FLAG) != 0) {
+ if (aDown) {
+ conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_LEFT);
+ }
+ else {
+ conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT);
+ }
+ }
+ if ((changedMask & ControllerPacket.B_FLAG) != 0) {
+ if (bDown) {
+ conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_RIGHT);
+ }
+ else {
+ conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT);
+ }
+ }
+ if ((changedMask & ControllerPacket.UP_FLAG) != 0) {
+ if ((inputMap & ControllerPacket.UP_FLAG) != 0) {
+ conn.sendMouseScroll((byte) 1);
+ }
+ }
+ if ((changedMask & ControllerPacket.DOWN_FLAG) != 0) {
+ if ((inputMap & ControllerPacket.DOWN_FLAG) != 0) {
+ conn.sendMouseScroll((byte) -1);
+ }
+ }
+ if ((changedMask & ControllerPacket.RIGHT_FLAG) != 0) {
+ if ((inputMap & ControllerPacket.RIGHT_FLAG) != 0) {
+ conn.sendMouseHScroll((byte) 1);
+ }
+ }
+ if ((changedMask & ControllerPacket.LEFT_FLAG) != 0) {
+ if ((inputMap & ControllerPacket.LEFT_FLAG) != 0) {
+ conn.sendMouseHScroll((byte) -1);
+ }
+ }
+
+ conn.sendControllerInput(controllerNumber, getActiveControllerMask(),
+ (short)0, (byte)0, (byte)0, (short)0, (short)0, (short)0, (short)0);
+ }
+ else {
+ conn.sendControllerInput(controllerNumber, getActiveControllerMask(),
+ inputMap,
+ leftTrigger, rightTrigger,
+ leftStickX, leftStickY,
+ rightStickX, rightStickY);
+ }
+ }
+
+ private final int REMAP_IGNORE = -1;
+ private final int REMAP_CONSUME = -2;
+
+ // Return a valid keycode, -2 to consume, or -1 to not consume the event
+ // Device MAY BE NULL
+ private int handleRemapping(InputDeviceContext context, KeyEvent event) {
+ // Don't capture the back button if configured
+ if (context.ignoreBack) {
+ if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) {
+ return REMAP_IGNORE;
+ }
+ }
+
+ // If we know this gamepad has a share button and receive an unmapped
+ // KEY_RECORD event, report that as a share button press.
+ if (context.hasShare) {
+ if (event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN &&
+ event.getScanCode() == 167) {
+ return KeyEvent.KEYCODE_MEDIA_RECORD;
+ }
+ }
+
+ // The Shield's key layout files map the DualShock 4 clickpad button to
+ // BUTTON_SELECT instead of something sane like BUTTON_1 as the standard AOSP
+ // mapping does. If we get a button from a Sony device reported as BUTTON_SELECT
+ // that matches the keycode used by hid-sony for the clickpad or it's from the
+ // separate touchpad input device, remap it to BUTTON_1 to match the current AOSP
+ // layout and trigger our touchpad button logic.
+ if (context.vendorId == 0x054c &&
+ event.getKeyCode() == KeyEvent.KEYCODE_BUTTON_SELECT &&
+ (event.getScanCode() == 317 || context.isDualShockStandaloneTouchpad)) {
+ return KeyEvent.KEYCODE_BUTTON_1;
+ }
+
+ // Override mode button for 8BitDo controllers
+ if (context.vendorId == 0x2dc8 && event.getScanCode() == 306) {
+ return KeyEvent.KEYCODE_BUTTON_MODE;
+ }
+
+ // This mapping was adding in Android 10, then changed based on
+ // kernel changes (adding hid-nintendo) in Android 11. If we're
+ // on anything newer than Pie, just use the built-in mapping.
+ if ((context.vendorId == 0x057e && context.productId == 0x2009 && Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) || // Switch Pro controller
+ (context.vendorId == 0x0f0d && context.productId == 0x00c1)) { // HORIPAD for Switch
+ switch (event.getScanCode()) {
+ case 0x130://304
+ return KeyEvent.KEYCODE_BUTTON_A;
+ case 0x131:
+ return KeyEvent.KEYCODE_BUTTON_B;
+ case 0x132:
+ return KeyEvent.KEYCODE_BUTTON_X;
+ case 0x133:
+ return KeyEvent.KEYCODE_BUTTON_Y;
+ case 0x134:
+ return KeyEvent.KEYCODE_BUTTON_L1;
+ case 0x135:
+ return KeyEvent.KEYCODE_BUTTON_R1;
+ case 0x136:
+ return KeyEvent.KEYCODE_BUTTON_L2;
+ case 0x137:
+ return KeyEvent.KEYCODE_BUTTON_R2;
+ case 0x138:
+ return KeyEvent.KEYCODE_BUTTON_SELECT;
+ case 0x139:
+ return KeyEvent.KEYCODE_BUTTON_START;
+ case 0x13A:
+ return KeyEvent.KEYCODE_BUTTON_THUMBL;
+ case 0x13B:
+ return KeyEvent.KEYCODE_BUTTON_THUMBR;
+ case 0x13D:
+ return KeyEvent.KEYCODE_BUTTON_MODE;
+ }
+ }
+
+
+ //fix joycon-left 十字键
+ if(prefConfig.enableJoyConFix&&context.vendorId == 0x057e && context.productId == 0x2006){
+ switch (event.getScanCode())
+ {
+ case 546://十字键
+ return KeyEvent.KEYCODE_DPAD_LEFT;
+ case 547:
+ return KeyEvent.KEYCODE_DPAD_RIGHT;
+ case 544:
+ return KeyEvent.KEYCODE_DPAD_UP;
+ case 545:
+ return KeyEvent.KEYCODE_DPAD_DOWN;
+ case 309://截图键
+ return KeyEvent.KEYCODE_BUTTON_MODE;
+ case 310:
+ return KeyEvent.KEYCODE_BUTTON_L1;
+ case 312:
+ return KeyEvent.KEYCODE_BUTTON_L2;
+ case 314:
+ return KeyEvent.KEYCODE_BUTTON_SELECT;
+ case 317:
+ return KeyEvent.KEYCODE_BUTTON_THUMBL;
+ }
+ }
+ //fix JoyCon-right xy互换
+ if(prefConfig.enableJoyConFix&&context.vendorId == 0x057e && context.productId == 0x2007){
+ switch (event.getScanCode())
+ {
+ case 307://XY相反
+ return KeyEvent.KEYCODE_BUTTON_Y;
+ case 308:
+ return KeyEvent.KEYCODE_BUTTON_X;
+ case 304:
+ return KeyEvent.KEYCODE_BUTTON_A;
+ case 305:
+ return KeyEvent.KEYCODE_BUTTON_B;
+ case 311:
+ return KeyEvent.KEYCODE_BUTTON_R1;
+ case 313:
+ return KeyEvent.KEYCODE_BUTTON_R2;
+ case 315:
+ return KeyEvent.KEYCODE_BUTTON_START;
+ case 316:
+ return KeyEvent.KEYCODE_BUTTON_MODE;
+ case 318:
+ return KeyEvent.KEYCODE_BUTTON_THUMBR;
+ }
+ }
+
+
+ if (context.usesLinuxGamepadStandardFaceButtons) {
+ // Android's Generic.kl swaps BTN_NORTH and BTN_WEST
+ switch (event.getScanCode()) {
+ case 304:
+ return KeyEvent.KEYCODE_BUTTON_A;
+ case 305:
+ return KeyEvent.KEYCODE_BUTTON_B;
+ case 307:
+ return KeyEvent.KEYCODE_BUTTON_Y;
+ case 308:
+ return KeyEvent.KEYCODE_BUTTON_X;
+ }
+ }
+
+ if (context.isNonStandardDualShock4) {
+ switch (event.getScanCode()) {
+ case 304:
+ return KeyEvent.KEYCODE_BUTTON_X;
+ case 305:
+ return KeyEvent.KEYCODE_BUTTON_A;
+ case 306:
+ return KeyEvent.KEYCODE_BUTTON_B;
+ case 307:
+ return KeyEvent.KEYCODE_BUTTON_Y;
+ case 308:
+ return KeyEvent.KEYCODE_BUTTON_L1;
+ case 309:
+ return KeyEvent.KEYCODE_BUTTON_R1;
+ /*
+ **** Using analog triggers instead ****
+ case 310:
+ return KeyEvent.KEYCODE_BUTTON_L2;
+ case 311:
+ return KeyEvent.KEYCODE_BUTTON_R2;
+ */
+ case 312:
+ return KeyEvent.KEYCODE_BUTTON_SELECT;
+ case 313:
+ return KeyEvent.KEYCODE_BUTTON_START;
+ case 314:
+ return KeyEvent.KEYCODE_BUTTON_THUMBL;
+ case 315:
+ return KeyEvent.KEYCODE_BUTTON_THUMBR;
+ case 316:
+ return KeyEvent.KEYCODE_BUTTON_MODE;
+ default:
+ return REMAP_CONSUME;
+ }
+ }
+ // If this is a Serval controller sending an unknown key code, it's probably
+ // the start and select buttons
+ else if (context.isServal && event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN) {
+ switch (event.getScanCode()) {
+ case 314:
+ return KeyEvent.KEYCODE_BUTTON_SELECT;
+ case 315:
+ return KeyEvent.KEYCODE_BUTTON_START;
+ }
+ }
+ else if (context.isNonStandardXboxBtController) {
+ switch (event.getScanCode()) {
+ case 306:
+ return KeyEvent.KEYCODE_BUTTON_X;
+ case 307:
+ return KeyEvent.KEYCODE_BUTTON_Y;
+ case 308:
+ return KeyEvent.KEYCODE_BUTTON_L1;
+ case 309:
+ return KeyEvent.KEYCODE_BUTTON_R1;
+ case 310:
+ return KeyEvent.KEYCODE_BUTTON_SELECT;
+ case 311:
+ return KeyEvent.KEYCODE_BUTTON_START;
+ case 312:
+ return KeyEvent.KEYCODE_BUTTON_THUMBL;
+ case 313:
+ return KeyEvent.KEYCODE_BUTTON_THUMBR;
+ case 139:
+ return KeyEvent.KEYCODE_BUTTON_MODE;
+ default:
+ // Other buttons are mapped correctly
+ }
+
+ // The Xbox button is sent as MENU
+ if (event.getKeyCode() == KeyEvent.KEYCODE_MENU) {
+ return KeyEvent.KEYCODE_BUTTON_MODE;
+ }
+ }
+ else if (context.vendorId == 0x0b05 && // ASUS
+ (context.productId == 0x7900 || // Kunai - USB
+ context.productId == 0x7902)) // Kunai - Bluetooth
+ {
+ // ROG Kunai has special M1-M4 buttons that are accessible via the
+ // joycon-style detachable controllers that we should map to Start
+ // and Select.
+ switch (event.getScanCode()) {
+ case 264:
+ case 266:
+ return KeyEvent.KEYCODE_BUTTON_START;
+
+ case 265:
+ case 267:
+ return KeyEvent.KEYCODE_BUTTON_SELECT;
+ }
+ }
+
+ if (context.hatXAxis == -1 &&
+ context.hatYAxis == -1 &&
+ /* FIXME: There's no good way to know for sure if xpad is bound
+ to this device, so we won't use the name to validate if these
+ scancodes should be mapped to DPAD
+
+ context.isXboxController &&
+ */
+ event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN) {
+ // If there's not a proper Xbox controller mapping, we'll translate the raw d-pad
+ // scan codes into proper key codes
+ switch (event.getScanCode())
+ {
+ case 704:
+ return KeyEvent.KEYCODE_DPAD_LEFT;
+ case 705:
+ return KeyEvent.KEYCODE_DPAD_RIGHT;
+ case 706:
+ return KeyEvent.KEYCODE_DPAD_UP;
+ case 707:
+ return KeyEvent.KEYCODE_DPAD_DOWN;
+ }
+ }
+
+ // Past here we can fixup the keycode and potentially trigger
+ // another special case so we need to remember what keycode we're using
+ int keyCode = event.getKeyCode();
+
+ // This is a hack for (at least) the "Tablet Remote" app
+ // which sends BACK with META_ALT_ON instead of KEYCODE_BUTTON_B
+ if (keyCode == KeyEvent.KEYCODE_BACK &&
+ !event.hasNoModifiers() &&
+ (event.getFlags() & KeyEvent.FLAG_SOFT_KEYBOARD) != 0)
+ {
+ keyCode = KeyEvent.KEYCODE_BUTTON_B;
+ }
+
+ if (keyCode == KeyEvent.KEYCODE_BUTTON_START ||
+ keyCode == KeyEvent.KEYCODE_MENU) {
+ // Ensure that we never use back as start if we have a real start
+ context.backIsStart = false;
+ }
+ else if (keyCode == KeyEvent.KEYCODE_BUTTON_SELECT) {
+ // Don't use mode as select if we have a select
+ context.modeIsSelect = false;
+ }
+ else if (context.backIsStart && keyCode == KeyEvent.KEYCODE_BACK) {
+ // Emulate the start button with back
+ return KeyEvent.KEYCODE_BUTTON_START;
+ }
+ else if (context.modeIsSelect && keyCode == KeyEvent.KEYCODE_BUTTON_MODE) {
+ // Emulate the select button with mode
+ return KeyEvent.KEYCODE_BUTTON_SELECT;
+ }
+ else if (context.searchIsMode && keyCode == KeyEvent.KEYCODE_SEARCH) {
+ // Emulate the mode button with search
+ return KeyEvent.KEYCODE_BUTTON_MODE;
+ }
+
+ return keyCode;
+ }
+
+ private int handleFlipFaceButtons(int keyCode) {
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_BUTTON_A:
+ return KeyEvent.KEYCODE_BUTTON_B;
+ case KeyEvent.KEYCODE_BUTTON_B:
+ return KeyEvent.KEYCODE_BUTTON_A;
+ case KeyEvent.KEYCODE_BUTTON_X:
+ return KeyEvent.KEYCODE_BUTTON_Y;
+ case KeyEvent.KEYCODE_BUTTON_Y:
+ return KeyEvent.KEYCODE_BUTTON_X;
+ default:
+ return keyCode;
+ }
+ }
+
+ private Vector2d populateCachedVector(float x, float y) {
+ // Reinitialize our cached Vector2d object
+ inputVector.initialize(x, y);
+ return inputVector;
+ }
+
+ private void handleDeadZone(Vector2d stickVector, float deadzoneRadius) {
+ if (deadzoneRadius > 0) {
+ // We're not normalizing here because we let the computer handle the deadzones.
+ // Normalizing can make the deadzones larger than they should be after the computer also
+ // evaluates the deadzone.
+ if (stickVector.getMagnitude() <= deadzoneRadius) {
+ // Deadzone
+ stickVector.initialize(0, 0);
+ }
+ } else {
+ double currentMagnitude = stickVector.getMagnitude();
+ if (currentMagnitude < 0.01) {
+ // Keep a 1% actual deadzone for actual centering
+ stickVector.initialize(0, 0);
+ return;
+ }
+ double remainingMagnitude = 1 + deadzoneRadius;
+ double normalizedMagnitude = -deadzoneRadius + currentMagnitude * remainingMagnitude;
+ if (normalizedMagnitude >= 1) {
+ return;
+ }
+ double scaleFactor = normalizedMagnitude / currentMagnitude;
+ stickVector.setX((float) (stickVector.getX() * scaleFactor));
+ stickVector.setY((float) (stickVector.getY() * scaleFactor));
+ }
+ }
+
+ private void handleAxisSet(InputDeviceContext context, float lsX, float lsY, float rsX,
+ float rsY, float lt, float rt, float hatX, float hatY) {
+
+ if (context.leftStickXAxis != -1 && context.leftStickYAxis != -1) {
+ Vector2d leftStickVector = populateCachedVector(lsX, lsY);
+
+ handleDeadZone(leftStickVector, context.leftStickDeadzoneRadius);
+
+ context.leftStickX = (short) (leftStickVector.getX() * 0x7FFE);
+ context.leftStickY = (short) (-leftStickVector.getY() * 0x7FFE);
+ }
+
+ if (context.rightStickXAxis != -1 && context.rightStickYAxis != -1) {
+ Vector2d rightStickVector = populateCachedVector(rsX, rsY);
+
+ handleDeadZone(rightStickVector, context.rightStickDeadzoneRadius);
+
+ context.rightStickX = (short) (rightStickVector.getX() * 0x7FFE);
+ context.rightStickY = (short) (-rightStickVector.getY() * 0x7FFE);
+ }
+
+ if (context.leftTriggerAxis != -1 && context.rightTriggerAxis != -1) {
+ // Android sends an initial 0 value for trigger axes even if the trigger
+ // should be negative when idle. After the first touch, the axes will go back
+ // to normal behavior, so ignore triggersIdleNegative for each trigger until
+ // first touch.
+ if (lt != 0) {
+ context.leftTriggerAxisUsed = true;
+ }
+ if (rt != 0) {
+ context.rightTriggerAxisUsed = true;
+ }
+ if (context.triggersIdleNegative) {
+ if (context.leftTriggerAxisUsed) {
+ lt = (lt + 1) / 2;
+ }
+ if (context.rightTriggerAxisUsed) {
+ rt = (rt + 1) / 2;
+ }
+ }
+
+ if (lt <= context.triggerDeadzone) {
+ lt = 0;
+ }
+ if (rt <= context.triggerDeadzone) {
+ rt = 0;
+ }
+
+ context.leftTrigger = (byte)(lt * 0xFF);
+ context.rightTrigger = (byte)(rt * 0xFF);
+ }
+
+ if (context.hatXAxis != -1 && context.hatYAxis != -1) {
+ context.inputMap &= ~(ControllerPacket.LEFT_FLAG | ControllerPacket.RIGHT_FLAG);
+ if (hatX < -0.5) {
+ context.inputMap |= ControllerPacket.LEFT_FLAG;
+ context.hatXAxisUsed = true;
+ }
+ else if (hatX > 0.5) {
+ context.inputMap |= ControllerPacket.RIGHT_FLAG;
+ context.hatXAxisUsed = true;
+ }
+
+ context.inputMap &= ~(ControllerPacket.UP_FLAG | ControllerPacket.DOWN_FLAG);
+ if (hatY < -0.5) {
+ context.inputMap |= ControllerPacket.UP_FLAG;
+ context.hatYAxisUsed = true;
+ }
+ else if (hatY > 0.5) {
+ context.inputMap |= ControllerPacket.DOWN_FLAG;
+ context.hatYAxisUsed = true;
+ }
+ }
+
+ sendControllerInputPacket(context);
+ }
+
+ // Normalize the given raw float value into a 0.0-1.0f range
+ private float normalizeRawValueWithRange(float value, InputDevice.MotionRange range) {
+ value = Math.max(value, range.getMin());
+ value = Math.min(value, range.getMax());
+
+ value -= range.getMin();
+
+ return value / range.getRange();
+ }
+
+ private boolean sendTouchpadEventForPointer(InputDeviceContext context, MotionEvent event, byte touchType, int pointerIndex) {
+ float normalizedX = normalizeRawValueWithRange(event.getX(pointerIndex), context.touchpadXRange);
+ float normalizedY = normalizeRawValueWithRange(event.getY(pointerIndex), context.touchpadYRange);
+ float normalizedPressure = context.touchpadPressureRange != null ?
+ normalizeRawValueWithRange(event.getPressure(pointerIndex), context.touchpadPressureRange)
+ : 0;
+
+ return conn.sendControllerTouchEvent((byte)context.controllerNumber, touchType,
+ event.getPointerId(pointerIndex),
+ normalizedX, normalizedY, normalizedPressure) != MoonBridge.LI_ERR_UNSUPPORTED;
+ }
+
+ public boolean tryHandleTouchpadEvent(MotionEvent event) {
+ // Bail if this is not a touchpad or mouse event
+ if (event.getSource() != InputDevice.SOURCE_TOUCHPAD &&
+ event.getSource() != InputDevice.SOURCE_MOUSE) {
+ return false;
+ }
+
+ // Only get a context if one already exists. We want to ensure we don't report non-gamepads.
+ InputDeviceContext context = inputDeviceContexts.get(event.getDeviceId());
+ if (context == null) {
+ return false;
+ }
+
+ // When we're working with a mouse source instead of a touchpad, we're quite limited in
+ // what useful input we can provide via the controller API. The ABS_X/ABS_Y values are
+ // screen coordinates rather than touchpad coordinates. For now, we will just support
+ // the clickpad button and nothing else.
+ if (event.getSource() == InputDevice.SOURCE_MOUSE) {
+ // Unlike the touchpad where down and up refer to individual touches on the touchpad,
+ // down and up on a mouse indicates the state of the left mouse button.
+ switch (event.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN:
+ context.inputMap |= ControllerPacket.TOUCHPAD_FLAG;
+ sendControllerInputPacket(context);
+ break;
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_CANCEL:
+ context.inputMap &= ~ControllerPacket.TOUCHPAD_FLAG;
+ sendControllerInputPacket(context);
+ break;
+ default:
+ break;
+ }
+
+ return !prefConfig.gamepadTouchpadAsMouse;
+ }
+
+ byte touchType;
+ switch (event.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_POINTER_DOWN:
+ touchType = MoonBridge.LI_TOUCH_EVENT_DOWN;
+ break;
+
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_POINTER_UP:
+ if ((event.getFlags() & MotionEvent.FLAG_CANCELED) != 0) {
+ touchType = MoonBridge.LI_TOUCH_EVENT_CANCEL;
+ }
+ else {
+ touchType = MoonBridge.LI_TOUCH_EVENT_UP;
+ }
+ break;
+
+ case MotionEvent.ACTION_MOVE:
+ touchType = MoonBridge.LI_TOUCH_EVENT_MOVE;
+ break;
+
+ case MotionEvent.ACTION_CANCEL:
+ // ACTION_CANCEL applies to *all* pointers in the gesture, so it maps to CANCEL_ALL
+ // rather than CANCEL. For a single pointer cancellation, that's indicated via
+ // FLAG_CANCELED on a ACTION_POINTER_UP.
+ // https://developer.android.com/develop/ui/views/touch-and-input/gestures/multi
+ touchType = MoonBridge.LI_TOUCH_EVENT_CANCEL_ALL;
+ break;
+
+ case MotionEvent.ACTION_BUTTON_PRESS:
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && event.getActionButton() == MotionEvent.BUTTON_PRIMARY) {
+ context.inputMap |= ControllerPacket.TOUCHPAD_FLAG;
+ sendControllerInputPacket(context);
+ return !prefConfig.gamepadTouchpadAsMouse; // Report as unhandled event to trigger mouse handling
+ }
+ return false;
+
+ case MotionEvent.ACTION_BUTTON_RELEASE:
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && event.getActionButton() == MotionEvent.BUTTON_PRIMARY) {
+ context.inputMap &= ~ControllerPacket.TOUCHPAD_FLAG;
+ sendControllerInputPacket(context);
+ return !prefConfig.gamepadTouchpadAsMouse; // Report as unhandled event to trigger mouse handling
+ }
+ return false;
+
+ default:
+ return false;
+ }
+
+ // Bail if the user wants gamepad touchpads to control the mouse
+ //
+ // NB: We do this after processing ACTION_BUTTON_PRESS and ACTION_BUTTON_RELEASE
+ // because we want to still send the touchpad button via the gamepad even when
+ // configured to use the touchpad for mouse control.
+ if (prefConfig.gamepadTouchpadAsMouse) {
+ return false;
+ }
+
+ // If we don't have X and Y ranges, we can't process this event
+ if (context.touchpadXRange == null || context.touchpadYRange == null) {
+ return false;
+ }
+
+ if (event.getActionMasked() == MotionEvent.ACTION_MOVE) {
+ // Move events may impact all active pointers
+ for (int i = 0; i < event.getPointerCount(); i++) {
+ if (!sendTouchpadEventForPointer(context, event, touchType, i)) {
+ // Controller touch events are not supported by the host
+ return false;
+ }
+ }
+ return true;
+ }
+ else if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) {
+ // Cancel impacts all active pointers
+ return conn.sendControllerTouchEvent((byte)context.controllerNumber, MoonBridge.LI_TOUCH_EVENT_CANCEL_ALL,
+ 0, 0, 0, 0) != MoonBridge.LI_ERR_UNSUPPORTED;
+ }
+ else {
+ // Down and Up events impact the action index pointer
+ return sendTouchpadEventForPointer(context, event, touchType, event.getActionIndex());
+ }
+ }
+
+ public boolean handleMotionEvent(MotionEvent event) {
+ InputDeviceContext context = getContextForEvent(event);
+ if (context == null) {
+ return true;
+ }
+
+ float lsX = 0, lsY = 0, rsX = 0, rsY = 0, rt = 0, lt = 0, hatX = 0, hatY = 0;
+
+ // We purposefully ignore the historical values in the motion event as it makes
+ // the controller feel sluggish for some users.
+
+ if (context.leftStickXAxis != -1 && context.leftStickYAxis != -1) {
+ lsX = event.getAxisValue(context.leftStickXAxis);
+ lsY = event.getAxisValue(context.leftStickYAxis);
+ }
+
+ if (context.rightStickXAxis != -1 && context.rightStickYAxis != -1) {
+ rsX = event.getAxisValue(context.rightStickXAxis);
+ rsY = event.getAxisValue(context.rightStickYAxis);
+ }
+
+ if (context.leftTriggerAxis != -1 && context.rightTriggerAxis != -1) {
+ lt = event.getAxisValue(context.leftTriggerAxis);
+ rt = event.getAxisValue(context.rightTriggerAxis);
+ }
+
+ if (context.hatXAxis != -1 && context.hatYAxis != -1) {
+ hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
+ hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y);
+ }
+
+ handleAxisSet(context, lsX, lsY, rsX, rsY, lt, rt, hatX, hatY);
+
+ return true;
+ }
+
+ private Vector2d convertRawStickAxisToPixelMovement(short stickX, short stickY) {
+ Vector2d vector = new Vector2d();
+ vector.initialize(stickX, stickY);
+ vector.scalarMultiply(1 / 32766.0f);
+ vector.scalarMultiply(4);
+ if (vector.getMagnitude() > 0) {
+ // Move faster as the stick is pressed further from center
+ vector.scalarMultiply(Math.pow(vector.getMagnitude(), 2));
+ }
+ return vector;
+ }
+
+ private void sendEmulatedMouseMove(short x, short y, boolean mouseEmulationXDown, int mouseEmulationPixelMultiplier) {
+ Vector2d vector = convertRawStickAxisToPixelMovement(x, y);
+ if (vector.getMagnitude() >= 1) {
+
+ // Used a fixed amount of mouse movement while the X button is pressed
+ if(mouseEmulationXDown == true )
+ {
+ // convert the vector number to -1 if negative and +1 if positive and then send the mouse movement in pixels
+ conn.sendMouseMove((short)(Integer.signum((int)vector.getX()) * mouseEmulationPixelMultiplier) , (short)(Integer.signum((int)-vector.getY()) * mouseEmulationPixelMultiplier) );
+ }
+ else {
+ // If X button is not pressed, base the movement on how much the stick is moved from the center
+ conn.sendMouseMove((short) vector.getX(), (short) -vector.getY());
+ }
+ }
+ }
+
+ private void sendEmulatedMouseScroll(short x, short y) {
+ Vector2d vector = convertRawStickAxisToPixelMovement(x, y);
+ if (vector.getMagnitude() >= 1) {
+ conn.sendMouseHighResScroll((short)vector.getY());
+ conn.sendMouseHighResHScroll((short)vector.getX());
+ }
+ }
+
+ @TargetApi(31)
+ private boolean hasDualAmplitudeControlledRumbleVibrators(VibratorManager vm) {
+ int[] vibratorIds = vm.getVibratorIds();
+
+ // There must be exactly 2 vibrators on this device
+ if (vibratorIds.length != 2) {
+ return false;
+ }
+
+ // Both vibrators must have amplitude control
+ for (int vid : vibratorIds) {
+ if (!vm.getVibrator(vid).hasAmplitudeControl()) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ // This must only be called if hasDualAmplitudeControlledRumbleVibrators() is true!
+ @TargetApi(31)
+ private void rumbleDualVibrators(VibratorManager vm, short lowFreqMotor, short highFreqMotor) {
+ // Normalize motor values to 0-255 amplitudes for VibrationManager
+ highFreqMotor = (short)((highFreqMotor >> 8) & 0xFF);
+ lowFreqMotor = (short)((lowFreqMotor >> 8) & 0xFF);
+
+ // If they're both zero, we can just call cancel().
+ if (lowFreqMotor == 0 && highFreqMotor == 0) {
+ vm.cancel();
+ return;
+ }
+
+ // There's no documentation that states that vibrators for FF_RUMBLE input devices will
+ // always be enumerated in this order, but it seems consistent between Xbox Series X (USB),
+ // PS3 (USB), and PS4 (USB+BT) controllers on Android 12 Beta 3.
+ int[] vibratorIds = vm.getVibratorIds();
+ int[] vibratorAmplitudes = new int[] { highFreqMotor, lowFreqMotor };
+
+ CombinedVibration.ParallelCombination combo = CombinedVibration.startParallel();
+
+ for (int i = 0; i < vibratorIds.length; i++) {
+ // It's illegal to create a VibrationEffect with an amplitude of 0.
+ // Simply excluding that vibrator from our ParallelCombination will turn it off.
+ if (vibratorAmplitudes[i] != 0) {
+ combo.addVibrator(vibratorIds[i], VibrationEffect.createOneShot(60000, vibratorAmplitudes[i]));
+ }
+ }
+
+ VibrationAttributes.Builder vibrationAttributes = new VibrationAttributes.Builder();
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ vibrationAttributes.setUsage(VibrationAttributes.USAGE_MEDIA);
+ }
+
+ vm.vibrate(combo.combine(), vibrationAttributes.build());
+ }
+
+ @TargetApi(31)
+ private boolean hasQuadAmplitudeControlledRumbleVibrators(VibratorManager vm) {
+ int[] vibratorIds = vm.getVibratorIds();
+
+ // There must be exactly 4 vibrators on this device
+ if (vibratorIds.length != 4) {
+ return false;
+ }
+
+ // All vibrators must have amplitude control
+ for (int vid : vibratorIds) {
+ if (!vm.getVibrator(vid).hasAmplitudeControl()) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ // This must only be called if hasQuadAmplitudeControlledRumbleVibrators() is true!
+ @TargetApi(31)
+ private void rumbleQuadVibrators(VibratorManager vm, short lowFreqMotor, short highFreqMotor, short leftTrigger, short rightTrigger) {
+ // Normalize motor values to 0-255 amplitudes for VibrationManager
+ highFreqMotor = (short)((highFreqMotor >> 8) & 0xFF);
+ lowFreqMotor = (short)((lowFreqMotor >> 8) & 0xFF);
+ leftTrigger = (short)((leftTrigger >> 8) & 0xFF);
+ rightTrigger = (short)((rightTrigger >> 8) & 0xFF);
+
+ // If they're all zero, we can just call cancel().
+ if (lowFreqMotor == 0 && highFreqMotor == 0 && leftTrigger == 0 && rightTrigger == 0) {
+ vm.cancel();
+ return;
+ }
+
+ // This is a guess based upon the behavior of FF_RUMBLE, but untested due to lack of Linux
+ // support for trigger rumble!
+ int[] vibratorIds = vm.getVibratorIds();
+ int[] vibratorAmplitudes = new int[] { highFreqMotor, lowFreqMotor, leftTrigger, rightTrigger };
+
+ CombinedVibration.ParallelCombination combo = CombinedVibration.startParallel();
+
+ for (int i = 0; i < vibratorIds.length; i++) {
+ // It's illegal to create a VibrationEffect with an amplitude of 0.
+ // Simply excluding that vibrator from our ParallelCombination will turn it off.
+ if (vibratorAmplitudes[i] != 0) {
+ combo.addVibrator(vibratorIds[i], VibrationEffect.createOneShot(60000, vibratorAmplitudes[i]));
+ }
+ }
+
+ VibrationAttributes.Builder vibrationAttributes = new VibrationAttributes.Builder();
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ vibrationAttributes.setUsage(VibrationAttributes.USAGE_MEDIA);
+ }
+
+ vm.vibrate(combo.combine(), vibrationAttributes.build());
+ }
+
+ private void rumbleSingleVibrator(Vibrator vibrator, short lowFreqMotor, short highFreqMotor) {
+ // Since we can only use a single amplitude value, compute the desired amplitude
+ // by taking 80% of the big motor and 33% of the small motor, then capping to 255.
+ // NB: This value is now 0-255 as required by VibrationEffect.
+ short lowFreqMotorMSB = (short)((lowFreqMotor >> 8) & 0xFF);
+ short highFreqMotorMSB = (short)((highFreqMotor >> 8) & 0xFF);
+ int simulatedAmplitude = Math.min(255, (int)((lowFreqMotorMSB * 0.80) + (highFreqMotorMSB * 0.33)));
+
+ if (simulatedAmplitude == 0) {
+ // This case is easy - just cancel the current effect and get out.
+ // NB: We cannot simply check lowFreqMotor == highFreqMotor == 0
+ // because our simulatedAmplitude could be 0 even though our inputs
+ // are not (ex: lowFreqMotor == 0 && highFreqMotor == 1).
+ vibrator.cancel();
+ return;
+ }
+
+ // Attempt to use amplitude-based control if we're on Oreo and the device
+ // supports amplitude-based vibration control.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ if (vibrator.hasAmplitudeControl()) {
+ VibrationEffect effect = VibrationEffect.createOneShot(60000, simulatedAmplitude);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ VibrationAttributes vibrationAttributes = new VibrationAttributes.Builder()
+ .setUsage(VibrationAttributes.USAGE_MEDIA)
+ .build();
+ vibrator.vibrate(effect, vibrationAttributes);
+ }
+ else {
+ AudioAttributes audioAttributes = new AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_GAME)
+ .build();
+ vibrator.vibrate(effect, audioAttributes);
+ }
+ return;
+ }
+ }
+
+ // If we reach this point, we don't have amplitude controls available, so
+ // we must emulate it by PWMing the vibration. Ick.
+ long pwmPeriod = 20;
+ long onTime = (long)((simulatedAmplitude / 255.0) * pwmPeriod);
+ long offTime = pwmPeriod - onTime;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ VibrationAttributes vibrationAttributes = new VibrationAttributes.Builder()
+ .setUsage(VibrationAttributes.USAGE_MEDIA)
+ .build();
+ vibrator.vibrate(VibrationEffect.createWaveform(new long[]{0, onTime, offTime}, 0), vibrationAttributes);
+ }
+ else {
+ AudioAttributes audioAttributes = new AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_GAME)
+ .build();
+ vibrator.vibrate(new long[]{0, onTime, offTime}, 0, audioAttributes);
+ }
+ }
+
+ public void handleRumble(short controllerNumber, short lowFreqMotor, short highFreqMotor) {
+ boolean foundMatchingDevice = false;
+ boolean vibrated = false;
+
+ if (stopped) {
+ return;
+ }
+
+ for (int i = 0; i < inputDeviceContexts.size(); i++) {
+ InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i);
+
+ if (deviceContext.controllerNumber == controllerNumber) {
+ foundMatchingDevice = true;
+
+ deviceContext.lowFreqMotor = lowFreqMotor;
+ deviceContext.highFreqMotor = highFreqMotor;
+
+ // Prefer the documented Android 12 rumble API which can handle dual vibrators on PS/Xbox controllers
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && deviceContext.vibratorManager != null) {
+ vibrated = true;
+ if (deviceContext.quadVibrators) {
+ rumbleQuadVibrators(deviceContext.vibratorManager,
+ deviceContext.lowFreqMotor, deviceContext.highFreqMotor,
+ deviceContext.leftTriggerMotor, deviceContext.rightTriggerMotor);
+ }
+ else {
+ rumbleDualVibrators(deviceContext.vibratorManager,
+ deviceContext.lowFreqMotor, deviceContext.highFreqMotor);
+ }
+ }
+ // On Shield devices, we can use their special API to rumble Shield controllers
+ else if (sceManager.rumble(deviceContext.inputDevice, deviceContext.lowFreqMotor, deviceContext.highFreqMotor)) {
+ vibrated = true;
+ }
+ // If all else fails, we have to try the old Vibrator API
+ else if (deviceContext.vibrator != null) {
+ vibrated = true;
+ rumbleSingleVibrator(deviceContext.vibrator, deviceContext.lowFreqMotor, deviceContext.highFreqMotor);
+ }
+ }
+ }
+
+ for (int i = 0; i < usbDeviceContexts.size(); i++) {
+ UsbDeviceContext deviceContext = usbDeviceContexts.valueAt(i);
+
+ if (deviceContext.controllerNumber == controllerNumber) {
+ foundMatchingDevice = vibrated = true;
+ deviceContext.device.rumble(lowFreqMotor, highFreqMotor);
+ }
+ }
+
+ // We may decide to rumble the device for player 1
+ if (controllerNumber == 0) {
+ // If we didn't find a matching device, it must be the on-screen
+ // controls that triggered the rumble. Vibrate the device if
+ // the user has requested that behavior.
+ if (!foundMatchingDevice && prefConfig.onscreenController && !prefConfig.onlyL3R3 && prefConfig.vibrateOsc) {
+ rumbleSingleVibrator(deviceVibrator, lowFreqMotor, highFreqMotor);
+ }
+ else if (foundMatchingDevice && !vibrated && prefConfig.vibrateFallbackToDevice) {
+ // We found a device to vibrate but it didn't have rumble support. The user
+ // has requested us to vibrate the device in this case.
+
+ // We cast the unsigned short value to a signed int before multiplying by
+ // the preferred strength. The resulting value is capped at 65534 before
+ // we cast it back to a short so it doesn't go above 100%.
+ short lowFreqMotorAdjusted = (short)(Math.min((((lowFreqMotor & 0xffff)
+ * prefConfig.vibrateFallbackToDeviceStrength) / 100), Short.MAX_VALUE*2));
+ short highFreqMotorAdjusted = (short)(Math.min((((highFreqMotor & 0xffff)
+ * prefConfig.vibrateFallbackToDeviceStrength) / 100), Short.MAX_VALUE*2));
+
+ rumbleSingleVibrator(deviceVibrator, lowFreqMotorAdjusted, highFreqMotorAdjusted);
+ }
+ }
+ }
+
+ public void handleRumbleTriggers(short controllerNumber, short leftTrigger, short rightTrigger) {
+ if (stopped) {
+ return;
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ for (int i = 0; i < inputDeviceContexts.size(); i++) {
+ InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i);
+
+ if (deviceContext.controllerNumber == controllerNumber) {
+ deviceContext.leftTriggerMotor = leftTrigger;
+ deviceContext.rightTriggerMotor = rightTrigger;
+
+ if (deviceContext.quadVibrators) {
+ rumbleQuadVibrators(deviceContext.vibratorManager,
+ deviceContext.lowFreqMotor, deviceContext.highFreqMotor,
+ deviceContext.leftTriggerMotor, deviceContext.rightTriggerMotor);
+ }
+ }
+ }
+ }
+
+ for (int i = 0; i < usbDeviceContexts.size(); i++) {
+ UsbDeviceContext deviceContext = usbDeviceContexts.valueAt(i);
+
+ if (deviceContext.controllerNumber == controllerNumber) {
+ deviceContext.device.rumbleTriggers(leftTrigger, rightTrigger);
+ }
+ }
+ }
+
+ private SensorEventListener createSensorListener(final short controllerNumber, final byte motionType, final boolean needsDeviceOrientationCorrection) {
+ return new SensorEventListener() {
+ private float[] lastValues = new float[3];
+
+ @Override
+ public void onSensorChanged(SensorEvent sensorEvent) {
+ // Android will invoke our callback any time we get a new reading,
+ // even if the values are the same as last time. Don't report a
+ // duplicate set of values to save bandwidth.
+ if (sensorEvent.values[0] == lastValues[0] &&
+ sensorEvent.values[1] == lastValues[1] &&
+ sensorEvent.values[2] == lastValues[2]) {
+ return;
+ }
+ else {
+ lastValues[0] = sensorEvent.values[0];
+ lastValues[1] = sensorEvent.values[1];
+ lastValues[2] = sensorEvent.values[2];
+ }
+
+ int x = 0;
+ int y = 1;
+ int z = 2;
+ int xFactor = 1;
+ int yFactor = 1;
+ int zFactor = 1;
+
+ if (needsDeviceOrientationCorrection) {
+ int deviceRotation = activityContext.getWindowManager().getDefaultDisplay().getRotation();
+ switch (deviceRotation) {
+ case Surface.ROTATION_0:
+ case Surface.ROTATION_180:
+ x = 0;
+ y = 2;
+ z = 1;
+ break;
+
+ case Surface.ROTATION_90:
+ case Surface.ROTATION_270:
+ x = 1;
+ y = 2;
+ z = 0;
+ break;
+ }
+
+ switch (deviceRotation) {
+ case Surface.ROTATION_0:
+ zFactor = -1;
+ break;
+ case Surface.ROTATION_90:
+ xFactor = -1;
+ zFactor = -1;
+ break;
+ case Surface.ROTATION_180:
+ xFactor = -1;
+ break;
+ case Surface.ROTATION_270:
+ break;
+ }
+ }
+
+ if (motionType == MoonBridge.LI_MOTION_TYPE_GYRO) {
+ // Convert from rad/s to deg/s
+ conn.sendControllerMotionEvent((byte) controllerNumber,
+ motionType,
+ sensorEvent.values[x] * xFactor * 57.2957795f,
+ sensorEvent.values[y] * yFactor * 57.2957795f,
+ sensorEvent.values[z] * zFactor * 57.2957795f);
+ }
+ else {
+ // Pass m/s^2 directly without conversion
+ conn.sendControllerMotionEvent((byte) controllerNumber,
+ motionType,
+ sensorEvent.values[x] * xFactor,
+ sensorEvent.values[y] * yFactor,
+ sensorEvent.values[z] * zFactor);
+ }
+ }
+
+ @Override
+ public void onAccuracyChanged(Sensor sensor, int accuracy) {}
+ };
+ }
+
+ public void handleSetMotionEventState(final short controllerNumber, final byte motionType, short reportRateHz) {
+ if (stopped) {
+ return;
+ }
+
+ // Report rate is restricted to <= 200 Hz without the HIGH_SAMPLING_RATE_SENSORS permission
+ reportRateHz = (short) Math.min(200, reportRateHz);
+
+ for (int i = 0; i < inputDeviceContexts.size() + usbDeviceContexts.size(); i++) {
+ InputDeviceContext deviceContext;
+ if (i < inputDeviceContexts.size()) {
+ deviceContext = inputDeviceContexts.valueAt(i);
+ } else {
+ deviceContext = usbDeviceContexts.valueAt(i - inputDeviceContexts.size());
+ }
+
+ if (deviceContext.controllerNumber == controllerNumber) {
+ // Store the desired report rate even if we don't have sensors. In some cases,
+ // input devices can be reconfigured at runtime which results in a change where
+ // sensors disappear and reappear. By storing the desired report rate, we can
+ // reapply the desired motion sensor configuration after they reappear.
+ switch (motionType) {
+ case MoonBridge.LI_MOTION_TYPE_ACCEL:
+ deviceContext.accelReportRateHz = reportRateHz;
+ break;
+ case MoonBridge.LI_MOTION_TYPE_GYRO:
+ deviceContext.gyroReportRateHz = reportRateHz;
+ break;
+ }
+
+ backgroundThreadHandler.removeCallbacks(deviceContext.enableSensorRunnable);
+
+ SensorManager sm = deviceContext.sensorManager;
+ if (sm == null) {
+ continue;
+ }
+
+ switch (motionType) {
+ case MoonBridge.LI_MOTION_TYPE_ACCEL:
+ if (deviceContext.accelListener != null) {
+ sm.unregisterListener(deviceContext.accelListener);
+ deviceContext.accelListener = null;
+ }
+
+ // Enable the accelerometer if requested
+ Sensor accelSensor = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
+ if (reportRateHz != 0 && accelSensor != null) {
+ deviceContext.accelListener = createSensorListener(controllerNumber, motionType, sm == deviceSensorManager);
+ sm.registerListener(deviceContext.accelListener, accelSensor, 1000000 / reportRateHz);
+ }
+ break;
+ case MoonBridge.LI_MOTION_TYPE_GYRO:
+ if (deviceContext.gyroListener != null) {
+ sm.unregisterListener(deviceContext.gyroListener);
+ deviceContext.gyroListener = null;
+ }
+
+ // Enable the gyroscope if requested
+ Sensor gyroSensor = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE);
+ if (reportRateHz != 0 && gyroSensor != null) {
+ deviceContext.gyroListener = createSensorListener(controllerNumber, motionType, sm == deviceSensorManager);
+ sm.registerListener(deviceContext.gyroListener, gyroSensor, 1000000 / reportRateHz);
+ }
+ break;
+ }
+ break;
+ }
+ }
+ }
+
+ public void handleSetControllerLED(short controllerNumber, byte r, byte g, byte b) {
+ if (stopped) {
+ return;
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ for (int i = 0; i < inputDeviceContexts.size(); i++) {
+ InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i);
+
+ // Ignore input devices without an RGB LED
+ if (deviceContext.controllerNumber == controllerNumber && deviceContext.hasRgbLed) {
+ // Create a new light session if one doesn't already exist
+ if (deviceContext.lightsSession == null) {
+ deviceContext.lightsSession = deviceContext.inputDevice.getLightsManager().openSession();
+ }
+
+ // Convert the RGB components into the integer value that LightState uses
+ int argbValue = 0xFF000000 | ((r << 16) & 0xFF0000) | ((g << 8) & 0xFF00) | (b & 0xFF);
+ LightState lightState = new LightState.Builder().setColor(argbValue).build();
+
+ // Set the RGB value for each RGB-controllable LED on the device
+ LightsRequest.Builder lightsRequestBuilder = new LightsRequest.Builder();
+ for (Light light : deviceContext.inputDevice.getLightsManager().getLights()) {
+ if (light.hasRgbControl()) {
+ lightsRequestBuilder.addLight(light, lightState);
+ }
+ }
+
+ // Apply the LED changes
+ deviceContext.lightsSession.requestLights(lightsRequestBuilder.build());
+ }
+ }
+ }
+ }
+
+ public boolean handleButtonUp(KeyEvent event) {
+ InputDeviceContext context = getContextForEvent(event);
+ if (context == null) {
+ return true;
+ }
+
+ int keyCode = handleRemapping(context, event);
+ if (keyCode < 0) {
+ return (keyCode == REMAP_CONSUME);
+ }
+
+ if (prefConfig.flipFaceButtons) {
+ keyCode = handleFlipFaceButtons(keyCode);
+ }
+
+ // If the button hasn't been down long enough, sleep for a bit before sending the up event
+ // This allows "instant" button presses (like OUYA's virtual menu button) to work. This
+ // path should not be triggered during normal usage.
+ int buttonDownTime = (int)(event.getEventTime() - event.getDownTime());
+ if (buttonDownTime < ControllerHandler.MINIMUM_BUTTON_DOWN_TIME_MS)
+ {
+ // Since our sleep time is so short (<= 25 ms), it shouldn't cause a problem doing this
+ // in the UI thread.
+ try {
+ Thread.sleep(ControllerHandler.MINIMUM_BUTTON_DOWN_TIME_MS - buttonDownTime);
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+
+ // InterruptedException clears the thread's interrupt status. Since we can't
+ // handle that here, we will re-interrupt the thread to set the interrupt
+ // status back to true.
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_BUTTON_MODE:
+ context.inputMap &= ~ControllerPacket.SPECIAL_BUTTON_FLAG;
+ break;
+ case KeyEvent.KEYCODE_BUTTON_START:
+ case KeyEvent.KEYCODE_MENU:
+ context.startUpTime = event.getEventTime();
+ // Sometimes we'll get a spurious key up event on controller disconnect.
+ // Make sure it's real by checking that the key is actually down before taking
+ // any action.
+ if ((context.inputMap & ControllerPacket.PLAY_FLAG) != 0 &&
+ context.startUpTime - context.startDownTime > ControllerHandler.START_DOWN_TIME_MOUSE_MODE_MS &&
+ prefConfig.mouseEmulation) {
+ if (prefConfig.enableBackMenu && context.backMenuPending){
+ //todo 展示快捷菜单
+ context.backMenuPending = false;
+ gestures.showGameMenu(context);
+ } else {
+ context.toggleMouseEmulation();
+ }
+ }
+ context.inputMap &= ~ControllerPacket.PLAY_FLAG;
+ break;
+ case KeyEvent.KEYCODE_BACK:
+ if (prefConfig.backAsGuide) {
+ if (context.needsClickpadEmulation) {
+ context.inputMap &= ~ControllerPacket.SPECIAL_BUTTON_FLAG;
+ }
+ else {
+ context.inputMap &= ~ControllerPacket.MISC_FLAG;
+ }
+ break;
+ }
+ case KeyEvent.KEYCODE_BUTTON_SELECT:
+ context.inputMap &= ~ControllerPacket.BACK_FLAG;
+ break;
+ case KeyEvent.KEYCODE_DPAD_LEFT:
+ if (context.hatXAxisUsed) {
+ // Suppress this duplicate event if we have a hat
+ return true;
+ }
+ context.inputMap &= ~ControllerPacket.LEFT_FLAG;
+ break;
+ case KeyEvent.KEYCODE_DPAD_RIGHT:
+ if (context.hatXAxisUsed) {
+ // Suppress this duplicate event if we have a hat
+ return true;
+ }
+ context.inputMap &= ~ControllerPacket.RIGHT_FLAG;
+ break;
+ case KeyEvent.KEYCODE_DPAD_UP:
+ if (context.hatYAxisUsed) {
+ // Suppress this duplicate event if we have a hat
+ return true;
+ }
+ context.inputMap &= ~ControllerPacket.UP_FLAG;
+ break;
+ case KeyEvent.KEYCODE_DPAD_DOWN:
+ if (context.hatYAxisUsed) {
+ // Suppress this duplicate event if we have a hat
+ return true;
+ }
+ context.inputMap &= ~ControllerPacket.DOWN_FLAG;
+ break;
+ case KeyEvent.KEYCODE_DPAD_UP_LEFT:
+ if (context.hatXAxisUsed && context.hatYAxisUsed) {
+ // Suppress this duplicate event if we have a hat
+ return true;
+ }
+ context.inputMap &= ~(ControllerPacket.UP_FLAG | ControllerPacket.LEFT_FLAG);
+ break;
+ case KeyEvent.KEYCODE_DPAD_UP_RIGHT:
+ if (context.hatXAxisUsed && context.hatYAxisUsed) {
+ // Suppress this duplicate event if we have a hat
+ return true;
+ }
+ context.inputMap &= ~(ControllerPacket.UP_FLAG | ControllerPacket.RIGHT_FLAG);
+ break;
+ case KeyEvent.KEYCODE_DPAD_DOWN_LEFT:
+ if (context.hatXAxisUsed && context.hatYAxisUsed) {
+ // Suppress this duplicate event if we have a hat
+ return true;
+ }
+ context.inputMap &= ~(ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG);
+ break;
+ case KeyEvent.KEYCODE_DPAD_DOWN_RIGHT:
+ if (context.hatXAxisUsed && context.hatYAxisUsed) {
+ // Suppress this duplicate event if we have a hat
+ return true;
+ }
+ context.inputMap &= ~(ControllerPacket.DOWN_FLAG | ControllerPacket.RIGHT_FLAG);
+ break;
+ case KeyEvent.KEYCODE_BUTTON_B:
+ context.inputMap &= ~ControllerPacket.B_FLAG;
+ break;
+ case KeyEvent.KEYCODE_DPAD_CENTER:
+ case KeyEvent.KEYCODE_BUTTON_A:
+ context.inputMap &= ~ControllerPacket.A_FLAG;
+ break;
+ case KeyEvent.KEYCODE_BUTTON_X:
+ context.inputMap &= ~ControllerPacket.X_FLAG;
+ break;
+ case KeyEvent.KEYCODE_BUTTON_Y:
+ context.inputMap &= ~ControllerPacket.Y_FLAG;
+ break;
+ case KeyEvent.KEYCODE_BUTTON_L1:
+ context.inputMap &= ~ControllerPacket.LB_FLAG;
+ context.lastLbUpTime = event.getEventTime();
+ break;
+ case KeyEvent.KEYCODE_BUTTON_R1:
+ context.inputMap &= ~ControllerPacket.RB_FLAG;
+ context.lastRbUpTime = event.getEventTime();
+ break;
+ case KeyEvent.KEYCODE_BUTTON_THUMBL:
+ context.inputMap &= ~ControllerPacket.LS_CLK_FLAG;
+ break;
+ case KeyEvent.KEYCODE_BUTTON_THUMBR:
+ context.inputMap &= ~ControllerPacket.RS_CLK_FLAG;
+ break;
+ case KeyEvent.KEYCODE_MEDIA_RECORD: // Xbox Series X Share button
+ context.inputMap &= ~ControllerPacket.MISC_FLAG;
+ break;
+ case KeyEvent.KEYCODE_BUTTON_1: // PS4/PS5 touchpad button (prior to 4.10)
+ context.inputMap &= ~ControllerPacket.TOUCHPAD_FLAG;
+ break;
+ case KeyEvent.KEYCODE_BUTTON_L2:
+ if (context.leftTriggerAxisUsed) {
+ // Suppress this digital event if an analog trigger is active
+ return true;
+ }
+ context.leftTrigger = 0;
+ break;
+ case KeyEvent.KEYCODE_BUTTON_R2:
+ if (context.rightTriggerAxisUsed) {
+ // Suppress this digital event if an analog trigger is active
+ return true;
+ }
+ context.rightTrigger = 0;
+ break;
+ case KeyEvent.KEYCODE_UNKNOWN:
+ // Paddles aren't mapped in any of the Android key layout files,
+ // so we need to handle the evdev key codes directly.
+ if (context.hasPaddles) {
+ switch (event.getScanCode()) {
+ case 0x2c4: // BTN_TRIGGER_HAPPY5
+ context.inputMap &= ~ControllerPacket.PADDLE1_FLAG;
+ break;
+ case 0x2c5: // BTN_TRIGGER_HAPPY6
+ context.inputMap &= ~ControllerPacket.PADDLE2_FLAG;
+ break;
+ case 0x2c6: // BTN_TRIGGER_HAPPY7
+ context.inputMap &= ~ControllerPacket.PADDLE3_FLAG;
+ break;
+ case 0x2c7: // BTN_TRIGGER_HAPPY8
+ context.inputMap &= ~ControllerPacket.PADDLE4_FLAG;
+ break;
+ default:
+ return false;
+ }
+ }
+ else {
+ return false;
+ }
+ break;
+ default:
+ return false;
+ }
+
+ // Check if we're emulating the select button
+ if ((context.emulatingButtonFlags & ControllerHandler.EMULATING_SELECT) != 0)
+ {
+ // If either start or LB is up, select comes up too
+ if ((context.inputMap & ControllerPacket.PLAY_FLAG) == 0 ||
+ (context.inputMap & ControllerPacket.LB_FLAG) == 0)
+ {
+ context.inputMap &= ~ControllerPacket.BACK_FLAG;
+
+ context.emulatingButtonFlags &= ~ControllerHandler.EMULATING_SELECT;
+ }
+ }
+
+ // Check if we're emulating the special button
+ if ((context.emulatingButtonFlags & ControllerHandler.EMULATING_SPECIAL) != 0)
+ {
+ // If either start or select and RB is up, the special button comes up too
+ if ((context.inputMap & ControllerPacket.PLAY_FLAG) == 0 ||
+ ((context.inputMap & ControllerPacket.BACK_FLAG) == 0 &&
+ (context.inputMap & ControllerPacket.RB_FLAG) == 0))
+ {
+ context.inputMap &= ~ControllerPacket.SPECIAL_BUTTON_FLAG;
+
+ context.emulatingButtonFlags &= ~ControllerHandler.EMULATING_SPECIAL;
+ }
+ }
+
+ // Check if we're emulating the touchpad button
+ if ((context.emulatingButtonFlags & ControllerHandler.EMULATING_TOUCHPAD) != 0)
+ {
+ // If either select or LB is up, touchpad comes up too
+ if ((context.inputMap & ControllerPacket.BACK_FLAG) == 0 ||
+ (context.inputMap & ControllerPacket.LB_FLAG) == 0)
+ {
+ context.inputMap &= ~ControllerPacket.TOUCHPAD_FLAG;
+
+ context.emulatingButtonFlags &= ~ControllerHandler.EMULATING_TOUCHPAD;
+ }
+ }
+
+ sendControllerInputPacket(context);
+
+ if (context.pendingExit && context.inputMap == 0) {
+ // All buttons from the quit combo are lifted. Finish the activity now.
+ activityContext.finish();
+ }
+
+ return true;
+ }
+
+ public boolean handleButtonDown(KeyEvent event) {
+ InputDeviceContext context = getContextForEvent(event);
+ if (context == null) {
+ return true;
+ }
+
+ int keyCode = handleRemapping(context, event);
+ if (keyCode < 0) {
+ return (keyCode == REMAP_CONSUME);
+ }
+
+ if (prefConfig.flipFaceButtons) {
+ keyCode = handleFlipFaceButtons(keyCode);
+ }
+
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_BUTTON_MODE:
+ context.hasMode = true;
+ context.inputMap |= ControllerPacket.SPECIAL_BUTTON_FLAG;
+ break;
+ case KeyEvent.KEYCODE_BUTTON_START:
+ case KeyEvent.KEYCODE_MENU:
+ if (event.getRepeatCount() == 0) {
+ context.startDownTime = event.getEventTime();
+ if (context.startDownTime - context.startUpTime <= ControllerHandler.QUICK_MENU_FIRST_STAGE_MS) {
+ context.backMenuPending = true;
+ } else {
+ context.backMenuPending = false;
+ }
+ }
+ context.inputMap |= ControllerPacket.PLAY_FLAG;
+ break;
+ case KeyEvent.KEYCODE_BACK:
+ if (prefConfig.backAsGuide) {
+ context.hasSelect = true;
+ if (context.needsClickpadEmulation) {
+ context.inputMap |= ControllerPacket.SPECIAL_BUTTON_FLAG;
+ }
+ else {
+ context.inputMap |= ControllerPacket.MISC_FLAG;
+ }
+ break;
+ }
+ case KeyEvent.KEYCODE_BUTTON_SELECT:
+ context.hasSelect = true;
+ context.inputMap |= ControllerPacket.BACK_FLAG;
+ break;
+ case KeyEvent.KEYCODE_DPAD_LEFT:
+ if (context.hatXAxisUsed) {
+ // Suppress this duplicate event if we have a hat
+ return true;
+ }
+ context.inputMap |= ControllerPacket.LEFT_FLAG;
+ break;
+ case KeyEvent.KEYCODE_DPAD_RIGHT:
+ if (context.hatXAxisUsed) {
+ // Suppress this duplicate event if we have a hat
+ return true;
+ }
+ context.inputMap |= ControllerPacket.RIGHT_FLAG;
+ break;
+ case KeyEvent.KEYCODE_DPAD_UP:
+ if (context.hatYAxisUsed) {
+ // Suppress this duplicate event if we have a hat
+ return true;
+ }
+ context.inputMap |= ControllerPacket.UP_FLAG;
+ break;
+ case KeyEvent.KEYCODE_DPAD_DOWN:
+ if (context.hatYAxisUsed) {
+ // Suppress this duplicate event if we have a hat
+ return true;
+ }
+ context.inputMap |= ControllerPacket.DOWN_FLAG;
+ break;
+ case KeyEvent.KEYCODE_DPAD_UP_LEFT:
+ if (context.hatXAxisUsed && context.hatYAxisUsed) {
+ // Suppress this duplicate event if we have a hat
+ return true;
+ }
+ context.inputMap |= ControllerPacket.UP_FLAG | ControllerPacket.LEFT_FLAG;
+ break;
+ case KeyEvent.KEYCODE_DPAD_UP_RIGHT:
+ if (context.hatXAxisUsed && context.hatYAxisUsed) {
+ // Suppress this duplicate event if we have a hat
+ return true;
+ }
+ context.inputMap |= ControllerPacket.UP_FLAG | ControllerPacket.RIGHT_FLAG;
+ break;
+ case KeyEvent.KEYCODE_DPAD_DOWN_LEFT:
+ if (context.hatXAxisUsed && context.hatYAxisUsed) {
+ // Suppress this duplicate event if we have a hat
+ return true;
+ }
+ context.inputMap |= ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG;
+ break;
+ case KeyEvent.KEYCODE_DPAD_DOWN_RIGHT:
+ if (context.hatXAxisUsed && context.hatYAxisUsed) {
+ // Suppress this duplicate event if we have a hat
+ return true;
+ }
+ context.inputMap |= ControllerPacket.DOWN_FLAG | ControllerPacket.RIGHT_FLAG;
+ break;
+ case KeyEvent.KEYCODE_BUTTON_B:
+ context.inputMap |= ControllerPacket.B_FLAG;
+ break;
+ case KeyEvent.KEYCODE_DPAD_CENTER:
+ case KeyEvent.KEYCODE_BUTTON_A:
+ context.inputMap |= ControllerPacket.A_FLAG;
+ break;
+ case KeyEvent.KEYCODE_BUTTON_X:
+ context.inputMap |= ControllerPacket.X_FLAG;
+ break;
+ case KeyEvent.KEYCODE_BUTTON_Y:
+ context.inputMap |= ControllerPacket.Y_FLAG;
+ break;
+ case KeyEvent.KEYCODE_BUTTON_L1:
+ context.inputMap |= ControllerPacket.LB_FLAG;
+ break;
+ case KeyEvent.KEYCODE_BUTTON_R1:
+ context.inputMap |= ControllerPacket.RB_FLAG;
+ break;
+ case KeyEvent.KEYCODE_BUTTON_THUMBL:
+ context.inputMap |= ControllerPacket.LS_CLK_FLAG;
+ break;
+ case KeyEvent.KEYCODE_BUTTON_THUMBR:
+ context.inputMap |= ControllerPacket.RS_CLK_FLAG;
+ break;
+ case KeyEvent.KEYCODE_MEDIA_RECORD: // Xbox Series X Share button
+ context.inputMap |= ControllerPacket.MISC_FLAG;
+ break;
+ case KeyEvent.KEYCODE_BUTTON_1: // PS4/PS5 touchpad button (prior to 4.10)
+ context.inputMap |= ControllerPacket.TOUCHPAD_FLAG;
+ break;
+ case KeyEvent.KEYCODE_BUTTON_L2:
+ if (context.leftTriggerAxisUsed) {
+ // Suppress this digital event if an analog trigger is active
+ return true;
+ }
+ context.leftTrigger = (byte)0xFF;
+ break;
+ case KeyEvent.KEYCODE_BUTTON_R2:
+ if (context.rightTriggerAxisUsed) {
+ // Suppress this digital event if an analog trigger is active
+ return true;
+ }
+ context.rightTrigger = (byte)0xFF;
+ break;
+ case KeyEvent.KEYCODE_UNKNOWN:
+ // Paddles aren't mapped in any of the Android key layout files,
+ // so we need to handle the evdev key codes directly.
+ if (context.hasPaddles) {
+ switch (event.getScanCode()) {
+ case 0x2c4: // BTN_TRIGGER_HAPPY5
+ context.inputMap |= ControllerPacket.PADDLE1_FLAG;
+ break;
+ case 0x2c5: // BTN_TRIGGER_HAPPY6
+ context.inputMap |= ControllerPacket.PADDLE2_FLAG;
+ break;
+ case 0x2c6: // BTN_TRIGGER_HAPPY7
+ context.inputMap |= ControllerPacket.PADDLE3_FLAG;
+ break;
+ case 0x2c7: // BTN_TRIGGER_HAPPY8
+ context.inputMap |= ControllerPacket.PADDLE4_FLAG;
+ break;
+ default:
+ return false;
+ }
+ }
+ else {
+ return false;
+ }
+ break;
+ default:
+ return false;
+ }
+
+ // Start+Back+LB+RB is the quit combo
+ if (context.inputMap == (ControllerPacket.BACK_FLAG | ControllerPacket.PLAY_FLAG |
+ ControllerPacket.LB_FLAG | ControllerPacket.RB_FLAG)) {
+ // Wait for the combo to lift and then finish the activity
+ context.pendingExit = true;
+ }
+
+ // Start+LB acts like select for controllers with one button
+ if (!context.hasSelect) {
+ if (context.inputMap == (ControllerPacket.PLAY_FLAG | ControllerPacket.LB_FLAG) ||
+ (context.inputMap == ControllerPacket.PLAY_FLAG &&
+ event.getEventTime() - context.lastLbUpTime <= MAXIMUM_BUMPER_UP_DELAY_MS))
+ {
+ context.inputMap &= ~(ControllerPacket.PLAY_FLAG | ControllerPacket.LB_FLAG);
+ context.inputMap |= ControllerPacket.BACK_FLAG;
+
+ context.emulatingButtonFlags |= ControllerHandler.EMULATING_SELECT;
+ }
+ }
+ else if (context.needsClickpadEmulation) {
+ // Select+LB acts like the clickpad when we're faking a PS4 controller for motion support
+ if (context.inputMap == (ControllerPacket.BACK_FLAG | ControllerPacket.LB_FLAG) ||
+ (context.inputMap == ControllerPacket.BACK_FLAG &&
+ event.getEventTime() - context.lastLbUpTime <= MAXIMUM_BUMPER_UP_DELAY_MS))
+ {
+ context.inputMap &= ~(ControllerPacket.BACK_FLAG | ControllerPacket.LB_FLAG);
+ context.inputMap |= ControllerPacket.TOUCHPAD_FLAG;
+
+ context.emulatingButtonFlags |= ControllerHandler.EMULATING_TOUCHPAD;
+ }
+ }
+
+ // If there is a physical select button, we'll use Start+Select as the special button combo
+ // otherwise we'll use Start+RB.
+ if (!context.hasMode) {
+ if (context.hasSelect) {
+ if (context.inputMap == (ControllerPacket.PLAY_FLAG | ControllerPacket.BACK_FLAG)) {
+ context.inputMap &= ~(ControllerPacket.PLAY_FLAG | ControllerPacket.BACK_FLAG);
+ context.inputMap |= ControllerPacket.SPECIAL_BUTTON_FLAG;
+
+ context.emulatingButtonFlags |= ControllerHandler.EMULATING_SPECIAL;
+ }
+ }
+ else {
+ if (context.inputMap == (ControllerPacket.PLAY_FLAG | ControllerPacket.RB_FLAG) ||
+ (context.inputMap == ControllerPacket.PLAY_FLAG &&
+ event.getEventTime() - context.lastRbUpTime <= MAXIMUM_BUMPER_UP_DELAY_MS))
+ {
+ context.inputMap &= ~(ControllerPacket.PLAY_FLAG | ControllerPacket.RB_FLAG);
+ context.inputMap |= ControllerPacket.SPECIAL_BUTTON_FLAG;
+
+ context.emulatingButtonFlags |= ControllerHandler.EMULATING_SPECIAL;
+ }
+ }
+ }
+
+ // We don't need to send repeat key down events, but the platform
+ // sends us events that claim to be repeats but they're from different
+ // devices, so we just send them all and deal with some duplicates.
+ sendControllerInputPacket(context);
+ return true;
+ }
+
+ public void reportOscState(int buttonFlags,
+ short leftStickX, short leftStickY,
+ short rightStickX, short rightStickY,
+ byte leftTrigger, byte rightTrigger) {
+ defaultContext.leftStickX = leftStickX;
+ defaultContext.leftStickY = leftStickY;
+
+ defaultContext.rightStickX = rightStickX;
+ defaultContext.rightStickY = rightStickY;
+
+ defaultContext.leftTrigger = leftTrigger;
+ defaultContext.rightTrigger = rightTrigger;
+
+ defaultContext.inputMap = buttonFlags;
+
+ sendControllerInputPacket(defaultContext);
+ }
+
+ @Override
+ public void reportControllerState(int controllerId, int buttonFlags,
+ float leftStickX, float leftStickY,
+ float rightStickX, float rightStickY,
+ float leftTrigger, float rightTrigger) {
+ GenericControllerContext context = usbDeviceContexts.get(controllerId);
+ if (context == null) {
+ return;
+ }
+
+ Vector2d leftStickVector = populateCachedVector(leftStickX, leftStickY);
+
+ handleDeadZone(leftStickVector, context.leftStickDeadzoneRadius);
+
+ context.leftStickX = (short) (leftStickVector.getX() * 0x7FFE);
+ context.leftStickY = (short) (-leftStickVector.getY() * 0x7FFE);
+
+ Vector2d rightStickVector = populateCachedVector(rightStickX, rightStickY);
+
+ handleDeadZone(rightStickVector, context.rightStickDeadzoneRadius);
+
+ context.rightStickX = (short) (rightStickVector.getX() * 0x7FFE);
+ context.rightStickY = (short) (-rightStickVector.getY() * 0x7FFE);
+
+ if (leftTrigger <= context.triggerDeadzone) {
+ leftTrigger = 0;
+ }
+ if (rightTrigger <= context.triggerDeadzone) {
+ rightTrigger = 0;
+ }
+
+ context.leftTrigger = (byte)(leftTrigger * 0xFF);
+ context.rightTrigger = (byte)(rightTrigger * 0xFF);
+
+ context.inputMap = buttonFlags;
+
+ sendControllerInputPacket(context);
+ }
+
+ @Override
+ public void reportControllerMotion(int controllerId, byte motionType, float motionX, float motionY, float motionZ) {
+ GenericControllerContext context = usbDeviceContexts.get(controllerId);
+ if (context == null) {
+ return;
+ }
+
+ conn.sendControllerMotionEvent((byte)context.controllerNumber, motionType, motionX, motionY, motionZ);
+ }
+
+ @Override
+ public void deviceRemoved(AbstractController controller) {
+ UsbDeviceContext context = usbDeviceContexts.get(controller.getControllerId());
+ if (context != null) {
+ LimeLog.info("Removed controller: "+controller.getControllerId());
+ releaseControllerNumber(context);
+ context.destroy();
+ usbDeviceContexts.remove(controller.getControllerId());
+ }
+ }
+
+ @Override
+ public void deviceAdded(AbstractController controller) {
+ if (stopped) {
+ return;
+ }
+
+ UsbDeviceContext context = createUsbDeviceContextForDevice(controller);
+ usbDeviceContexts.put(controller.getControllerId(), context);
+ }
+
+ class GenericControllerContext implements GameInputDevice{
+ public int id;
+ public boolean external;
+
+ public int vendorId;
+ public int productId;
+
+ public float leftStickDeadzoneRadius;
+ public float rightStickDeadzoneRadius;
+ public float triggerDeadzone;
+
+ public boolean assignedControllerNumber;
+ public boolean reservedControllerNumber;
+ public short controllerNumber;
+
+ public int inputMap = 0;
+ public byte leftTrigger = 0x00;
+ public byte rightTrigger = 0x00;
+ public short rightStickX = 0x0000;
+ public short rightStickY = 0x0000;
+ public short leftStickX = 0x0000;
+ public short leftStickY = 0x0000;
+
+ public boolean mouseEmulationActive;
+ public boolean mouseEmulationXDown = false;
+ public int mouseEmulationPixelMultiplier = 1;
+
+ public int mouseEmulationLastInputMap;
+ public final int mouseEmulationReportPeriod = 50;
+
+ public final Runnable mouseEmulationRunnable = new Runnable() {
+ @Override
+ public void run() {
+ if (!mouseEmulationActive) {
+ return;
+ }
+
+ // Send mouse events from analog sticks
+ if (prefConfig.analogStickForScrolling == PreferenceConfiguration.AnalogStickForScrolling.RIGHT) {
+
+ // Changed absolute value
+ sendEmulatedMouseMove(leftStickX, leftStickY, mouseEmulationXDown, mouseEmulationPixelMultiplier);
+ sendEmulatedMouseScroll(rightStickX, rightStickY);
+ }
+ else if (prefConfig.analogStickForScrolling == PreferenceConfiguration.AnalogStickForScrolling.LEFT) {
+ sendEmulatedMouseMove(rightStickX, rightStickY, mouseEmulationXDown, mouseEmulationPixelMultiplier);
+ sendEmulatedMouseScroll(leftStickX, leftStickY);
+ }
+ else {
+ sendEmulatedMouseMove(leftStickX, leftStickY, mouseEmulationXDown, mouseEmulationPixelMultiplier);
+ sendEmulatedMouseMove(rightStickX, rightStickY, mouseEmulationXDown, mouseEmulationPixelMultiplier);
+ }
+
+ // Requeue the callback
+ mainThreadHandler.postDelayed(this, mouseEmulationReportPeriod);
+ }
+ };
+
+ @Override
+ public List getGameMenuOptions() {
+ List options = new ArrayList<>();
+ options.add(new GameMenu.MenuOption(activityContext.getString(mouseEmulationActive ?
+ R.string.game_menu_toggle_mouse_off : R.string.game_menu_toggle_mouse_on),
+ true, () -> toggleMouseEmulation()));
+
+ return options;
+ }
+
+ public void toggleMouseEmulation() {
+ mainThreadHandler.removeCallbacks(mouseEmulationRunnable);
+ mouseEmulationActive = !mouseEmulationActive;
+ Toast.makeText(activityContext, "Mouse emulation is: " + (mouseEmulationActive ? "ON" : "OFF"), Toast.LENGTH_SHORT).show();
+
+ if (mouseEmulationActive) {
+ mainThreadHandler.postDelayed(mouseEmulationRunnable, mouseEmulationReportPeriod);
+ }
+ }
+
+ public void destroy() {
+ mouseEmulationActive = false;
+ mainThreadHandler.removeCallbacks(mouseEmulationRunnable);
+ }
+
+ public void sendControllerArrival() {}
+
+ }
+
+ class InputDeviceContext extends GenericControllerContext {
+ public String name;
+ public VibratorManager vibratorManager;
+ public Vibrator vibrator;
+ public boolean quadVibrators;
+ public short lowFreqMotor, highFreqMotor;
+ public short leftTriggerMotor, rightTriggerMotor;
+
+ public SensorManager sensorManager;
+ public SensorEventListener gyroListener;
+ public short gyroReportRateHz;
+ public SensorEventListener accelListener;
+ public short accelReportRateHz;
+
+ public InputDevice inputDevice;
+
+ public boolean hasRgbLed;
+ public LightsManager.LightsSession lightsSession;
+
+ // These are BatteryState values, not Moonlight values
+ public int lastReportedBatteryStatus;
+ public float lastReportedBatteryCapacity;
+
+ public int leftStickXAxis = -1;
+ public int leftStickYAxis = -1;
+
+ public int rightStickXAxis = -1;
+ public int rightStickYAxis = -1;
+
+ public int leftTriggerAxis = -1;
+ public int rightTriggerAxis = -1;
+ public boolean triggersIdleNegative;
+ public boolean leftTriggerAxisUsed, rightTriggerAxisUsed;
+
+ public int hatXAxis = -1;
+ public int hatYAxis = -1;
+ public boolean hatXAxisUsed, hatYAxisUsed;
+
+ InputDevice.MotionRange touchpadXRange;
+ InputDevice.MotionRange touchpadYRange;
+ InputDevice.MotionRange touchpadPressureRange;
+
+ public boolean isNonStandardDualShock4;
+ public boolean usesLinuxGamepadStandardFaceButtons;
+ public boolean isNonStandardXboxBtController;
+ public boolean isServal;
+ public boolean backIsStart;
+ public boolean modeIsSelect;
+ public boolean searchIsMode;
+ public boolean ignoreBack;
+ public boolean hasJoystickAxes;
+ public boolean pendingExit;
+ public boolean isDualShockStandaloneTouchpad;
+
+ public int emulatingButtonFlags = 0;
+ public boolean hasSelect;
+ public boolean hasMode;
+ public boolean hasPaddles;
+ public boolean hasShare;
+ public boolean needsClickpadEmulation;
+
+ // Used for OUYA bumper state tracking since they force all buttons
+ // up when the OUYA button goes down. We watch the last time we get
+ // a bumper up and compare that to our maximum delay when we receive
+ // a Start button press to see if we should activate one of our
+ // emulated button combos.
+ public long lastLbUpTime = 0;
+ public long lastRbUpTime = 0;
+
+ public long startDownTime = 0;
+ public long startUpTime = 0;
+ public boolean backMenuPending = false;
+
+ public final Runnable batteryStateUpdateRunnable = new Runnable() {
+ @Override
+ public void run() {
+ sendControllerBatteryPacket(InputDeviceContext.this);
+
+ // Requeue the callback
+ backgroundThreadHandler.postDelayed(this, BATTERY_RECHECK_INTERVAL_MS);
+ }
+ };
+
+ public final Runnable enableSensorRunnable = new Runnable() {
+ @Override
+ public void run() {
+ // Turn back on any sensors that should be reporting but are currently unregistered
+ if (accelReportRateHz != 0 && accelListener == null) {
+ handleSetMotionEventState(controllerNumber, MoonBridge.LI_MOTION_TYPE_ACCEL, accelReportRateHz);
+ }
+ if (gyroReportRateHz != 0 && gyroListener == null) {
+ handleSetMotionEventState(controllerNumber, MoonBridge.LI_MOTION_TYPE_GYRO, gyroReportRateHz);
+ }
+ }
+ };
+
+ @Override
+ public void destroy() {
+ super.destroy();
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && vibratorManager != null) {
+ vibratorManager.cancel();
+ }
+ else if (vibrator != null) {
+ vibrator.cancel();
+ }
+
+ backgroundThreadHandler.removeCallbacks(enableSensorRunnable);
+
+ if (gyroListener != null) {
+ sensorManager.unregisterListener(gyroListener);
+ }
+ if (accelListener != null) {
+ sensorManager.unregisterListener(accelListener);
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ if (lightsSession != null) {
+ lightsSession.close();
+ }
+ }
+
+ backgroundThreadHandler.removeCallbacks(batteryStateUpdateRunnable);
+ }
+
+ @Override
+ public void sendControllerArrival() {
+ byte type;
+ switch (inputDevice.getVendorId()) {
+ case 0x045e: // Microsoft
+ type = MoonBridge.LI_CTYPE_XBOX;
+ break;
+ case 0x054c: // Sony
+ type = MoonBridge.LI_CTYPE_PS;
+ break;
+ case 0x057e: // Nintendo
+ type = MoonBridge.LI_CTYPE_NINTENDO;
+ break;
+ default:
+ // Consult SDL's controller type list to see if it knows
+ type = MoonBridge.guessControllerType(inputDevice.getVendorId(), inputDevice.getProductId());
+ break;
+ }
+
+ int supportedButtonFlags = 0;
+ for (Map.Entry entry : ANDROID_TO_LI_BUTTON_MAP.entrySet()) {
+ if (inputDevice.hasKeys(entry.getKey())[0]) {
+ supportedButtonFlags |= entry.getValue();
+ }
+ }
+
+ // Add non-standard button flags that may not be mapped in the Android kl file
+ if (hasPaddles) {
+ supportedButtonFlags |=
+ ControllerPacket.PADDLE1_FLAG |
+ ControllerPacket.PADDLE2_FLAG |
+ ControllerPacket.PADDLE3_FLAG |
+ ControllerPacket.PADDLE4_FLAG;
+ }
+ if (hasShare) {
+ supportedButtonFlags |= ControllerPacket.MISC_FLAG;
+ }
+
+ if (getMotionRangeForJoystickAxis(inputDevice, MotionEvent.AXIS_HAT_X) != null) {
+ supportedButtonFlags |= ControllerPacket.LEFT_FLAG | ControllerPacket.RIGHT_FLAG;
+ }
+ if (getMotionRangeForJoystickAxis(inputDevice, MotionEvent.AXIS_HAT_Y) != null) {
+ supportedButtonFlags |= ControllerPacket.UP_FLAG | ControllerPacket.DOWN_FLAG;
+ }
+
+ short capabilities = 0;
+
+ // Most of the advanced InputDevice capabilities came in Android S
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ if (quadVibrators) {
+ capabilities |= MoonBridge.LI_CCAP_RUMBLE | MoonBridge.LI_CCAP_TRIGGER_RUMBLE;
+ }
+ else if (vibratorManager != null || vibrator != null) {
+ capabilities |= MoonBridge.LI_CCAP_RUMBLE;
+ }
+
+ // Calling InputDevice.getBatteryState() to see if a battery is present
+ // performs a Binder transaction that can cause ANRs on some devices.
+ // To avoid this, we will just claim we can report battery state for all
+ // external gamepad devices on Android S. If it turns out that no battery
+ // is actually present, we'll just report unknown battery state to the host.
+ if (external) {
+ capabilities |= MoonBridge.LI_CCAP_BATTERY_STATE;
+ }
+
+ // Light.hasRgbControl() was totally broken prior to Android 14.
+ // It always returned true because LIGHT_CAPABILITY_RGB was defined as 0,
+ // so we will just guess RGB is supported if it's a PlayStation controller.
+ if (hasRgbLed && (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE || type == MoonBridge.LI_CTYPE_PS)) {
+ capabilities |= MoonBridge.LI_CCAP_RGB_LED;
+ }
+ }
+
+ // Report analog triggers if we have at least one trigger axis
+ if (leftTriggerAxis != -1 || rightTriggerAxis != -1) {
+ capabilities |= MoonBridge.LI_CCAP_ANALOG_TRIGGERS;
+ }
+
+ // Report sensors if the input device has them or we're using built-in sensors for a built-in controller
+ if (sensorManager != null && sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null) {
+ capabilities |= MoonBridge.LI_CCAP_ACCEL;
+ }
+ if (sensorManager != null && sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null) {
+ capabilities |= MoonBridge.LI_CCAP_GYRO;
+ }
+
+ byte reportedType;
+ if (type != MoonBridge.LI_CTYPE_PS && sensorManager != null) {
+ // Override the detected controller type if we're emulating motion sensors on an Xbox controller
+ Toast.makeText(activityContext, activityContext.getResources().getText(R.string.toast_controller_type_changed), Toast.LENGTH_LONG).show();
+ reportedType = MoonBridge.LI_CTYPE_UNKNOWN;
+
+ // Remember that we should enable the clickpad emulation combo (Select+LB) for this device
+ needsClickpadEmulation = true;
+ }
+ else {
+ // Report the true type to the host PC if we're not emulating motion sensors
+ reportedType = type;
+ }
+
+ // We can perform basic rumble with any vibrator
+ if (vibrator != null) {
+ capabilities |= MoonBridge.LI_CCAP_RUMBLE;
+ }
+
+ // Shield controllers use special APIs for rumble and battery state
+ if (sceManager.isRecognizedDevice(inputDevice)) {
+ capabilities |= MoonBridge.LI_CCAP_RUMBLE | MoonBridge.LI_CCAP_BATTERY_STATE;
+ }
+
+ if ((inputDevice.getSources() & InputDevice.SOURCE_TOUCHPAD) == InputDevice.SOURCE_TOUCHPAD) {
+ capabilities |= MoonBridge.LI_CCAP_TOUCHPAD;
+
+ // Use the platform API or internal heuristics to determine if this has a clickpad
+ if (hasButtonUnderTouchpad(inputDevice, type)) {
+ supportedButtonFlags |= ControllerPacket.TOUCHPAD_FLAG;
+ }
+ }
+
+ conn.sendControllerArrivalEvent((byte)controllerNumber, getActiveControllerMask(),
+ reportedType, supportedButtonFlags, capabilities);
+
+ // After reporting arrival to the host, send initial battery state and begin monitoring
+ // Might result in stutter in pointer device input. The problem happens within Android framework,
+ // Here we can only provide a workaround by disabling this option if user wishes.
+ if (prefConfig.enableBatteryReport) {
+ backgroundThreadHandler.post(batteryStateUpdateRunnable);
+ }
+ }
+
+ public void migrateContext(InputDeviceContext oldContext) {
+ // Take ownership of the sensor and light sessions
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ this.lightsSession = oldContext.lightsSession;
+ oldContext.lightsSession = null;
+ }
+ this.gyroReportRateHz = oldContext.gyroReportRateHz;
+ this.accelReportRateHz = oldContext.accelReportRateHz;
+
+ // Don't release the controller number, because we will carry it over if it is present.
+ // We also want to make sure the change is invisible to the host PC to avoid an add/remove
+ // cycle for the gamepad which may break some games.
+ oldContext.destroy();
+
+ // Copy over existing controller number state
+ this.assignedControllerNumber = oldContext.assignedControllerNumber;
+ this.reservedControllerNumber = oldContext.reservedControllerNumber;
+ this.controllerNumber = oldContext.controllerNumber;
+
+ // We may have set this device to use the built-in sensor manager. If so, do that again.
+ if (oldContext.sensorManager == deviceSensorManager) {
+ this.sensorManager = deviceSensorManager;
+ }
+
+ // Copy state initialized in reportControllerArrival()
+ this.needsClickpadEmulation = oldContext.needsClickpadEmulation;
+
+ // Re-enable sensors on the new context
+ enableSensors();
+
+ // Refresh battery state and start the battery state polling again
+ backgroundThreadHandler.post(batteryStateUpdateRunnable);
+ }
+
+ public void disableSensors() {
+ // Stop any pending enablement
+ backgroundThreadHandler.removeCallbacks(enableSensorRunnable);
+
+ // Unregister all sensor listeners
+ if (gyroListener != null) {
+ sensorManager.unregisterListener(gyroListener);
+ gyroListener = null;
+
+ // Send a gyro event to ensure the virtual controller is stationary
+ conn.sendControllerMotionEvent((byte) controllerNumber, MoonBridge.LI_MOTION_TYPE_GYRO, 0.f, 0.f, 0.f);
+ }
+ if (accelListener != null) {
+ sensorManager.unregisterListener(accelListener);
+ accelListener = null;
+
+ // We leave the acceleration as-is to preserve the attitude of the controller
+ }
+ }
+
+ public void enableSensors() {
+ // We allow 1 second for the input device to settle before re-enabling sensors.
+ // Pointer capture can cause the input device to change, which can cause
+ // InputDeviceSensorManager to crash due to missing null checks on the InputDevice.
+ backgroundThreadHandler.postDelayed(enableSensorRunnable, 1000);
+ }
+ }
+
+ class UsbDeviceContext extends InputDeviceContext {
+ public AbstractController device;
+
+// @Override
+// public void destroy() {
+// super.destroy();
+//
+// // Nothing for now
+// }
+
+ @Override
+ public void sendControllerArrival() {
+ byte type = device.getType();
+ short capabilities = device.getCapabilities();
+
+ // Report sensors if the input device has them or we're using built-in sensors for a built-in controller
+ if (type != MoonBridge.LI_CTYPE_PS && type != MoonBridge.LI_CTYPE_NINTENDO && sensorManager != null) {
+ if (sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null) {
+ capabilities |= MoonBridge.LI_CCAP_GYRO;
+ }
+ if (sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null) {
+ capabilities |= MoonBridge.LI_CCAP_ACCEL;
+ }
+
+ type = MoonBridge.LI_CTYPE_UNKNOWN;
+ }
+
+ if (type != MoonBridge.LI_CTYPE_PS && (capabilities & (MoonBridge.LI_CCAP_GYRO | MoonBridge.LI_CCAP_ACCEL)) != 0) {
+ activityContext.runOnUiThread(() -> {
+ Toast.makeText(activityContext, activityContext.getResources().getText(R.string.toast_controller_type_changed), Toast.LENGTH_LONG).show();
+ });
+ }
+
+ conn.sendControllerArrivalEvent((byte)controllerNumber, getActiveControllerMask(),
+ type, device.getSupportedButtonFlags(), capabilities);
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/GameInputDevice.java b/app/src/main/java/com/limelight/binding/input/GameInputDevice.java
new file mode 100755
index 0000000000..c1fa51bb50
--- /dev/null
+++ b/app/src/main/java/com/limelight/binding/input/GameInputDevice.java
@@ -0,0 +1,19 @@
+package com.limelight.binding.input;
+
+import com.limelight.GameMenu;
+
+import java.util.List;
+
+/**
+ * Description
+ * Date: 2024-01-16
+ * Time: 15:26
+ * User: Genng(genng1991@gmail.com)
+ */
+public interface GameInputDevice {
+
+ /**
+ * @return list of device specific game menu options, e.g. configure a controller's mouse mode
+ */
+ List getGameMenuOptions();
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/limelight/binding/input/KeyboardTranslator.java b/app/src/main/java/com/limelight/binding/input/KeyboardTranslator.java
old mode 100644
new mode 100755
index 5c5b4cc847..f82cf599a7
--- a/app/src/main/java/com/limelight/binding/input/KeyboardTranslator.java
+++ b/app/src/main/java/com/limelight/binding/input/KeyboardTranslator.java
@@ -1,386 +1,438 @@
-package com.limelight.binding.input;
-
-import android.annotation.TargetApi;
-import android.hardware.input.InputManager;
-import android.os.Build;
-import android.util.SparseArray;
-import android.view.InputDevice;
-import android.view.KeyEvent;
-
-import java.util.Arrays;
-
-/**
- * Class to translate a Android key code into the codes GFE is expecting
- * @author Diego Waxemberg
- * @author Cameron Gutman
- */
-public class KeyboardTranslator implements InputManager.InputDeviceListener {
-
- /**
- * GFE's prefix for every key code
- */
- private static final short KEY_PREFIX = (short) 0x80;
-
- public static final int VK_0 = 48;
- public static final int VK_9 = 57;
- public static final int VK_A = 65;
- public static final int VK_Z = 90;
- public static final int VK_NUMPAD0 = 96;
- public static final int VK_BACK_SLASH = 92;
- public static final int VK_CAPS_LOCK = 20;
- public static final int VK_CLEAR = 12;
- public static final int VK_COMMA = 44;
- public static final int VK_BACK_SPACE = 8;
- public static final int VK_EQUALS = 61;
- public static final int VK_ESCAPE = 27;
- public static final int VK_F1 = 112;
- public static final int VK_END = 35;
- public static final int VK_HOME = 36;
- public static final int VK_NUM_LOCK = 144;
- public static final int VK_PAGE_UP = 33;
- public static final int VK_PAGE_DOWN = 34;
- public static final int VK_PLUS = 521;
- public static final int VK_CLOSE_BRACKET = 93;
- public static final int VK_SCROLL_LOCK = 145;
- public static final int VK_SEMICOLON = 59;
- public static final int VK_SLASH = 47;
- public static final int VK_SPACE = 32;
- public static final int VK_PRINTSCREEN = 154;
- public static final int VK_TAB = 9;
- public static final int VK_LEFT = 37;
- public static final int VK_RIGHT = 39;
- public static final int VK_UP = 38;
- public static final int VK_DOWN = 40;
- public static final int VK_BACK_QUOTE = 192;
- public static final int VK_QUOTE = 222;
- public static final int VK_PAUSE = 19;
-
- private static class KeyboardMapping {
- private final InputDevice device;
- private final int[] deviceKeyCodeToQwertyKeyCode;
-
- @TargetApi(33)
- public KeyboardMapping(InputDevice device) {
- int maxKeyCode = KeyEvent.getMaxKeyCode();
-
- this.device = device;
- this.deviceKeyCodeToQwertyKeyCode = new int[maxKeyCode + 1];
-
- // Any unmatched keycodes are treated as unknown
- Arrays.fill(deviceKeyCodeToQwertyKeyCode, KeyEvent.KEYCODE_UNKNOWN);
-
- for (int i = 0; i <= maxKeyCode; i++) {
- int deviceKeyCode = device.getKeyCodeForKeyLocation(i);
- if (deviceKeyCode != KeyEvent.KEYCODE_UNKNOWN) {
- deviceKeyCodeToQwertyKeyCode[deviceKeyCode] = i;
- }
- }
- }
-
- @TargetApi(33)
- public int getDeviceKeyCodeForQwertyKeyCode(int qwertyKeyCode) {
- return device.getKeyCodeForKeyLocation(qwertyKeyCode);
- }
-
- public int getQwertyKeyCodeForDeviceKeyCode(int deviceKeyCode) {
- if (deviceKeyCode > KeyEvent.getMaxKeyCode()) {
- return KeyEvent.KEYCODE_UNKNOWN;
- }
-
- return deviceKeyCodeToQwertyKeyCode[deviceKeyCode];
- }
- }
-
- private final SparseArray keyboardMappings = new SparseArray<>();
-
- public KeyboardTranslator() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- for (int deviceId : InputDevice.getDeviceIds()) {
- InputDevice device = InputDevice.getDevice(deviceId);
- if (device != null && device.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC) {
- keyboardMappings.set(deviceId, new KeyboardMapping(device));
- }
- }
- }
- }
-
- public boolean hasNormalizedMapping(int keycode, int deviceId) {
- if (deviceId >= 0) {
- KeyboardMapping mapping = keyboardMappings.get(deviceId);
- if (mapping != null) {
- // Try to map this device-specific keycode onto a QWERTY layout.
- // GFE assumes incoming keycodes are from a QWERTY keyboard.
- int qwertyKeyCode = mapping.getQwertyKeyCodeForDeviceKeyCode(keycode);
- if (qwertyKeyCode != KeyEvent.KEYCODE_UNKNOWN) {
- return true;
- }
- }
- }
-
- return false;
- }
-
- /**
- * Translates the given keycode and returns the GFE keycode
- * @param keycode the code to be translated
- * @param deviceId InputDevice.getId() or -1 if unknown
- * @return a GFE keycode for the given keycode
- */
- public short translate(int keycode, int deviceId) {
- int translated;
-
- // If a device ID was provided, look up the keyboard mapping
- if (deviceId >= 0) {
- KeyboardMapping mapping = keyboardMappings.get(deviceId);
- if (mapping != null) {
- // Try to map this device-specific keycode onto a QWERTY layout.
- // GFE assumes incoming keycodes are from a QWERTY keyboard.
- int qwertyKeyCode = mapping.getQwertyKeyCodeForDeviceKeyCode(keycode);
- if (qwertyKeyCode != KeyEvent.KEYCODE_UNKNOWN) {
- keycode = qwertyKeyCode;
- }
- }
- }
-
- // This is a poor man's mapping between Android key codes
- // and Windows VK_* codes. For all defined VK_ codes, see:
- // https://msdn.microsoft.com/en-us/library/windows/desktop/dd375731(v=vs.85).aspx
- if (keycode >= KeyEvent.KEYCODE_0 &&
- keycode <= KeyEvent.KEYCODE_9) {
- translated = (keycode - KeyEvent.KEYCODE_0) + VK_0;
- }
- else if (keycode >= KeyEvent.KEYCODE_A &&
- keycode <= KeyEvent.KEYCODE_Z) {
- translated = (keycode - KeyEvent.KEYCODE_A) + VK_A;
- }
- else if (keycode >= KeyEvent.KEYCODE_NUMPAD_0 &&
- keycode <= KeyEvent.KEYCODE_NUMPAD_9) {
- translated = (keycode - KeyEvent.KEYCODE_NUMPAD_0) + VK_NUMPAD0;
- }
- else if (keycode >= KeyEvent.KEYCODE_F1 &&
- keycode <= KeyEvent.KEYCODE_F12) {
- translated = (keycode - KeyEvent.KEYCODE_F1) + VK_F1;
- }
- else {
- switch (keycode) {
- case KeyEvent.KEYCODE_ALT_LEFT:
- translated = 0xA4;
- break;
-
- case KeyEvent.KEYCODE_ALT_RIGHT:
- translated = 0xA5;
- break;
-
- case KeyEvent.KEYCODE_BACKSLASH:
- translated = 0xdc;
- break;
-
- case KeyEvent.KEYCODE_CAPS_LOCK:
- translated = VK_CAPS_LOCK;
- break;
-
- case KeyEvent.KEYCODE_CLEAR:
- translated = VK_CLEAR;
- break;
-
- case KeyEvent.KEYCODE_COMMA:
- translated = 0xbc;
- break;
-
- case KeyEvent.KEYCODE_CTRL_LEFT:
- translated = 0xA2;
- break;
-
- case KeyEvent.KEYCODE_CTRL_RIGHT:
- translated = 0xA3;
- break;
-
- case KeyEvent.KEYCODE_DEL:
- translated = VK_BACK_SPACE;
- break;
-
- case KeyEvent.KEYCODE_ENTER:
- translated = 0x0d;
- break;
-
- case KeyEvent.KEYCODE_PLUS:
- case KeyEvent.KEYCODE_EQUALS:
- translated = 0xbb;
- break;
-
- case KeyEvent.KEYCODE_ESCAPE:
- translated = VK_ESCAPE;
- break;
-
- case KeyEvent.KEYCODE_FORWARD_DEL:
- translated = 0x2e;
- break;
-
- case KeyEvent.KEYCODE_INSERT:
- translated = 0x2d;
- break;
-
- case KeyEvent.KEYCODE_LEFT_BRACKET:
- translated = 0xdb;
- break;
-
- case KeyEvent.KEYCODE_META_LEFT:
- translated = 0x5b;
- break;
-
- case KeyEvent.KEYCODE_META_RIGHT:
- translated = 0x5c;
- break;
-
- case KeyEvent.KEYCODE_MENU:
- translated = 0x5d;
- break;
-
- case KeyEvent.KEYCODE_MINUS:
- translated = 0xbd;
- break;
-
- case KeyEvent.KEYCODE_MOVE_END:
- translated = VK_END;
- break;
-
- case KeyEvent.KEYCODE_MOVE_HOME:
- translated = VK_HOME;
- break;
-
- case KeyEvent.KEYCODE_NUM_LOCK:
- translated = VK_NUM_LOCK;
- break;
-
- case KeyEvent.KEYCODE_PAGE_DOWN:
- translated = VK_PAGE_DOWN;
- break;
-
- case KeyEvent.KEYCODE_PAGE_UP:
- translated = VK_PAGE_UP;
- break;
-
- case KeyEvent.KEYCODE_PERIOD:
- translated = 0xbe;
- break;
-
- case KeyEvent.KEYCODE_RIGHT_BRACKET:
- translated = 0xdd;
- break;
-
- case KeyEvent.KEYCODE_SCROLL_LOCK:
- translated = VK_SCROLL_LOCK;
- break;
-
- case KeyEvent.KEYCODE_SEMICOLON:
- translated = 0xba;
- break;
-
- case KeyEvent.KEYCODE_SHIFT_LEFT:
- translated = 0xA0;
- break;
-
- case KeyEvent.KEYCODE_SHIFT_RIGHT:
- translated = 0xA1;
- break;
-
- case KeyEvent.KEYCODE_SLASH:
- translated = 0xbf;
- break;
-
- case KeyEvent.KEYCODE_SPACE:
- translated = VK_SPACE;
- break;
-
- case KeyEvent.KEYCODE_SYSRQ:
- // Android defines this as SysRq/PrntScrn
- translated = VK_PRINTSCREEN;
- break;
-
- case KeyEvent.KEYCODE_TAB:
- translated = VK_TAB;
- break;
-
- case KeyEvent.KEYCODE_DPAD_LEFT:
- translated = VK_LEFT;
- break;
-
- case KeyEvent.KEYCODE_DPAD_RIGHT:
- translated = VK_RIGHT;
- break;
-
- case KeyEvent.KEYCODE_DPAD_UP:
- translated = VK_UP;
- break;
-
- case KeyEvent.KEYCODE_DPAD_DOWN:
- translated = VK_DOWN;
- break;
-
- case KeyEvent.KEYCODE_GRAVE:
- translated = VK_BACK_QUOTE;
- break;
-
- case KeyEvent.KEYCODE_APOSTROPHE:
- translated = 0xde;
- break;
-
- case KeyEvent.KEYCODE_BREAK:
- translated = VK_PAUSE;
- break;
-
- case KeyEvent.KEYCODE_NUMPAD_DIVIDE:
- translated = 0x6F;
- break;
-
- case KeyEvent.KEYCODE_NUMPAD_MULTIPLY:
- translated = 0x6A;
- break;
-
- case KeyEvent.KEYCODE_NUMPAD_SUBTRACT:
- translated = 0x6D;
- break;
-
- case KeyEvent.KEYCODE_NUMPAD_ADD:
- translated = 0x6B;
- break;
-
- case KeyEvent.KEYCODE_NUMPAD_DOT:
- translated = 0x6E;
- break;
-
- default:
- return 0;
- }
- }
-
- return (short) ((KEY_PREFIX << 8) | translated);
- }
-
- @Override
- public void onInputDeviceAdded(int index) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- InputDevice device = InputDevice.getDevice(index);
- if (device != null && device.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC) {
- keyboardMappings.put(index, new KeyboardMapping(device));
- }
- }
- }
-
- @Override
- public void onInputDeviceRemoved(int index) {
- keyboardMappings.remove(index);
- }
-
- @Override
- public void onInputDeviceChanged(int index) {
- keyboardMappings.remove(index);
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- InputDevice device = InputDevice.getDevice(index);
- if (device != null && device.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC) {
- keyboardMappings.set(index, new KeyboardMapping(device));
- }
- }
- }
-}
+package com.limelight.binding.input;
+
+import android.annotation.TargetApi;
+import android.hardware.input.InputManager;
+import android.os.Build;
+import android.util.SparseArray;
+import android.view.InputDevice;
+import android.view.KeyEvent;
+
+import com.limelight.LimeLog;
+import com.limelight.preferences.PreferenceConfiguration;
+import com.limelight.utils.KeyMapper;
+
+import java.util.Arrays;
+
+/**
+ * Class to translate a Android key code into the codes GFE is expecting
+ * @author Diego Waxemberg
+ * @author Cameron Gutman
+ */
+public class KeyboardTranslator implements InputManager.InputDeviceListener {
+
+ /**
+ * GFE's prefix for every key code
+ */
+ private static final short KEY_PREFIX = (short) 0x80;
+
+ public static final int VK_0 = 48;
+ public static final int VK_9 = 57;
+ public static final int VK_A = 65;
+ public static final int VK_Z = 90;
+ public static final int VK_NUMPAD0 = 96;
+ public static final int VK_BACK_SLASH = 92;
+ public static final int VK_CAPS_LOCK = 20;
+ public static final int VK_CLEAR = 12;
+ public static final int VK_COMMA = 44;
+ public static final int VK_BACK_SPACE = 8;
+ public static final int VK_EQUALS = 61;
+ public static final int VK_ESCAPE = 27;
+ public static final int VK_F1 = 112;
+ public static final int VK_F12 = 123;
+
+ public static final int VK_END = 35;
+ public static final int VK_HOME = 36;
+ public static final int VK_NUM_LOCK = 144;
+ public static final int VK_PAGE_UP = 33;
+ public static final int VK_PAGE_DOWN = 34;
+ public static final int VK_PLUS = 521;
+ public static final int VK_CLOSE_BRACKET = 93;
+ public static final int VK_SCROLL_LOCK = 145;
+ public static final int VK_SEMICOLON = 59;
+ public static final int VK_SLASH = 47;
+ public static final int VK_SPACE = 32;
+ public static final int VK_PRINTSCREEN = 154;
+ public static final int VK_TAB = 9;
+ public static final int VK_LEFT = 37;
+ public static final int VK_RIGHT = 39;
+ public static final int VK_UP = 38;
+ public static final int VK_DOWN = 40;
+ public static final int VK_BACK_QUOTE = 192;
+ public static final int VK_QUOTE = 222;
+ public static final int VK_PAUSE = 19;
+
+ public static final int VK_B = 66;
+
+ public static final int VK_C = 67;
+ public static final int VK_D = 68;
+ public static final int VK_G = 71;
+ public static final int VK_V = 86;
+ public static final int VK_Q = 81;
+
+ public static final int VK_S = 83;
+
+ public static final int VK_U = 85;
+
+ public static final int VK_X = 88;
+ public static final int VK_R = 82;
+
+ public static final int VK_I = 73;
+
+ public static final int VK_F11 = 122;
+ public static final int VK_LWIN = 91;
+ public static final int VK_LSHIFT = 160;
+ public static final int VK_LCONTROL = 162;
+
+ //Left ALT key
+ public static final int VK_LMENU = 164;
+ //ENTER key
+ public static final int VK_RETURN = 13;
+
+ public static final int VK_F4 = 115;
+
+ private final PreferenceConfiguration prefConfig;
+
+ private static class KeyboardMapping {
+ private final InputDevice device;
+ private final int[] deviceKeyCodeToQwertyKeyCode;
+
+ @TargetApi(33)
+ public KeyboardMapping(InputDevice device) {
+ int maxKeyCode = KeyEvent.getMaxKeyCode();
+
+ this.device = device;
+ this.deviceKeyCodeToQwertyKeyCode = new int[maxKeyCode + 1];
+
+ // Any unmatched keycodes are treated as unknown
+ Arrays.fill(deviceKeyCodeToQwertyKeyCode, KeyEvent.KEYCODE_UNKNOWN);
+
+ for (int i = 0; i <= maxKeyCode; i++) {
+ int deviceKeyCode = device.getKeyCodeForKeyLocation(i);
+ if (deviceKeyCode != KeyEvent.KEYCODE_UNKNOWN) {
+ deviceKeyCodeToQwertyKeyCode[deviceKeyCode] = i;
+ }
+ }
+ }
+
+ @TargetApi(33)
+ public int getDeviceKeyCodeForQwertyKeyCode(int qwertyKeyCode) {
+ return device.getKeyCodeForKeyLocation(qwertyKeyCode);
+ }
+
+ public int getQwertyKeyCodeForDeviceKeyCode(int deviceKeyCode) {
+ if (deviceKeyCode > KeyEvent.getMaxKeyCode()) {
+ return KeyEvent.KEYCODE_UNKNOWN;
+ }
+
+ return deviceKeyCodeToQwertyKeyCode[deviceKeyCode];
+ }
+ }
+
+ private final SparseArray keyboardMappings = new SparseArray<>();
+
+ public KeyboardTranslator(PreferenceConfiguration prefConfig) {
+ this.prefConfig = prefConfig;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ for (int deviceId : InputDevice.getDeviceIds()) {
+ InputDevice device = InputDevice.getDevice(deviceId);
+ if (device != null && device.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC) {
+ keyboardMappings.set(deviceId, new KeyboardMapping(device));
+ }
+ }
+ }
+ }
+
+ public boolean hasNormalizedMapping(int keycode, int deviceId) {
+ if (deviceId >= 0) {
+ KeyboardMapping mapping = keyboardMappings.get(deviceId);
+ if (mapping != null) {
+ // Try to map this device-specific keycode onto a QWERTY layout.
+ // GFE assumes incoming keycodes are from a QWERTY keyboard.
+ int qwertyKeyCode = mapping.getQwertyKeyCodeForDeviceKeyCode(keycode);
+ if (qwertyKeyCode != KeyEvent.KEYCODE_UNKNOWN) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Translates the given keycode and returns the GFE keycode
+ * @param keycode the code to be translated
+ * @param deviceId InputDevice.getId() or -1 if unknown
+ * @return a GFE keycode for the given keycode
+ */
+ public short translate(int keycode, int scancode, int deviceId) {
+ int translated;
+
+ // If a device ID was provided, look up the keyboard mapping
+ // Force qwerty will break user's keyboard layout settings
+ if (prefConfig.forceQwerty && deviceId >= 0) {
+ KeyboardMapping mapping = keyboardMappings.get(deviceId);
+ if (mapping != null) {
+ // Try to map this device-specific keycode onto a QWERTY layout.
+ // GFE assumes incoming keycodes are from a QWERTY keyboard.
+ int qwertyKeyCode = mapping.getQwertyKeyCodeForDeviceKeyCode(keycode);
+ if (qwertyKeyCode != KeyEvent.KEYCODE_UNKNOWN) {
+ keycode = qwertyKeyCode;
+ }
+ }
+ }
+
+ // This is a poor man's mapping between Android key codes
+ // and Windows VK_* codes. For all defined VK_ codes, see:
+ // https://msdn.microsoft.com/en-us/library/windows/desktop/dd375731(v=vs.85).aspx
+ if (keycode >= KeyEvent.KEYCODE_0 &&
+ keycode <= KeyEvent.KEYCODE_9) {
+ translated = (keycode - KeyEvent.KEYCODE_0) + VK_0;
+ }
+ else if (keycode >= KeyEvent.KEYCODE_A &&
+ keycode <= KeyEvent.KEYCODE_Z) {
+ translated = (keycode - KeyEvent.KEYCODE_A) + VK_A;
+ }
+ else if (keycode >= KeyEvent.KEYCODE_NUMPAD_0 &&
+ keycode <= KeyEvent.KEYCODE_NUMPAD_9) {
+ translated = (keycode - KeyEvent.KEYCODE_NUMPAD_0) + VK_NUMPAD0;
+ }
+ else if (keycode >= KeyEvent.KEYCODE_F1 &&
+ keycode <= KeyEvent.KEYCODE_F12) {
+ translated = (keycode - KeyEvent.KEYCODE_F1) + VK_F1;
+ }
+ else {
+ switch (keycode) {
+ case KeyEvent.KEYCODE_ALT_LEFT:
+ translated = 0xA4;
+ break;
+
+ case KeyEvent.KEYCODE_ALT_RIGHT:
+ translated = 0xA5;
+ break;
+
+ case KeyEvent.KEYCODE_BACKSLASH:
+ translated = 0xdc;
+ break;
+
+ case KeyEvent.KEYCODE_CAPS_LOCK:
+ translated = VK_CAPS_LOCK;
+ break;
+
+ case KeyEvent.KEYCODE_CLEAR:
+ translated = VK_CLEAR;
+ break;
+
+ case KeyEvent.KEYCODE_COMMA:
+ translated = 0xbc;
+ break;
+
+ case KeyEvent.KEYCODE_CTRL_LEFT:
+ translated = 0xA2;
+ break;
+
+ case KeyEvent.KEYCODE_CTRL_RIGHT:
+ translated = 0xA3;
+ break;
+
+ case KeyEvent.KEYCODE_DEL:
+ translated = VK_BACK_SPACE;
+ break;
+
+ case KeyEvent.KEYCODE_ENTER:
+ translated = 0x0d;
+ break;
+
+ case KeyEvent.KEYCODE_PLUS:
+ case KeyEvent.KEYCODE_EQUALS:
+ translated = 0xbb;
+ break;
+
+ case KeyEvent.KEYCODE_ESCAPE:
+ translated = VK_ESCAPE;
+ break;
+
+ case KeyEvent.KEYCODE_FORWARD_DEL:
+ translated = 0x2e;
+ break;
+
+ case KeyEvent.KEYCODE_INSERT:
+ translated = 0x2d;
+ break;
+
+ case KeyEvent.KEYCODE_LEFT_BRACKET:
+ translated = 0xdb;
+ break;
+
+ case KeyEvent.KEYCODE_META_LEFT:
+ translated = 0x5b;
+ break;
+
+ case KeyEvent.KEYCODE_META_RIGHT:
+ translated = 0x5c;
+ break;
+
+ case KeyEvent.KEYCODE_MENU:
+ translated = 0x5d;
+ break;
+
+ case KeyEvent.KEYCODE_MINUS:
+ translated = 0xbd;
+ break;
+
+ case KeyEvent.KEYCODE_MOVE_END:
+ translated = VK_END;
+ break;
+
+ case KeyEvent.KEYCODE_MOVE_HOME:
+ translated = VK_HOME;
+ break;
+
+ case KeyEvent.KEYCODE_NUM_LOCK:
+ translated = VK_NUM_LOCK;
+ break;
+
+ case KeyEvent.KEYCODE_PAGE_DOWN:
+ translated = VK_PAGE_DOWN;
+ break;
+
+ case KeyEvent.KEYCODE_PAGE_UP:
+ translated = VK_PAGE_UP;
+ break;
+
+ case KeyEvent.KEYCODE_PERIOD:
+ translated = 0xbe;
+ break;
+
+ case KeyEvent.KEYCODE_RIGHT_BRACKET:
+ translated = 0xdd;
+ break;
+
+ case KeyEvent.KEYCODE_SCROLL_LOCK:
+ translated = VK_SCROLL_LOCK;
+ break;
+
+ case KeyEvent.KEYCODE_SEMICOLON:
+ translated = 0xba;
+ break;
+
+ case KeyEvent.KEYCODE_SHIFT_LEFT:
+ translated = 0xA0;
+ break;
+
+ case KeyEvent.KEYCODE_SHIFT_RIGHT:
+ translated = 0xA1;
+ break;
+
+ case KeyEvent.KEYCODE_SLASH:
+ translated = 0xbf;
+ break;
+
+ case KeyEvent.KEYCODE_SPACE:
+ translated = VK_SPACE;
+ break;
+
+ case KeyEvent.KEYCODE_SYSRQ:
+ // Android defines this as SysRq/PrntScrn
+ translated = VK_PRINTSCREEN;
+ break;
+
+ case KeyEvent.KEYCODE_TAB:
+ translated = VK_TAB;
+ break;
+
+ case KeyEvent.KEYCODE_DPAD_LEFT:
+ translated = VK_LEFT;
+ break;
+
+ case KeyEvent.KEYCODE_DPAD_RIGHT:
+ translated = VK_RIGHT;
+ break;
+
+ case KeyEvent.KEYCODE_DPAD_UP:
+ translated = VK_UP;
+ break;
+
+ case KeyEvent.KEYCODE_DPAD_DOWN:
+ translated = VK_DOWN;
+ break;
+
+ case KeyEvent.KEYCODE_GRAVE:
+ translated = VK_BACK_QUOTE;
+ break;
+
+ case KeyEvent.KEYCODE_APOSTROPHE:
+ translated = 0xde;
+ break;
+
+ case KeyEvent.KEYCODE_BREAK:
+ translated = VK_PAUSE;
+ break;
+
+ case KeyEvent.KEYCODE_NUMPAD_DIVIDE:
+ translated = 0x6F;
+ break;
+
+ case KeyEvent.KEYCODE_NUMPAD_MULTIPLY:
+ translated = 0x6A;
+ break;
+
+ case KeyEvent.KEYCODE_NUMPAD_SUBTRACT:
+ translated = 0x6D;
+ break;
+
+ case KeyEvent.KEYCODE_NUMPAD_ADD:
+ translated = 0x6B;
+ break;
+
+ case KeyEvent.KEYCODE_NUMPAD_DOT:
+ translated = 0x6E;
+ break;
+
+ default:
+ translated = 0;
+ }
+ }
+
+ if (translated == 0) {
+ // Do not translate with scan code if we have a normalized mapping
+ if (hasNormalizedMapping(keycode, deviceId)) {
+ return 0;
+ }
+
+ // Fall back to scancode translation
+ translated = KeyMapper.getWindowsKeyCode(scancode);
+ if (translated < 0) {
+ return 0;
+ }
+ }
+
+ return (short) ((KEY_PREFIX << 8) | translated);
+ }
+
+ @Override
+ public void onInputDeviceAdded(int index) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ InputDevice device = InputDevice.getDevice(index);
+ if (device != null && device.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC) {
+ keyboardMappings.put(index, new KeyboardMapping(device));
+ }
+ }
+ }
+
+ @Override
+ public void onInputDeviceRemoved(int index) {
+ keyboardMappings.remove(index);
+ }
+
+ @Override
+ public void onInputDeviceChanged(int index) {
+ keyboardMappings.remove(index);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ InputDevice device = InputDevice.getDevice(index);
+ if (device != null && device.getKeyboardType() == InputDevice.KEYBOARD_TYPE_ALPHABETIC) {
+ keyboardMappings.set(index, new KeyboardMapping(device));
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/capture/AndroidNativePointerCaptureProvider.java b/app/src/main/java/com/limelight/binding/input/capture/AndroidNativePointerCaptureProvider.java
old mode 100644
new mode 100755
index 589f41375e..d849b83b5e
--- a/app/src/main/java/com/limelight/binding/input/capture/AndroidNativePointerCaptureProvider.java
+++ b/app/src/main/java/com/limelight/binding/input/capture/AndroidNativePointerCaptureProvider.java
@@ -1,170 +1,170 @@
-package com.limelight.binding.input.capture;
-
-import android.annotation.TargetApi;
-import android.app.Activity;
-import android.hardware.input.InputManager;
-import android.os.Build;
-import android.os.Handler;
-import android.view.InputDevice;
-import android.view.MotionEvent;
-import android.view.View;
-
-
-// We extend AndroidPointerIconCaptureProvider because we want to also get the
-// pointer icon hiding behavior over our stream view just in case pointer capture
-// is unavailable on this system (ex: DeX, ChromeOS)
-@TargetApi(Build.VERSION_CODES.O)
-public class AndroidNativePointerCaptureProvider extends AndroidPointerIconCaptureProvider implements InputManager.InputDeviceListener {
- private final InputManager inputManager;
- private final View targetView;
-
- public AndroidNativePointerCaptureProvider(Activity activity, View targetView) {
- super(activity, targetView);
- this.inputManager = activity.getSystemService(InputManager.class);
- this.targetView = targetView;
- }
-
- public static boolean isCaptureProviderSupported() {
- return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O;
- }
-
- // We only capture the pointer if we have a compatible InputDevice
- // present. This is a workaround for an Android 12 regression causing
- // incorrect mouse input when using the SPen.
- // https://github.com/moonlight-stream/moonlight-android/issues/1030
- private boolean hasCaptureCompatibleInputDevice() {
- for (int id : InputDevice.getDeviceIds()) {
- InputDevice device = InputDevice.getDevice(id);
- if (device == null) {
- continue;
- }
-
- // Skip touchscreens when considering compatible capture devices.
- // Samsung devices on Android 12 will report a sec_touchpad device
- // with SOURCE_TOUCHSCREEN, SOURCE_KEYBOARD, and SOURCE_MOUSE.
- // Upon enabling pointer capture, that device will switch to
- // SOURCE_KEYBOARD and SOURCE_TOUCHPAD.
- // Only skip on non ChromeOS devices cause the ChromeOS pointer else
- // gets disabled removing relative mouse capabilities
- // on Chromebooks with touchscreens
- if (device.supportsSource(InputDevice.SOURCE_TOUCHSCREEN) && !targetView.getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management")) {
- continue;
- }
-
- if (device.supportsSource(InputDevice.SOURCE_MOUSE) ||
- device.supportsSource(InputDevice.SOURCE_MOUSE_RELATIVE) ||
- device.supportsSource(InputDevice.SOURCE_TOUCHPAD)) {
- return true;
- }
- }
-
- return false;
- }
-
- @Override
- public void showCursor() {
- super.showCursor();
-
- // It is important to unregister the listener *before* releasing pointer capture,
- // because releasing pointer capture can cause an onInputDeviceChanged() callback
- // for devices with a touchpad (like a DS4 controller).
- inputManager.unregisterInputDeviceListener(this);
- targetView.releasePointerCapture();
- }
-
- @Override
- public void hideCursor() {
- super.hideCursor();
-
- // Listen for device events to enable/disable capture
- inputManager.registerInputDeviceListener(this, null);
-
- // Capture now if we have a capture-capable device
- if (hasCaptureCompatibleInputDevice()) {
- targetView.requestPointerCapture();
- }
- }
-
- @Override
- public void onWindowFocusChanged(boolean focusActive) {
- // NB: We have to check cursor visibility here because Android pointer capture
- // doesn't support capturing the cursor while it's visible. Enabling pointer
- // capture implicitly hides the cursor.
- if (!focusActive || !isCapturing || isCursorVisible) {
- return;
- }
-
- // Recapture the pointer if focus was regained. On Android Q,
- // we have to delay a bit before requesting capture because otherwise
- // we'll hit the "requestPointerCapture called for a window that has no focus"
- // error and it will not actually capture the cursor.
- Handler h = new Handler();
- h.postDelayed(new Runnable() {
- @Override
- public void run() {
- if (hasCaptureCompatibleInputDevice()) {
- targetView.requestPointerCapture();
- }
- }
- }, 500);
- }
-
- @Override
- public boolean eventHasRelativeMouseAxes(MotionEvent event) {
- // SOURCE_MOUSE_RELATIVE is how SOURCE_MOUSE appears when our view has pointer capture.
- // SOURCE_TOUCHPAD will have relative axes populated iff our view has pointer capture.
- // See https://developer.android.com/reference/android/view/View#requestPointerCapture()
- int eventSource = event.getSource();
- return (eventSource == InputDevice.SOURCE_MOUSE_RELATIVE && event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) ||
- (eventSource == InputDevice.SOURCE_TOUCHPAD && targetView.hasPointerCapture());
- }
-
- @Override
- public float getRelativeAxisX(MotionEvent event) {
- int axis = (event.getSource() == InputDevice.SOURCE_MOUSE_RELATIVE) ?
- MotionEvent.AXIS_X : MotionEvent.AXIS_RELATIVE_X;
- float x = event.getAxisValue(axis);
- for (int i = 0; i < event.getHistorySize(); i++) {
- x += event.getHistoricalAxisValue(axis, i);
- }
- return x;
- }
-
- @Override
- public float getRelativeAxisY(MotionEvent event) {
- int axis = (event.getSource() == InputDevice.SOURCE_MOUSE_RELATIVE) ?
- MotionEvent.AXIS_Y : MotionEvent.AXIS_RELATIVE_Y;
- float y = event.getAxisValue(axis);
- for (int i = 0; i < event.getHistorySize(); i++) {
- y += event.getHistoricalAxisValue(axis, i);
- }
- return y;
- }
-
- @Override
- public void onInputDeviceAdded(int deviceId) {
- // Check if we've added a capture-compatible device
- if (!targetView.hasPointerCapture() && hasCaptureCompatibleInputDevice()) {
- targetView.requestPointerCapture();
- }
- }
-
- @Override
- public void onInputDeviceRemoved(int deviceId) {
- // Check if the capture-compatible device was removed
- if (targetView.hasPointerCapture() && !hasCaptureCompatibleInputDevice()) {
- targetView.releasePointerCapture();
- }
- }
-
- @Override
- public void onInputDeviceChanged(int deviceId) {
- // Emulating a remove+add should be sufficient for our purposes.
- //
- // Note: This callback must be handled carefully because it can happen as a result of
- // calling requestPointerCapture(). This can cause trackpad devices to gain SOURCE_MOUSE_RELATIVE
- // and re-enter this callback.
- onInputDeviceRemoved(deviceId);
- onInputDeviceAdded(deviceId);
- }
-}
+package com.limelight.binding.input.capture;
+
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.hardware.input.InputManager;
+import android.os.Build;
+import android.os.Handler;
+import android.view.InputDevice;
+import android.view.MotionEvent;
+import android.view.View;
+
+
+// We extend AndroidPointerIconCaptureProvider because we want to also get the
+// pointer icon hiding behavior over our stream view just in case pointer capture
+// is unavailable on this system (ex: DeX, ChromeOS)
+@TargetApi(Build.VERSION_CODES.O)
+public class AndroidNativePointerCaptureProvider extends AndroidPointerIconCaptureProvider implements InputManager.InputDeviceListener {
+ private final InputManager inputManager;
+ private final View targetView;
+
+ public AndroidNativePointerCaptureProvider(Activity activity, View targetView) {
+ super(activity, targetView);
+ this.inputManager = activity.getSystemService(InputManager.class);
+ this.targetView = targetView;
+ }
+
+ public static boolean isCaptureProviderSupported() {
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O;
+ }
+
+ // We only capture the pointer if we have a compatible InputDevice
+ // present. This is a workaround for an Android 12 regression causing
+ // incorrect mouse input when using the SPen.
+ // https://github.com/moonlight-stream/moonlight-android/issues/1030
+ private boolean hasCaptureCompatibleInputDevice() {
+ for (int id : InputDevice.getDeviceIds()) {
+ InputDevice device = InputDevice.getDevice(id);
+ if (device == null) {
+ continue;
+ }
+
+ // Skip touchscreens when considering compatible capture devices.
+ // Samsung devices on Android 12 will report a sec_touchpad device
+ // with SOURCE_TOUCHSCREEN, SOURCE_KEYBOARD, and SOURCE_MOUSE.
+ // Upon enabling pointer capture, that device will switch to
+ // SOURCE_KEYBOARD and SOURCE_TOUCHPAD.
+ // Only skip on non ChromeOS devices cause the ChromeOS pointer else
+ // gets disabled removing relative mouse capabilities
+ // on Chromebooks with touchscreens
+ if (device.supportsSource(InputDevice.SOURCE_TOUCHSCREEN) && !targetView.getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management")) {
+ continue;
+ }
+
+ if (device.supportsSource(InputDevice.SOURCE_MOUSE) ||
+ device.supportsSource(InputDevice.SOURCE_MOUSE_RELATIVE) ||
+ device.supportsSource(InputDevice.SOURCE_TOUCHPAD)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ @Override
+ public void showCursor() {
+ super.showCursor();
+
+ // It is important to unregister the listener *before* releasing pointer capture,
+ // because releasing pointer capture can cause an onInputDeviceChanged() callback
+ // for devices with a touchpad (like a DS4 controller).
+ inputManager.unregisterInputDeviceListener(this);
+ targetView.releasePointerCapture();
+ }
+
+ @Override
+ public void hideCursor() {
+ super.hideCursor();
+
+ // Listen for device events to enable/disable capture
+ inputManager.registerInputDeviceListener(this, null);
+
+ // Capture now if we have a capture-capable device
+ if (hasCaptureCompatibleInputDevice()) {
+ targetView.requestPointerCapture();
+ }
+ }
+
+ @Override
+ public void onWindowFocusChanged(boolean focusActive) {
+ // NB: We have to check cursor visibility here because Android pointer capture
+ // doesn't support capturing the cursor while it's visible. Enabling pointer
+ // capture implicitly hides the cursor.
+ if (!focusActive || !isCapturing || isCursorVisible) {
+ return;
+ }
+
+ // Recapture the pointer if focus was regained. On Android Q,
+ // we have to delay a bit before requesting capture because otherwise
+ // we'll hit the "requestPointerCapture called for a window that has no focus"
+ // error and it will not actually capture the cursor.
+ Handler h = new Handler();
+ h.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ if (hasCaptureCompatibleInputDevice()) {
+ targetView.requestPointerCapture();
+ }
+ }
+ }, 500);
+ }
+
+ @Override
+ public boolean eventHasRelativeMouseAxes(MotionEvent event) {
+ // SOURCE_MOUSE_RELATIVE is how SOURCE_MOUSE appears when our view has pointer capture.
+ // SOURCE_TOUCHPAD will have relative axes populated iff our view has pointer capture.
+ // See https://developer.android.com/reference/android/view/View#requestPointerCapture()
+ int eventSource = event.getSource();
+ return (eventSource == InputDevice.SOURCE_MOUSE_RELATIVE && event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) ||
+ (eventSource == InputDevice.SOURCE_TOUCHPAD && targetView.hasPointerCapture());
+ }
+
+ @Override
+ public float getRelativeAxisX(MotionEvent event, int pointerIndex) {
+ int axis = (event.getSource() == InputDevice.SOURCE_MOUSE_RELATIVE) ?
+ MotionEvent.AXIS_X : MotionEvent.AXIS_RELATIVE_X;
+ float x = event.getAxisValue(axis, pointerIndex);
+ for (int i = 0; i < event.getHistorySize(); i++) {
+ x += event.getHistoricalAxisValue(axis, pointerIndex, i);
+ }
+ return x;
+ }
+
+ @Override
+ public float getRelativeAxisY(MotionEvent event, int pointerIndex) {
+ int axis = (event.getSource() == InputDevice.SOURCE_MOUSE_RELATIVE) ?
+ MotionEvent.AXIS_Y : MotionEvent.AXIS_RELATIVE_Y;
+ float y = event.getAxisValue(axis, pointerIndex);
+ for (int i = 0; i < event.getHistorySize(); i++) {
+ y += event.getHistoricalAxisValue(axis, pointerIndex, i);
+ }
+ return y;
+ }
+
+ @Override
+ public void onInputDeviceAdded(int deviceId) {
+ // Check if we've added a capture-compatible device
+ if (!targetView.hasPointerCapture() && hasCaptureCompatibleInputDevice()) {
+ targetView.requestPointerCapture();
+ }
+ }
+
+ @Override
+ public void onInputDeviceRemoved(int deviceId) {
+ // Check if the capture-compatible device was removed
+ if (targetView.hasPointerCapture() && !hasCaptureCompatibleInputDevice()) {
+ targetView.releasePointerCapture();
+ }
+ }
+
+ @Override
+ public void onInputDeviceChanged(int deviceId) {
+ // Emulating a remove+add should be sufficient for our purposes.
+ //
+ // Note: This callback must be handled carefully because it can happen as a result of
+ // calling requestPointerCapture(). This can cause trackpad devices to gain SOURCE_MOUSE_RELATIVE
+ // and re-enter this callback.
+ onInputDeviceRemoved(deviceId);
+ onInputDeviceAdded(deviceId);
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/capture/AndroidPointerIconCaptureProvider.java b/app/src/main/java/com/limelight/binding/input/capture/AndroidPointerIconCaptureProvider.java
old mode 100644
new mode 100755
index 6a2d472ae5..bbd1115fc9
--- a/app/src/main/java/com/limelight/binding/input/capture/AndroidPointerIconCaptureProvider.java
+++ b/app/src/main/java/com/limelight/binding/input/capture/AndroidPointerIconCaptureProvider.java
@@ -1,35 +1,35 @@
-package com.limelight.binding.input.capture;
-
-import android.annotation.TargetApi;
-import android.app.Activity;
-import android.content.Context;
-import android.os.Build;
-import android.view.PointerIcon;
-import android.view.View;
-
-@TargetApi(Build.VERSION_CODES.N)
-public class AndroidPointerIconCaptureProvider extends InputCaptureProvider {
- private final View targetView;
- private final Context context;
-
- public AndroidPointerIconCaptureProvider(Activity activity, View targetView) {
- this.context = activity;
- this.targetView = targetView;
- }
-
- public static boolean isCaptureProviderSupported() {
- return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N;
- }
-
- @Override
- public void hideCursor() {
- super.hideCursor();
- targetView.setPointerIcon(PointerIcon.getSystemIcon(context, PointerIcon.TYPE_NULL));
- }
-
- @Override
- public void showCursor() {
- super.showCursor();
- targetView.setPointerIcon(null);
- }
-}
+package com.limelight.binding.input.capture;
+
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.content.Context;
+import android.os.Build;
+import android.view.PointerIcon;
+import android.view.View;
+
+@TargetApi(Build.VERSION_CODES.N)
+public class AndroidPointerIconCaptureProvider extends InputCaptureProvider {
+ private final View targetView;
+ private final Context context;
+
+ public AndroidPointerIconCaptureProvider(Activity activity, View targetView) {
+ this.context = activity;
+ this.targetView = targetView;
+ }
+
+ public static boolean isCaptureProviderSupported() {
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N;
+ }
+
+ @Override
+ public void hideCursor() {
+ super.hideCursor();
+ targetView.setPointerIcon(PointerIcon.getSystemIcon(context, PointerIcon.TYPE_NULL));
+ }
+
+ @Override
+ public void showCursor() {
+ super.showCursor();
+ targetView.setPointerIcon(null);
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/capture/InputCaptureManager.java b/app/src/main/java/com/limelight/binding/input/capture/InputCaptureManager.java
old mode 100644
new mode 100755
diff --git a/app/src/main/java/com/limelight/binding/input/capture/InputCaptureProvider.java b/app/src/main/java/com/limelight/binding/input/capture/InputCaptureProvider.java
old mode 100644
new mode 100755
index 3070be5e2c..c9a7939df3
--- a/app/src/main/java/com/limelight/binding/input/capture/InputCaptureProvider.java
+++ b/app/src/main/java/com/limelight/binding/input/capture/InputCaptureProvider.java
@@ -1,49 +1,57 @@
-package com.limelight.binding.input.capture;
-
-import android.view.MotionEvent;
-
-public abstract class InputCaptureProvider {
- protected boolean isCapturing;
- protected boolean isCursorVisible;
-
- public void enableCapture() {
- isCapturing = true;
- hideCursor();
- }
- public void disableCapture() {
- isCapturing = false;
- showCursor();
- }
-
- public void destroy() {}
-
- public boolean isCapturingEnabled() {
- return isCapturing;
- }
-
- public boolean isCapturingActive() {
- return isCapturing;
- }
-
- public void showCursor() {
- isCursorVisible = true;
- }
-
- public void hideCursor() {
- isCursorVisible = false;
- }
-
- public boolean eventHasRelativeMouseAxes(MotionEvent event) {
- return false;
- }
-
- public float getRelativeAxisX(MotionEvent event) {
- return 0;
- }
-
- public float getRelativeAxisY(MotionEvent event) {
- return 0;
- }
-
- public void onWindowFocusChanged(boolean focusActive) {}
-}
+package com.limelight.binding.input.capture;
+
+import android.view.MotionEvent;
+
+public abstract class InputCaptureProvider {
+ protected boolean isCapturing;
+ protected boolean isCursorVisible;
+
+ public void enableCapture() {
+ isCapturing = true;
+ hideCursor();
+ }
+ public void disableCapture() {
+ isCapturing = false;
+ showCursor();
+ }
+
+ public void destroy() {}
+
+ public boolean isCapturingEnabled() {
+ return isCapturing;
+ }
+
+ public boolean isCapturingActive() {
+ return isCapturing;
+ }
+
+ public void showCursor() {
+ isCursorVisible = true;
+ }
+
+ public void hideCursor() {
+ isCursorVisible = false;
+ }
+
+ public boolean eventHasRelativeMouseAxes(MotionEvent event) {
+ return false;
+ }
+
+ public float getRelativeAxisX(MotionEvent event, int pointerIndex) {
+ return 0;
+ }
+
+ public float getRelativeAxisX(MotionEvent event) {
+ return getRelativeAxisX(event, 0);
+ }
+
+ public float getRelativeAxisY(MotionEvent event, int pointerIndex) {
+ return 0;
+ }
+
+ public float getRelativeAxisY(MotionEvent event) {
+ return getRelativeAxisY(event, 0);
+ }
+
+ public void onWindowFocusChanged(boolean focusActive) {}
+}
diff --git a/app/src/main/java/com/limelight/binding/input/capture/NullCaptureProvider.java b/app/src/main/java/com/limelight/binding/input/capture/NullCaptureProvider.java
old mode 100644
new mode 100755
index d8d9cb80b3..8065052dc6
--- a/app/src/main/java/com/limelight/binding/input/capture/NullCaptureProvider.java
+++ b/app/src/main/java/com/limelight/binding/input/capture/NullCaptureProvider.java
@@ -1,4 +1,4 @@
-package com.limelight.binding.input.capture;
-
-
-public class NullCaptureProvider extends InputCaptureProvider {}
+package com.limelight.binding.input.capture;
+
+
+public class NullCaptureProvider extends InputCaptureProvider {}
diff --git a/app/src/main/java/com/limelight/binding/input/capture/ShieldCaptureProvider.java b/app/src/main/java/com/limelight/binding/input/capture/ShieldCaptureProvider.java
old mode 100644
new mode 100755
index b7f7a86d54..3a9b2a7c49
--- a/app/src/main/java/com/limelight/binding/input/capture/ShieldCaptureProvider.java
+++ b/app/src/main/java/com/limelight/binding/input/capture/ShieldCaptureProvider.java
@@ -1,93 +1,93 @@
-package com.limelight.binding.input.capture;
-
-
-import android.content.Context;
-import android.hardware.input.InputManager;
-import android.view.MotionEvent;
-
-import java.lang.reflect.Field;
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
-
-// NVIDIA extended the Android input APIs with support for using an attached mouse in relative
-// mode without having to grab the input device (which requires root). The data comes in the form
-// of new AXIS_RELATIVE_X and AXIS_RELATIVE_Y constants in the mouse's MotionEvent objects and
-// a new function, InputManager.setCursorVisibility(), that allows the cursor to be hidden.
-//
-// http://docs.nvidia.com/gameworks/index.html#technologies/mobile/game_controller_handling_mouse.htm
-
-public class ShieldCaptureProvider extends InputCaptureProvider {
- private static boolean nvExtensionSupported;
- private static Method methodSetCursorVisibility;
- private static int AXIS_RELATIVE_X;
- private static int AXIS_RELATIVE_Y;
-
- private final Context context;
-
- static {
- try {
- methodSetCursorVisibility = InputManager.class.getMethod("setCursorVisibility", boolean.class);
-
- Field fieldRelX = MotionEvent.class.getField("AXIS_RELATIVE_X");
- Field fieldRelY = MotionEvent.class.getField("AXIS_RELATIVE_Y");
-
- AXIS_RELATIVE_X = (Integer) fieldRelX.get(null);
- AXIS_RELATIVE_Y = (Integer) fieldRelY.get(null);
-
- nvExtensionSupported = true;
- } catch (Exception e) {
- nvExtensionSupported = false;
- }
- }
-
- public ShieldCaptureProvider(Context context) {
- this.context = context;
- }
-
- public static boolean isCaptureProviderSupported() {
- return nvExtensionSupported;
- }
-
- private boolean setCursorVisibility(boolean visible) {
- try {
- methodSetCursorVisibility.invoke(context.getSystemService(Context.INPUT_SERVICE), visible);
- return true;
- } catch (InvocationTargetException e) {
- e.printStackTrace();
- } catch (IllegalAccessException e) {
- e.printStackTrace();
- }
-
- return false;
- }
-
- @Override
- public void hideCursor() {
- super.hideCursor();
- setCursorVisibility(false);
- }
-
- @Override
- public void showCursor() {
- super.showCursor();
- setCursorVisibility(true);
- }
-
- @Override
- public boolean eventHasRelativeMouseAxes(MotionEvent event) {
- // All mouse events should use relative axes, even if they are zero. This avoids triggering
- // cursor jumps if we get an event with no associated motion, like ACTION_DOWN or ACTION_UP.
- return event.getPointerCount() == 1 && event.getActionIndex() == 0 &&
- event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE;
- }
-
- @Override
- public float getRelativeAxisX(MotionEvent event) {
- return event.getAxisValue(AXIS_RELATIVE_X);
- }
-
- @Override
- public float getRelativeAxisY(MotionEvent event) {
- return event.getAxisValue(AXIS_RELATIVE_Y);
- }
-}
+package com.limelight.binding.input.capture;
+
+
+import android.content.Context;
+import android.hardware.input.InputManager;
+import android.view.MotionEvent;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+// NVIDIA extended the Android input APIs with support for using an attached mouse in relative
+// mode without having to grab the input device (which requires root). The data comes in the form
+// of new AXIS_RELATIVE_X and AXIS_RELATIVE_Y constants in the mouse's MotionEvent objects and
+// a new function, InputManager.setCursorVisibility(), that allows the cursor to be hidden.
+//
+// http://docs.nvidia.com/gameworks/index.html#technologies/mobile/game_controller_handling_mouse.htm
+
+public class ShieldCaptureProvider extends InputCaptureProvider {
+ private static boolean nvExtensionSupported;
+ private static Method methodSetCursorVisibility;
+ private static int AXIS_RELATIVE_X;
+ private static int AXIS_RELATIVE_Y;
+
+ private final Context context;
+
+ static {
+ try {
+ methodSetCursorVisibility = InputManager.class.getMethod("setCursorVisibility", boolean.class);
+
+ Field fieldRelX = MotionEvent.class.getField("AXIS_RELATIVE_X");
+ Field fieldRelY = MotionEvent.class.getField("AXIS_RELATIVE_Y");
+
+ AXIS_RELATIVE_X = (Integer) fieldRelX.get(null);
+ AXIS_RELATIVE_Y = (Integer) fieldRelY.get(null);
+
+ nvExtensionSupported = true;
+ } catch (Exception e) {
+ nvExtensionSupported = false;
+ }
+ }
+
+ public ShieldCaptureProvider(Context context) {
+ this.context = context;
+ }
+
+ public static boolean isCaptureProviderSupported() {
+ return nvExtensionSupported;
+ }
+
+ private boolean setCursorVisibility(boolean visible) {
+ try {
+ methodSetCursorVisibility.invoke(context.getSystemService(Context.INPUT_SERVICE), visible);
+ return true;
+ } catch (InvocationTargetException e) {
+ e.printStackTrace();
+ } catch (IllegalAccessException e) {
+ e.printStackTrace();
+ }
+
+ return false;
+ }
+
+ @Override
+ public void hideCursor() {
+ super.hideCursor();
+ setCursorVisibility(false);
+ }
+
+ @Override
+ public void showCursor() {
+ super.showCursor();
+ setCursorVisibility(true);
+ }
+
+ @Override
+ public boolean eventHasRelativeMouseAxes(MotionEvent event) {
+ // All mouse events should use relative axes, even if they are zero. This avoids triggering
+ // cursor jumps if we get an event with no associated motion, like ACTION_DOWN or ACTION_UP.
+ return event.getPointerCount() == 1 && event.getActionIndex() == 0 &&
+ event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE;
+ }
+
+ @Override
+ public float getRelativeAxisX(MotionEvent event, int pointerIndex) {
+ return event.getAxisValue(AXIS_RELATIVE_X);
+ }
+
+ @Override
+ public float getRelativeAxisY(MotionEvent event) {
+ return event.getAxisValue(AXIS_RELATIVE_Y);
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/driver/AbstractController.java b/app/src/main/java/com/limelight/binding/input/driver/AbstractController.java
old mode 100644
new mode 100755
index fe31ca679c..b08fb30d24
--- a/app/src/main/java/com/limelight/binding/input/driver/AbstractController.java
+++ b/app/src/main/java/com/limelight/binding/input/driver/AbstractController.java
@@ -1,77 +1,87 @@
-package com.limelight.binding.input.driver;
-
-public abstract class AbstractController {
-
- private final int deviceId;
- private final int vendorId;
- private final int productId;
-
- private UsbDriverListener listener;
-
- protected int buttonFlags, supportedButtonFlags;
- protected float leftTrigger, rightTrigger;
- protected float rightStickX, rightStickY;
- protected float leftStickX, leftStickY;
- protected short capabilities;
- protected byte type;
-
- public int getControllerId() {
- return deviceId;
- }
-
- public int getVendorId() {
- return vendorId;
- }
-
- public int getProductId() {
- return productId;
- }
-
- public int getSupportedButtonFlags() {
- return supportedButtonFlags;
- }
-
- public short getCapabilities() {
- return capabilities;
- }
-
- public byte getType() {
- return type;
- }
-
- protected void setButtonFlag(int buttonFlag, int data) {
- if (data != 0) {
- buttonFlags |= buttonFlag;
- }
- else {
- buttonFlags &= ~buttonFlag;
- }
- }
-
- protected void reportInput() {
- listener.reportControllerState(deviceId, buttonFlags, leftStickX, leftStickY,
- rightStickX, rightStickY, leftTrigger, rightTrigger);
- }
-
- public abstract boolean start();
- public abstract void stop();
-
- public AbstractController(int deviceId, UsbDriverListener listener, int vendorId, int productId) {
- this.deviceId = deviceId;
- this.listener = listener;
- this.vendorId = vendorId;
- this.productId = productId;
- }
-
- public abstract void rumble(short lowFreqMotor, short highFreqMotor);
-
- public abstract void rumbleTriggers(short leftTrigger, short rightTrigger);
-
- protected void notifyDeviceRemoved() {
- listener.deviceRemoved(this);
- }
-
- protected void notifyDeviceAdded() {
- listener.deviceAdded(this);
- }
-}
+package com.limelight.binding.input.driver;
+
+import com.limelight.nvstream.jni.MoonBridge;
+
+public abstract class AbstractController {
+
+ private final int deviceId;
+ private final int vendorId;
+ private final int productId;
+
+ private UsbDriverListener listener;
+
+ protected int buttonFlags, supportedButtonFlags;
+ protected float leftTrigger, rightTrigger;
+ protected float rightStickX, rightStickY;
+ protected float leftStickX, leftStickY;
+ protected float gyroX, gyroY, gyroZ;
+ protected float accelX, accelY, accelZ;
+ protected short capabilities;
+ protected byte type;
+
+ public int getControllerId() {
+ return deviceId;
+ }
+
+ public int getVendorId() {
+ return vendorId;
+ }
+
+ public int getProductId() {
+ return productId;
+ }
+
+ public int getSupportedButtonFlags() {
+ return supportedButtonFlags;
+ }
+
+ public short getCapabilities() {
+ return capabilities;
+ }
+
+ public byte getType() {
+ return type;
+ }
+
+ protected void setButtonFlag(int buttonFlag, int data) {
+ if (data != 0) {
+ buttonFlags |= buttonFlag;
+ } else {
+ buttonFlags &= ~buttonFlag;
+ }
+ }
+
+ protected void reportInput() {
+ listener.reportControllerState(deviceId, buttonFlags, leftStickX, leftStickY,
+ rightStickX, rightStickY, leftTrigger, rightTrigger);
+ }
+
+ // New method to report motion events
+ protected void reportMotion() {
+ listener.reportControllerMotion(deviceId, MoonBridge.LI_MOTION_TYPE_GYRO, gyroX, gyroY, gyroZ);
+ listener.reportControllerMotion(deviceId, MoonBridge.LI_MOTION_TYPE_ACCEL, accelX, accelY, accelZ);
+ }
+
+ public abstract boolean start();
+
+ public abstract void stop();
+
+ public AbstractController(int deviceId, UsbDriverListener listener, int vendorId, int productId) {
+ this.deviceId = deviceId;
+ this.listener = listener;
+ this.vendorId = vendorId;
+ this.productId = productId;
+ }
+
+ public abstract void rumble(short lowFreqMotor, short highFreqMotor);
+
+ public abstract void rumbleTriggers(short leftTrigger, short rightTrigger);
+
+ protected void notifyDeviceRemoved() {
+ listener.deviceRemoved(this);
+ }
+
+ protected void notifyDeviceAdded() {
+ listener.deviceAdded(this);
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/driver/AbstractXboxController.java b/app/src/main/java/com/limelight/binding/input/driver/AbstractXboxController.java
old mode 100644
new mode 100755
index 4525b4fb91..0b290229df
--- a/app/src/main/java/com/limelight/binding/input/driver/AbstractXboxController.java
+++ b/app/src/main/java/com/limelight/binding/input/driver/AbstractXboxController.java
@@ -1,173 +1,183 @@
-package com.limelight.binding.input.driver;
-
-import android.hardware.usb.UsbConstants;
-import android.hardware.usb.UsbDevice;
-import android.hardware.usb.UsbDeviceConnection;
-import android.hardware.usb.UsbEndpoint;
-import android.hardware.usb.UsbInterface;
-import android.os.SystemClock;
-
-import com.limelight.LimeLog;
-import com.limelight.nvstream.input.ControllerPacket;
-import com.limelight.nvstream.jni.MoonBridge;
-
-import java.nio.ByteBuffer;
-import java.nio.ByteOrder;
-
-public abstract class AbstractXboxController extends AbstractController {
- protected final UsbDevice device;
- protected final UsbDeviceConnection connection;
-
- private Thread inputThread;
- private boolean stopped;
-
- protected UsbEndpoint inEndpt, outEndpt;
-
- public AbstractXboxController(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) {
- super(deviceId, listener, device.getVendorId(), device.getProductId());
- this.device = device;
- this.connection = connection;
- this.type = MoonBridge.LI_CTYPE_XBOX;
- this.capabilities = MoonBridge.LI_CCAP_ANALOG_TRIGGERS | MoonBridge.LI_CCAP_RUMBLE;
- this.buttonFlags =
- ControllerPacket.A_FLAG | ControllerPacket.B_FLAG | ControllerPacket.X_FLAG | ControllerPacket.Y_FLAG |
- ControllerPacket.UP_FLAG | ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG | ControllerPacket.RIGHT_FLAG |
- ControllerPacket.LB_FLAG | ControllerPacket.RB_FLAG |
- ControllerPacket.LS_CLK_FLAG | ControllerPacket.RS_CLK_FLAG |
- ControllerPacket.BACK_FLAG | ControllerPacket.PLAY_FLAG | ControllerPacket.SPECIAL_BUTTON_FLAG;
- }
-
- private Thread createInputThread() {
- return new Thread() {
- public void run() {
- try {
- // Delay for a moment before reporting the new gamepad and
- // accepting new input. This allows time for the old InputDevice
- // to go away before we reclaim its spot. If the old device is still
- // around when we call notifyDeviceAdded(), we won't be able to claim
- // the controller number used by the original InputDevice.
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- return;
- }
-
- // Report that we're added _before_ reporting input
- notifyDeviceAdded();
-
- while (!isInterrupted() && !stopped) {
- byte[] buffer = new byte[64];
-
- int res;
-
- //
- // There's no way that I can tell to determine if a device has failed
- // or if the timeout has simply expired. We'll check how long the transfer
- // took to fail and assume the device failed if it happened before the timeout
- // expired.
- //
-
- do {
- // Read the next input state packet
- long lastMillis = SystemClock.uptimeMillis();
- res = connection.bulkTransfer(inEndpt, buffer, buffer.length, 3000);
-
- // If we get a zero length response, treat it as an error
- if (res == 0) {
- res = -1;
- }
-
- if (res == -1 && SystemClock.uptimeMillis() - lastMillis < 1000) {
- LimeLog.warning("Detected device I/O error");
- AbstractXboxController.this.stop();
- break;
- }
- } while (res == -1 && !isInterrupted() && !stopped);
-
- if (res == -1 || stopped) {
- break;
- }
-
- if (handleRead(ByteBuffer.wrap(buffer, 0, res).order(ByteOrder.LITTLE_ENDIAN))) {
- // Report input if handleRead() returns true
- reportInput();
- }
- }
- }
- };
- }
-
- public boolean start() {
- // Force claim all interfaces
- for (int i = 0; i < device.getInterfaceCount(); i++) {
- UsbInterface iface = device.getInterface(i);
-
- if (!connection.claimInterface(iface, true)) {
- LimeLog.warning("Failed to claim interfaces");
- return false;
- }
- }
-
- // Find the endpoints
- UsbInterface iface = device.getInterface(0);
- for (int i = 0; i < iface.getEndpointCount(); i++) {
- UsbEndpoint endpt = iface.getEndpoint(i);
- if (endpt.getDirection() == UsbConstants.USB_DIR_IN) {
- if (inEndpt != null) {
- LimeLog.warning("Found duplicate IN endpoint");
- return false;
- }
- inEndpt = endpt;
- }
- else if (endpt.getDirection() == UsbConstants.USB_DIR_OUT) {
- if (outEndpt != null) {
- LimeLog.warning("Found duplicate OUT endpoint");
- return false;
- }
- outEndpt = endpt;
- }
- }
-
- // Make sure the required endpoints were present
- if (inEndpt == null || outEndpt == null) {
- LimeLog.warning("Missing required endpoint");
- return false;
- }
-
- // Run the init function
- if (!doInit()) {
- return false;
- }
-
- // Start listening for controller input
- inputThread = createInputThread();
- inputThread.start();
-
- return true;
- }
-
- public void stop() {
- if (stopped) {
- return;
- }
-
- stopped = true;
-
- // Cancel any rumble effects
- rumble((short)0, (short)0);
-
- // Stop the input thread
- if (inputThread != null) {
- inputThread.interrupt();
- inputThread = null;
- }
-
- // Close the USB connection
- connection.close();
-
- // Report the device removed
- notifyDeviceRemoved();
- }
-
- protected abstract boolean handleRead(ByteBuffer buffer);
- protected abstract boolean doInit();
-}
+package com.limelight.binding.input.driver;
+
+import android.hardware.usb.UsbConstants;
+import android.hardware.usb.UsbDevice;
+import android.hardware.usb.UsbDeviceConnection;
+import android.hardware.usb.UsbEndpoint;
+import android.hardware.usb.UsbInterface;
+import android.os.SystemClock;
+
+import com.limelight.LimeLog;
+import com.limelight.nvstream.input.ControllerPacket;
+import com.limelight.nvstream.jni.MoonBridge;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+public abstract class AbstractXboxController extends AbstractController {
+ protected final UsbDevice device;
+ protected final UsbDeviceConnection connection;
+
+ private Thread inputThread;
+ private boolean stopped;
+
+ protected UsbEndpoint inEndpt, outEndpt;
+
+ public AbstractXboxController(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) {
+ super(deviceId, listener, device.getVendorId(), device.getProductId());
+ this.device = device;
+ this.connection = connection;
+ this.type = MoonBridge.LI_CTYPE_XBOX;
+ this.capabilities = MoonBridge.LI_CCAP_ANALOG_TRIGGERS | MoonBridge.LI_CCAP_RUMBLE;
+ this.buttonFlags =
+ ControllerPacket.A_FLAG | ControllerPacket.B_FLAG | ControllerPacket.X_FLAG | ControllerPacket.Y_FLAG |
+ ControllerPacket.UP_FLAG | ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG | ControllerPacket.RIGHT_FLAG |
+ ControllerPacket.LB_FLAG | ControllerPacket.RB_FLAG |
+ ControllerPacket.LS_CLK_FLAG | ControllerPacket.RS_CLK_FLAG |
+ ControllerPacket.BACK_FLAG | ControllerPacket.PLAY_FLAG | ControllerPacket.SPECIAL_BUTTON_FLAG;
+ }
+
+ private Thread createInputThread() {
+ return new Thread() {
+ public void run() {
+ try {
+ // Delay for a moment before reporting the new gamepad and
+ // accepting new input. This allows time for the old InputDevice
+ // to go away before we reclaim its spot. If the old device is still
+ // around when we call notifyDeviceAdded(), we won't be able to claim
+ // the controller number used by the original InputDevice.
+ Thread.sleep(1000);
+ } catch (InterruptedException e) {
+ return;
+ }
+
+ // Report that we're added _before_ reporting input
+ notifyDeviceAdded();
+
+ while (!isInterrupted() && !stopped) {
+ byte[] buffer = new byte[64];
+
+ int res;
+
+ //
+ // There's no way that I can tell to determine if a device has failed
+ // or if the timeout has simply expired. We'll check how long the transfer
+ // took to fail and assume the device failed if it happened before the timeout
+ // expired.
+ //
+
+ do {
+ // Read the next input state packet
+ long lastMillis = SystemClock.uptimeMillis();
+ res = connection.bulkTransfer(inEndpt, buffer, buffer.length, 3000);
+
+ // If we get a zero length response, treat it as an error
+ if (res == 0) {
+ res = -1;
+ }
+
+ if (res == -1 && SystemClock.uptimeMillis() - lastMillis < 1000) {
+ LimeLog.warning("Detected device I/O error");
+ AbstractXboxController.this.stop();
+ break;
+ }
+ } while (res == -1 && !isInterrupted() && !stopped);
+
+ if (res == -1 || stopped) {
+ break;
+ }
+
+ if (handleRead(ByteBuffer.wrap(buffer, 0, res).order(ByteOrder.LITTLE_ENDIAN))) {
+ // Report input if handleRead() returns true
+ reportInput();
+ }
+ }
+ }
+ };
+ }
+
+ public boolean start() {
+ // Force claim all interfaces
+ for (int i = 0; i < device.getInterfaceCount(); i++) {
+ UsbInterface iface = device.getInterface(i);
+
+ if (!connection.claimInterface(iface, true)) {
+ LimeLog.warning("Failed to claim interfaces");
+ return false;
+ }
+ }
+
+ // Find the endpoints
+ UsbInterface iface = device.getInterface(0);
+ for (int i = 0; i < iface.getEndpointCount(); i++) {
+ UsbEndpoint endpt = iface.getEndpoint(i);
+ if (endpt.getDirection() == UsbConstants.USB_DIR_IN) {
+ if (inEndpt != null) {
+ LimeLog.warning("Found duplicate IN endpoint");
+ return false;
+ }
+ inEndpt = endpt;
+ }
+ else if (endpt.getDirection() == UsbConstants.USB_DIR_OUT) {
+ if (outEndpt != null) {
+ LimeLog.warning("Found duplicate OUT endpoint");
+ return false;
+ }
+ outEndpt = endpt;
+ }
+ }
+
+ // Make sure the required endpoints were present
+ if (inEndpt == null || outEndpt == null) {
+ LimeLog.warning("Missing required endpoint");
+ return false;
+ }
+
+ // Run the init function
+ if (!doInit()) {
+ return false;
+ }
+
+ // Start listening for controller input
+ inputThread = createInputThread();
+ inputThread.start();
+
+ return true;
+ }
+
+ public void stop() {
+ if (stopped) {
+ return;
+ }
+
+ stopped = true;
+
+ // Cancel any rumble effects
+ rumble((short)0, (short)0);
+
+ // Stop the input thread
+ if (inputThread != null) {
+ inputThread.interrupt();
+ inputThread = null;
+ }
+
+
+ // Release all interfaces
+ for (int i = 0; i < device.getInterfaceCount(); i++) {
+ UsbInterface iface = device.getInterface(i);
+
+ if (!connection.releaseInterface(iface)) {
+ LimeLog.warning("Failed to release interfaces");
+ }
+ }
+
+ // Close the USB connection
+ connection.close();
+
+ // Report the device removed
+ notifyDeviceRemoved();
+ }
+
+ protected abstract boolean handleRead(ByteBuffer buffer);
+ protected abstract boolean doInit();
+}
diff --git a/app/src/main/java/com/limelight/binding/input/driver/ProConController.java b/app/src/main/java/com/limelight/binding/input/driver/ProConController.java
new file mode 100644
index 0000000000..f46618153d
--- /dev/null
+++ b/app/src/main/java/com/limelight/binding/input/driver/ProConController.java
@@ -0,0 +1,485 @@
+package com.limelight.binding.input.driver;
+
+import android.hardware.usb.UsbConstants;
+import android.hardware.usb.UsbDevice;
+import android.hardware.usb.UsbDeviceConnection;
+import android.hardware.usb.UsbEndpoint;
+import android.hardware.usb.UsbInterface;
+import android.os.SystemClock;
+
+import com.limelight.LimeLog;
+import com.limelight.nvstream.input.ControllerPacket;
+import com.limelight.nvstream.jni.MoonBridge;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.Locale;
+
+public class ProConController extends AbstractController {
+
+ private static final int PACKET_SIZE = 64;
+ private static final byte[] RUMBLE_NEUTRAL = {0x00, 0x01, 0x40, 0x40};
+ private static final byte[] RUMBLE = {0x74, (byte) 0xBE, (byte) 0xBD, 0x6F};
+ private static final int FACTORY_IMU_CALIBRATION_OFFSET = 0x6020;
+ private static final int FACTORY_LS_CALIBRATION_OFFSET = 0x603D;
+ private static final int FACTORY_RS_CALIBRATION_OFFSET = 0x6046;
+ private static final int USER_IMU_MAGIC_OFFSET = 0x8026;
+ private static final int USER_IMU_CALIBRATION_OFFSET = 0x8028;
+ private static final int USER_LS_MAGIC_OFFSET = 0x8010;
+ private static final int USER_LS_CALIBRATION_OFFSET = 0x8012;
+ private static final int USER_RS_MAGIC_OFFSET = 0x801B;
+ private static final int USER_RS_CALIBRATION_OFFSET = 0x801D;
+ private static final int IMU_CALIBRATION_LENGTH = 24;
+ private static final int STICK_CALIBRATION_LENGTH = 9;
+ private static final int COMMAND_RETRIES = 10;
+
+ private final UsbDevice device;
+ private final UsbDeviceConnection connection;
+ private UsbEndpoint inEndpt, outEndpt;
+ private Thread inputThread;
+ private boolean stopped = false;
+ private byte sendPacketCount = 0;
+ private final int[][][] stickCalibration = new int[2][2][3]; // [stick][axis][min, center, max]
+ private final float[][][] stickExtends = new float[2][2][2]; // Pre-calculated scale for each axis
+
+ public static boolean canClaimDevice(UsbDevice device) {
+ return (device.getVendorId() == 0x057e && device.getProductId() == 0x2009);
+ }
+
+ public ProConController(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) {
+ super(deviceId, listener, device.getVendorId(), device.getProductId());
+ this.device = device;
+ this.connection = connection;
+ this.type = MoonBridge.LI_CTYPE_NINTENDO;
+ this.capabilities = MoonBridge.LI_CCAP_GYRO | MoonBridge.LI_CCAP_ACCEL | MoonBridge.LI_CCAP_RUMBLE;
+ }
+
+ private Thread createInputThread() {
+ return new Thread(() -> {
+ try {
+ Thread.sleep(1000);
+ } catch (InterruptedException e) {
+ return;
+ }
+
+ boolean handshakeSuccess = handshake();
+
+ if (!handshakeSuccess) {
+ LimeLog.info("ProCon: Initial handshake failed!");
+ ProConController.this.stop();
+ return;
+ }
+
+ LimeLog.info("ProCon: handshake " + handshakeSuccess);
+ LimeLog.info("ProCon: highspeed " + highSpeed());
+ LimeLog.info("ProCon: handshake " + handshake());
+ LimeLog.info("ProCon: loadstickcalibration " + loadStickCalibration());
+ LimeLog.info("ProCon: enablevibration " + enableVibration(true));
+ LimeLog.info("ProCon: setinutreportmode " + setInputReportMode((byte)0x30));
+ LimeLog.info("ProCon: forceusb " + forceUSB());
+ LimeLog.info("ProCon: setplayerled " + setPlayerLED(getControllerId() + 1));
+ LimeLog.info("ProCon: enableimu " + enableIMU(true));
+
+ LimeLog.info("ProCon: initialized!");
+
+ notifyDeviceAdded();
+
+ while (!Thread.currentThread().isInterrupted() && !stopped) {
+ byte[] buffer = new byte[64];
+ int res;
+ do {
+ long lastMillis = SystemClock.uptimeMillis();
+ res = connection.bulkTransfer(inEndpt, buffer, buffer.length, 1000);
+ if (res == 0) {
+ res = -1;
+ }
+ if (res == -1 && SystemClock.uptimeMillis() - lastMillis < 1000) {
+ LimeLog.warning("Detected device I/O error");
+ ProConController.this.stop();
+ break;
+ }
+ } while (res == -1 && !Thread.currentThread().isInterrupted() && !stopped);
+
+ if (res == -1 || stopped) {
+ break;
+ }
+
+ if (handleRead(ByteBuffer.wrap(buffer, 0, res).order(ByteOrder.LITTLE_ENDIAN))) {
+ reportInput();
+ reportMotion();
+ }
+ }
+ });
+ }
+
+ private boolean sendData(byte[] data, int size) {
+ return connection.bulkTransfer(outEndpt, data, size, 100) == size;
+ }
+
+ private boolean sendCommand(byte id, boolean waitReply) {
+ byte[] data = new byte[] {(byte)0x80, id};
+ for (int i = 0; i < COMMAND_RETRIES; i++) {
+ if (!sendData(data, data.length)) {
+ continue;
+ }
+ if (!waitReply) {
+ return true;
+ }
+
+ byte[] buffer = new byte[PACKET_SIZE];
+ int res;
+ int retries = 0;
+ do {
+ res = connection.bulkTransfer(inEndpt, buffer, buffer.length, 100);
+ if (res > 0 && (buffer[0] & 0xFF) == 0x81 && (buffer[1] & 0xFF) == id) {
+ return true;
+ }
+ retries += 1;
+ } while (retries < 20 && res > 0 && !Thread.currentThread().isInterrupted() && !stopped);
+ }
+
+ return false;
+ }
+
+ private boolean sendSubcommand(byte subcommand, byte[] payload, byte[] buffer) {
+ byte[] data = new byte[11 + payload.length];
+ data[0] = 0x01; // Rumble and subcommand
+ data[1] = sendPacketCount++; // Counter (increments per call)
+ if (sendPacketCount > 0xF) {
+ sendPacketCount = 0;
+ }
+
+ data[10] = subcommand;
+ System.arraycopy(payload, 0, data, 11, payload.length);
+
+ for (int i = 0; i < COMMAND_RETRIES; i++) {
+ if (!sendData(data, data.length)) {
+ continue;
+ }
+
+// LimeLog.warning("ProCon: Sent: " + toHexadecimal(data, data.length));
+
+ // Wait for response
+ int res;
+ int retries = 0;
+ do {
+ res = connection.bulkTransfer(inEndpt, buffer, buffer.length, 100);
+ if (res < 0 || buffer[0] != 0x21 || buffer[14] != subcommand) {
+ retries += 1;
+ } else {
+ return true;
+ }
+ } while (retries < 20 && res > 0 && !Thread.currentThread().isInterrupted() && !stopped);
+ LimeLog.warning("ProCon: Failed to get subcmd reply: " + res + " bytes received, " + String.format((Locale)null, "0x%02x, 0x%02x", buffer[0], buffer[14]));
+ return false;
+ }
+
+ return false;
+ }
+
+ private boolean handshake() {
+ return sendCommand((byte)0x02, true);
+ }
+
+ private boolean highSpeed() {
+ return sendCommand((byte)0x03, true);
+ }
+
+ private boolean forceUSB() {
+ return sendCommand((byte)0x04, true);
+ }
+
+ private boolean setInputReportMode(byte mode) {
+ final byte[] data = new byte[] {mode};
+ return sendSubcommand((byte) 0x03, data, new byte[PACKET_SIZE]);
+ }
+
+ private boolean setPlayerLED(int id) {
+ final byte[] data = new byte[] {(byte)(id & 0b1111)};
+ return sendSubcommand((byte)0x30, data, new byte[PACKET_SIZE]);
+ }
+
+ private boolean enableIMU(boolean enable) {
+ byte[] data = new byte[]{(byte)(enable ? 0x01 : 0x00)};
+ return sendSubcommand((byte)0x40, data, new byte[PACKET_SIZE]);
+ }
+
+ private boolean enableVibration(boolean enable) {
+ byte[] data = new byte[]{(byte)(enable ? 0x01 : 0x00)};
+ return sendSubcommand((byte)0x48, data, new byte[PACKET_SIZE]);
+ }
+
+ public boolean start() {
+ for (int i = 0; i < device.getInterfaceCount(); i++) {
+ UsbInterface iface = device.getInterface(i);
+ if (!connection.claimInterface(iface, true)) {
+ LimeLog.warning("Failed to claim interfaces");
+ return false;
+ }
+ }
+
+ UsbInterface iface = device.getInterface(0);
+ for (int i = 0; i < iface.getEndpointCount(); i++) {
+ UsbEndpoint endpt = iface.getEndpoint(i);
+ if (endpt.getDirection() == UsbConstants.USB_DIR_IN) {
+ inEndpt = endpt;
+ } else if (endpt.getDirection() == UsbConstants.USB_DIR_OUT) {
+ outEndpt = endpt;
+ }
+ }
+
+ if (inEndpt == null || outEndpt == null) {
+ LimeLog.warning("Missing required endpoint");
+ return false;
+ }
+
+ inputThread = createInputThread();
+ inputThread.start();
+
+ return true;
+ }
+
+ public void stop() {
+ if (stopped) {
+ return;
+ }
+ stopped = true;
+ rumble((short) 0, (short) 0);
+ if (inputThread != null) {
+ inputThread.interrupt();
+ inputThread = null;
+ }
+
+// for (int i = 0; i < device.getInterfaceCount(); i++) {
+// UsbInterface iface = device.getInterface(i);
+// connection.releaseInterface(iface);
+// }
+ connection.close();
+ notifyDeviceRemoved();
+ }
+
+
+ @Override
+ public void rumble(short lowFreqMotor, short highFreqMotor) {
+ byte[] data = new byte[10];
+ data[0] = 0x10; // Rumble command
+ data[1] = sendPacketCount++; // Counter (increments per call)
+ if (sendPacketCount > 0xF) {
+ sendPacketCount = 0;
+ }
+
+ if (lowFreqMotor != 0) {
+ data[4] = data[8] = (byte)(0x50 - (lowFreqMotor & 0xFFFF >> 12));
+ data[5] = data[9] = (byte)((((lowFreqMotor & 0xFFFF) >> 8) / 5) + 0x40);
+ }
+ if (highFreqMotor != 0) {
+ data[6] = (byte)((0x70 - ((highFreqMotor & 0xFFFF) >> 10) & -0x04));
+ data[7] = (byte)(((highFreqMotor & 0xFFFF) >> 8) * 0xC8 / 0xFF);
+ }
+
+ data[2] |= 0x00;
+ data[3] |= 0x01;
+ data[5] |= 0x40;
+ data[6] |= 0x00;
+ data[7] |= 0x01;
+ data[9] |= 0x40;
+
+ sendData(data, data.length);
+ }
+
+ @Override
+ public void rumbleTriggers(short leftTrigger, short rightTrigger) {
+ // ProCon does not support trigger-specific rumble
+ }
+
+ protected boolean handleRead(ByteBuffer buffer) {
+ if (buffer.remaining() < PACKET_SIZE) {
+ return false;
+ }
+
+ if (buffer.get(0) != 0x30) {
+ return false;
+ }
+
+ buttonFlags = 0;
+ // Nintendo layout is swapped
+ setButtonFlag(ControllerPacket.B_FLAG, buffer.get(3) & 0x08);
+ setButtonFlag(ControllerPacket.A_FLAG, buffer.get(3) & 0x04);
+ setButtonFlag(ControllerPacket.Y_FLAG, buffer.get(3) & 0x02);
+ setButtonFlag(ControllerPacket.X_FLAG, buffer.get(3) & 0x01);
+ setButtonFlag(ControllerPacket.UP_FLAG, buffer.get(5) & 0x02);
+ setButtonFlag(ControllerPacket.DOWN_FLAG, buffer.get(5) & 0x01);
+ setButtonFlag(ControllerPacket.LEFT_FLAG, buffer.get(5) & 0x08);
+ setButtonFlag(ControllerPacket.RIGHT_FLAG, buffer.get(5) & 0x04);
+ setButtonFlag(ControllerPacket.BACK_FLAG, buffer.get(4) & 0x01);
+ setButtonFlag(ControllerPacket.PLAY_FLAG, buffer.get(4) & 0x02);
+ setButtonFlag(ControllerPacket.MISC_FLAG, buffer.get(4) & 0x20); // Screenshot
+ setButtonFlag(ControllerPacket.SPECIAL_BUTTON_FLAG, buffer.get(4) & 0x10); // Home
+ setButtonFlag(ControllerPacket.LB_FLAG, buffer.get(5) & 0x40);
+ setButtonFlag(ControllerPacket.RB_FLAG, buffer.get(3) & 0x40);
+ setButtonFlag(ControllerPacket.LS_CLK_FLAG, buffer.get(4) & 0x08);
+ setButtonFlag(ControllerPacket.RS_CLK_FLAG, buffer.get(4) & 0x04);
+
+ leftTrigger = ((buffer.get(5) & 0x80) != 0) ? 1 : 0;
+ rightTrigger = ((buffer.get(3) & 0x80) != 0) ? 1 : 0;
+
+ int _leftStickX = buffer.get(6) & 0xFF | ((buffer.get(7) & 0x0F) << 8);
+ int _leftStickY = ((buffer.get(7) & 0xF0) >> 4) | (buffer.get(8) << 4);
+ int _rightStickX = buffer.get(9) & 0xFF | ((buffer.get(10) & 0x0F) << 8);
+ int _rightStickY = ((buffer.get(10) & 0xF0) >> 4) | (buffer.get(11) << 4);
+
+ leftStickX = applyStickCalibration(_leftStickX, 0, 0);
+ leftStickY = applyStickCalibration(-_leftStickY - 1, 0, 1);
+ rightStickX = applyStickCalibration(_rightStickX, 1, 0);
+ rightStickY = applyStickCalibration(-_rightStickY - 1, 1, 1);
+
+ accelX = buffer.getShort(37) / 4096.0f;
+ accelY = buffer.getShort(39) / 4096.0f;
+ accelZ = buffer.getShort(41) / 4096.0f;
+ gyroZ = -buffer.getShort(43) / 16.0f;
+ gyroX = -buffer.getShort(45) / 16.0f;
+ gyroY = buffer.getShort(47) / 16.0f;
+
+ return true;
+ }
+
+ private boolean spiFlashRead(int offset, int length, byte[] buffer) {
+ // SPI Read Address (Little Endian)
+ byte[] address = {
+ (byte) (offset & 0xFF),
+ (byte) ((offset >> 8) & 0xFF),
+ (byte) ((offset >> 16) & 0xFF),
+ (byte) ((offset >> 24) & 0xFF),
+ (byte) length
+ };
+
+ if (!sendSubcommand((byte) 0x10, address, buffer)) {
+ LimeLog.warning("ProCon: Failed to receive SPI Flash data.");
+ return false;
+ }
+
+ return true;
+ }
+
+ private boolean checkUserCalMagic(int offset) {
+ byte[] buffer = new byte[PACKET_SIZE];
+
+ if (!spiFlashRead(offset, 2, buffer)) {
+ return false;
+ }
+
+ return ((buffer[20] & 0xFF) == 0xB2) && ((buffer[21] & 0xFF) == 0xA1);
+ }
+
+ private boolean loadStickCalibration() {
+ byte[] buffer = new byte[PACKET_SIZE];
+
+ int ls_addr = FACTORY_LS_CALIBRATION_OFFSET;
+ int rs_addr = FACTORY_RS_CALIBRATION_OFFSET;
+
+ if (checkUserCalMagic(USER_LS_MAGIC_OFFSET)) {
+ ls_addr = USER_LS_CALIBRATION_OFFSET;
+ LimeLog.info("ProCon: LS has user calibration!");
+ }
+ if (checkUserCalMagic(USER_RS_MAGIC_OFFSET)) {
+ rs_addr = USER_RS_CALIBRATION_OFFSET;
+ LimeLog.info("ProCon: RS has user calibration!");
+ }
+
+ boolean ls_calibrated = false;
+ if (spiFlashRead(ls_addr, STICK_CALIBRATION_LENGTH, buffer)) {
+ // read offset 20
+ int x_max = (buffer[20] & 0xFF) | ((buffer[21] & 0x0F) << 8);
+ int y_max = ((buffer[21] & 0xF0) >> 4) | ((buffer[22] & 0xFF) << 4);
+ int x_center = (buffer[23] & 0xFF) | ((buffer[24] & 0x0F) << 8);
+ int y_center = ((buffer[24] & 0xF0) >> 4) | ((buffer[25] & 0xFF) << 4);
+ int x_min = (buffer[26] & 0xFF) | ((buffer[27] & 0x0F) << 8);
+ int y_min = ((buffer[27] & 0xF0) >> 4) | ((buffer[28] & 0xFF) << 4);
+ stickCalibration[0][0][0] = x_center - x_min; // Min
+ stickCalibration[0][0][1] = x_center; // Center
+ stickCalibration[0][0][2] = x_center + x_max; // Max
+ stickCalibration[0][1][0] = 0x1000 - y_center - y_max; // Min
+ stickCalibration[0][1][1] = 0x1000 - y_center; // Center
+ stickCalibration[0][1][2] = 0x1000 - y_center + y_min; // Max
+ stickExtends[0][0][0] = (float) ((x_center - stickCalibration[0][0][0]) * -0.7);
+ stickExtends[0][0][1] = (float) ((stickCalibration[0][0][2] - x_center) * 0.7);
+ stickExtends[0][1][0] = (float) ((y_center - stickCalibration[0][1][0]) * -0.7);
+ stickExtends[0][1][1] = (float) ((stickCalibration[0][1][2] - y_center) * 0.7);
+
+ ls_calibrated = true;
+ }
+
+ if (!ls_calibrated) {
+ applyDefaultCalibration(0);
+ }
+
+ boolean rs_calibrated = false;
+ if (spiFlashRead(rs_addr, STICK_CALIBRATION_LENGTH, buffer)) {
+ // read offset 20
+ int x_center = (buffer[20] & 0xFF) | ((buffer[21] & 0x0F) << 8);
+ int y_center = ((buffer[21] & 0xF0) >> 4) | ((buffer[22] & 0xFF) << 4);
+ int x_min = (buffer[23] & 0xFF) | ((buffer[24] & 0x0F) << 8);
+ int y_min = ((buffer[24] & 0xF0) >> 4) | ((buffer[25] & 0xFF) << 4);
+ int x_max = (buffer[26] & 0xFF) | ((buffer[27] & 0x0F) << 8);
+ int y_max = ((buffer[27] & 0xF0) >> 4) | ((buffer[28] & 0xFF) << 4);
+ stickCalibration[1][0][0] = x_center - x_min; // Min
+ stickCalibration[1][0][1] = x_center; // Center
+ stickCalibration[1][0][2] = x_center + x_max; // Max
+ stickCalibration[1][1][0] = 0x1000 - y_center - y_max; // Min
+ stickCalibration[1][1][1] = 0x1000 - y_center; // Center
+ stickCalibration[1][1][2] = 0x1000 - y_center + y_min; // Max
+ stickExtends[1][0][0] = (float) ((x_center - stickCalibration[1][0][0]) * -0.7);
+ stickExtends[1][0][1] = (float) ((stickCalibration[1][0][2] - x_center) * 0.7);
+ stickExtends[1][1][0] = (float) ((y_center - stickCalibration[1][1][0]) * -0.7);
+ stickExtends[1][1][1] = (float) ((stickCalibration[1][1][2] - y_center) * 0.7);
+
+ rs_calibrated = true;
+ }
+
+ if (!rs_calibrated) {
+ applyDefaultCalibration(1);
+ }
+
+// LimeLog.info(String.format("ProCon: LS X: %04x, %04x, %04x", stickCalibration[0][0][0], stickCalibration[0][0][1], stickCalibration[0][0][2]));
+// LimeLog.info(String.format("ProCon: LS Y: %04x, %04x, %04x", stickCalibration[0][1][0], stickCalibration[0][1][1], stickCalibration[0][1][2]));
+// LimeLog.info(String.format("ProCon: RS X: %04x, %04x, %04x", stickCalibration[1][0][0], stickCalibration[1][0][1], stickCalibration[1][0][2]));
+// LimeLog.info(String.format("ProCon: RS Y: %04x, %04x, %04x", stickCalibration[1][1][0], stickCalibration[1][1][1], stickCalibration[1][1][2]));
+
+ return true;
+ }
+
+ private void applyDefaultCalibration(int stick) {
+ for (int axis = 0; axis < 2; axis++) {
+ stickCalibration[stick][axis][0] = 0x000; // Min
+ stickCalibration[stick][axis][1] = 0x800; // Center
+ stickCalibration[stick][axis][2] = 0xFFF; // Max
+
+ stickExtends[stick][axis][0] = -0x700;
+ stickExtends[stick][axis][1] = 0x700;
+ }
+ }
+
+ private float applyStickCalibration(int value, int stick, int axis) {
+ int center = stickCalibration[stick][axis][1];
+
+ if (value < 0) {
+ value += 0x1000;
+ }
+
+ value -= center;
+
+ if (value < stickExtends[stick][axis][0]) {
+ stickExtends[stick][axis][0] = value;
+ return -1;
+ } else if (value > stickExtends[stick][axis][1]) {
+ stickExtends[stick][axis][1] = value;
+ return 1;
+ }
+
+ if (value > 0) {
+ return value / stickExtends[stick][axis][1];
+ } else {
+ return -value / stickExtends[stick][axis][0];
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/driver/UsbDriverListener.java b/app/src/main/java/com/limelight/binding/input/driver/UsbDriverListener.java
old mode 100644
new mode 100755
index c812122299..483d5cdfa9
--- a/app/src/main/java/com/limelight/binding/input/driver/UsbDriverListener.java
+++ b/app/src/main/java/com/limelight/binding/input/driver/UsbDriverListener.java
@@ -1,11 +1,12 @@
-package com.limelight.binding.input.driver;
-
-public interface UsbDriverListener {
- void reportControllerState(int controllerId, int buttonFlags,
- float leftStickX, float leftStickY,
- float rightStickX, float rightStickY,
- float leftTrigger, float rightTrigger);
-
- void deviceRemoved(AbstractController controller);
- void deviceAdded(AbstractController controller);
-}
+package com.limelight.binding.input.driver;
+
+public interface UsbDriverListener {
+ void reportControllerState(int controllerId, int buttonFlags,
+ float leftStickX, float leftStickY,
+ float rightStickX, float rightStickY,
+ float leftTrigger, float rightTrigger);
+ void reportControllerMotion(int controllerId, byte motionType, float motionX, float motionY, float motionZ);
+
+ void deviceRemoved(AbstractController controller);
+ void deviceAdded(AbstractController controller);
+}
diff --git a/app/src/main/java/com/limelight/binding/input/driver/UsbDriverService.java b/app/src/main/java/com/limelight/binding/input/driver/UsbDriverService.java
old mode 100644
new mode 100755
index f81221a548..49792b4dc6
--- a/app/src/main/java/com/limelight/binding/input/driver/UsbDriverService.java
+++ b/app/src/main/java/com/limelight/binding/input/driver/UsbDriverService.java
@@ -1,353 +1,366 @@
-package com.limelight.binding.input.driver;
-
-import android.annotation.SuppressLint;
-import android.app.PendingIntent;
-import android.app.Service;
-import android.content.BroadcastReceiver;
-import android.content.Context;
-import android.content.Intent;
-import android.content.IntentFilter;
-import android.hardware.usb.UsbDevice;
-import android.hardware.usb.UsbDeviceConnection;
-import android.hardware.usb.UsbManager;
-import android.os.Binder;
-import android.os.Build;
-import android.os.Handler;
-import android.os.IBinder;
-import android.view.InputDevice;
-import android.widget.Toast;
-
-import com.limelight.LimeLog;
-import com.limelight.R;
-import com.limelight.preferences.PreferenceConfiguration;
-
-import java.io.File;
-import java.util.ArrayList;
-
-public class UsbDriverService extends Service implements UsbDriverListener {
-
- private static final String ACTION_USB_PERMISSION =
- "com.limelight.USB_PERMISSION";
-
- private UsbManager usbManager;
- private PreferenceConfiguration prefConfig;
- private boolean started;
-
- private final UsbEventReceiver receiver = new UsbEventReceiver();
- private final UsbDriverBinder binder = new UsbDriverBinder();
-
- private final ArrayList controllers = new ArrayList<>();
-
- private UsbDriverListener listener;
- private UsbDriverStateListener stateListener;
- private int nextDeviceId;
-
- @Override
- public void reportControllerState(int controllerId, int buttonFlags, float leftStickX, float leftStickY,
- float rightStickX, float rightStickY, float leftTrigger, float rightTrigger) {
- // Call through to the client's listener
- if (listener != null) {
- listener.reportControllerState(controllerId, buttonFlags, leftStickX, leftStickY, rightStickX, rightStickY, leftTrigger, rightTrigger);
- }
- }
-
- @Override
- public void deviceRemoved(AbstractController controller) {
- // Remove the the controller from our list (if not removed already)
- controllers.remove(controller);
-
- // Call through to the client's listener
- if (listener != null) {
- listener.deviceRemoved(controller);
- }
- }
-
- @Override
- public void deviceAdded(AbstractController controller) {
- // Call through to the client's listener
- if (listener != null) {
- listener.deviceAdded(controller);
- }
- }
-
- public class UsbEventReceiver extends BroadcastReceiver {
- @Override
- public void onReceive(Context context, Intent intent) {
- String action = intent.getAction();
-
- // Initial attachment broadcast
- if (action.equals(UsbManager.ACTION_USB_DEVICE_ATTACHED)) {
- final UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
-
- // shouldClaimDevice() looks at the kernel's enumerated input
- // devices to make its decision about whether to prompt to take
- // control of the device. The kernel bringing up the input stack
- // may race with this callback and cause us to prompt when the
- // kernel is capable of running the device. Let's post a delayed
- // message to process this state change to allow the kernel
- // some time to bring up the stack.
- new Handler().postDelayed(new Runnable() {
- @Override
- public void run() {
- // Continue the state machine
- handleUsbDeviceState(device);
- }
- }, 1000);
- }
- // Subsequent permission dialog completion intent
- else if (action.equals(ACTION_USB_PERMISSION)) {
- UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
-
- // Permission dialog is now closed
- if (stateListener != null) {
- stateListener.onUsbPermissionPromptCompleted();
- }
-
- // If we got this far, we've already found we're able to handle this device
- if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
- handleUsbDeviceState(device);
- }
- }
- }
- }
-
- public class UsbDriverBinder extends Binder {
- public void setListener(UsbDriverListener listener) {
- UsbDriverService.this.listener = listener;
-
- // Report all controllerMap that already exist
- if (listener != null) {
- for (AbstractController controller : controllers) {
- listener.deviceAdded(controller);
- }
- }
- }
-
- public void setStateListener(UsbDriverStateListener stateListener) {
- UsbDriverService.this.stateListener = stateListener;
- }
-
- public void start() {
- UsbDriverService.this.start();
- }
-
- public void stop() {
- UsbDriverService.this.stop();
- }
- }
-
- private void handleUsbDeviceState(UsbDevice device) {
- // Are we able to operate it?
- if (shouldClaimDevice(device, prefConfig.bindAllUsb)) {
- // Do we have permission yet?
- if (!usbManager.hasPermission(device)) {
- // Let's ask for permission
- try {
- // Tell the state listener that we're about to display a permission dialog
- if (stateListener != null) {
- stateListener.onUsbPermissionPromptStarting();
- }
-
- int intentFlags = 0;
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- // This PendingIntent must be mutable to allow the framework to populate EXTRA_DEVICE and EXTRA_PERMISSION_GRANTED.
- intentFlags |= PendingIntent.FLAG_MUTABLE;
- }
-
- // This function is not documented as throwing any exceptions (denying access
- // is indicated by calling the PendingIntent with a false result). However,
- // Samsung Knox has some policies which block this request, but rather than
- // just returning a false result or returning 0 enumerated devices,
- // they throw an undocumented SecurityException from this call, crashing
- // the whole app. :(
-
- // Use an explicit intent to activate our unexported broadcast receiver, as required on Android 14+
- Intent i = new Intent(ACTION_USB_PERMISSION);
- i.setPackage(getPackageName());
-
- usbManager.requestPermission(device, PendingIntent.getBroadcast(UsbDriverService.this, 0, i, intentFlags));
- } catch (SecurityException e) {
- Toast.makeText(this, this.getText(R.string.error_usb_prohibited), Toast.LENGTH_LONG).show();
- if (stateListener != null) {
- stateListener.onUsbPermissionPromptCompleted();
- }
- }
- return;
- }
-
- // Open the device
- UsbDeviceConnection connection = usbManager.openDevice(device);
- if (connection == null) {
- LimeLog.warning("Unable to open USB device: "+device.getDeviceName());
- return;
- }
-
-
- AbstractController controller;
-
- if (XboxOneController.canClaimDevice(device)) {
- controller = new XboxOneController(device, connection, nextDeviceId++, this);
- }
- else if (Xbox360Controller.canClaimDevice(device)) {
- controller = new Xbox360Controller(device, connection, nextDeviceId++, this);
- }
- else if (Xbox360WirelessDongle.canClaimDevice(device)) {
- controller = new Xbox360WirelessDongle(device, connection, nextDeviceId++, this);
- }
- else {
- // Unreachable
- return;
- }
-
- // Start the controller
- if (!controller.start()) {
- connection.close();
- return;
- }
-
- // Add this controller to the list
- controllers.add(controller);
- }
- }
-
- public static boolean isRecognizedInputDevice(UsbDevice device) {
- // Determine if this VID and PID combo matches an existing input device
- // and defer to the built-in controller support in that case.
- for (int id : InputDevice.getDeviceIds()) {
- InputDevice inputDev = InputDevice.getDevice(id);
- if (inputDev == null) {
- // Device was removed while looping
- continue;
- }
-
- if (inputDev.getVendorId() == device.getVendorId() &&
- inputDev.getProductId() == device.getProductId()) {
- return true;
- }
- }
-
- return false;
- }
-
- public static boolean kernelSupportsXboxOne() {
- String kernelVersion = System.getProperty("os.version");
- LimeLog.info("Kernel Version: "+kernelVersion);
-
- if (kernelVersion == null) {
- // We'll assume this is some newer version of Android
- // that doesn't let you read the kernel version this way.
- return true;
- }
- else if (kernelVersion.startsWith("2.") || kernelVersion.startsWith("3.")) {
- // These are old kernels that definitely don't support Xbox One controllers properly
- return false;
- }
- else if (kernelVersion.startsWith("4.4.") || kernelVersion.startsWith("4.9.")) {
- // These aren't guaranteed to have backported kernel patches for proper Xbox One
- // support (though some devices will).
- return false;
- }
- else {
- // The next AOSP common kernel is 4.14 which has working Xbox One controller support
- return true;
- }
- }
-
- public static boolean kernelSupportsXbox360W() {
- // Check if this kernel is 4.2+ to see if the xpad driver sets Xbox 360 wireless LEDs
- // https://github.com/torvalds/linux/commit/75b7f05d2798ee3a1cc5bbdd54acd0e318a80396
- String kernelVersion = System.getProperty("os.version");
- if (kernelVersion != null) {
- if (kernelVersion.startsWith("2.") || kernelVersion.startsWith("3.") ||
- kernelVersion.startsWith("4.0.") || kernelVersion.startsWith("4.1.")) {
- // Even if LED devices are present, the driver won't set the initial LED state.
- return false;
- }
- }
-
- // We know we have a kernel that should set Xbox 360 wireless LEDs, but we still don't
- // know if CONFIG_JOYSTICK_XPAD_LEDS was enabled during the kernel build. Unfortunately
- // it's not possible to detect this reliably due to Android's app sandboxing. Reading
- // /proc/config.gz and enumerating /sys/class/leds are both blocked by SELinux on any
- // relatively modern device. We will assume that CONFIG_JOYSTICK_XPAD_LEDS=y on these
- // kernels and users can override by using the settings option to claim all devices.
- return true;
- }
-
- public static boolean shouldClaimDevice(UsbDevice device, boolean claimAllAvailable) {
- return ((!kernelSupportsXboxOne() || !isRecognizedInputDevice(device) || claimAllAvailable) && XboxOneController.canClaimDevice(device)) ||
- ((!isRecognizedInputDevice(device) || claimAllAvailable) && Xbox360Controller.canClaimDevice(device)) ||
- // We must not call isRecognizedInputDevice() because wireless controllers don't share the same product ID as the dongle
- ((!kernelSupportsXbox360W() || claimAllAvailable) && Xbox360WirelessDongle.canClaimDevice(device));
- }
-
- @SuppressLint("UnspecifiedRegisterReceiverFlag")
- private void start() {
- if (started || usbManager == null) {
- return;
- }
-
- started = true;
-
- // Register for USB attach broadcasts and permission completions
- IntentFilter filter = new IntentFilter();
- filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED);
- filter.addAction(ACTION_USB_PERMISSION);
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- registerReceiver(receiver, filter, RECEIVER_NOT_EXPORTED);
- }
- else {
- registerReceiver(receiver, filter);
- }
-
- // Enumerate existing devices
- for (UsbDevice dev : usbManager.getDeviceList().values()) {
- if (shouldClaimDevice(dev, prefConfig.bindAllUsb)) {
- // Start the process of claiming this device
- handleUsbDeviceState(dev);
- }
- }
- }
-
- private void stop() {
- if (!started) {
- return;
- }
-
- started = false;
-
- // Stop the attachment receiver
- unregisterReceiver(receiver);
-
- // Stop all controllers
- while (controllers.size() > 0) {
- // Stop and remove the controller
- controllers.remove(0).stop();
- }
- }
-
- @Override
- public void onCreate() {
- this.usbManager = (UsbManager) getSystemService(Context.USB_SERVICE);
- this.prefConfig = PreferenceConfiguration.readPreferences(this);
- }
-
- @Override
- public void onDestroy() {
- stop();
-
- // Remove listeners
- listener = null;
- stateListener = null;
- }
-
- @Override
- public IBinder onBind(Intent intent) {
- return binder;
- }
-
- public interface UsbDriverStateListener {
- void onUsbPermissionPromptStarting();
- void onUsbPermissionPromptCompleted();
- }
-}
+package com.limelight.binding.input.driver;
+
+import android.annotation.SuppressLint;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.hardware.usb.UsbDevice;
+import android.hardware.usb.UsbDeviceConnection;
+import android.hardware.usb.UsbManager;
+import android.os.Binder;
+import android.os.Build;
+import android.os.Handler;
+import android.os.IBinder;
+import android.view.InputDevice;
+import android.widget.Toast;
+
+import com.limelight.LimeLog;
+import com.limelight.R;
+import com.limelight.preferences.PreferenceConfiguration;
+
+import java.io.File;
+import java.util.ArrayList;
+
+public class UsbDriverService extends Service implements UsbDriverListener {
+
+ private static final String ACTION_USB_PERMISSION =
+ "com.limelight.USB_PERMISSION";
+
+ private UsbManager usbManager;
+ private PreferenceConfiguration prefConfig;
+ private boolean started;
+
+ private final UsbEventReceiver receiver = new UsbEventReceiver();
+ private final UsbDriverBinder binder = new UsbDriverBinder();
+
+ private final ArrayList controllers = new ArrayList<>();
+
+ private UsbDriverListener listener;
+ private UsbDriverStateListener stateListener;
+ private int nextDeviceId;
+
+ @Override
+ public void reportControllerState(int controllerId, int buttonFlags, float leftStickX, float leftStickY,
+ float rightStickX, float rightStickY, float leftTrigger, float rightTrigger) {
+ // Call through to the client's listener
+ if (listener != null) {
+ listener.reportControllerState(controllerId, buttonFlags, leftStickX, leftStickY, rightStickX, rightStickY, leftTrigger, rightTrigger);
+ }
+ }
+
+ @Override
+ public void reportControllerMotion(int controllerId, byte motionType, float motionX, float motionY, float motionZ) {
+ // Call through to the client's listener
+ if (listener != null) {
+ listener.reportControllerMotion(controllerId, motionType, motionX, motionY, motionZ);
+ }
+ }
+
+ @Override
+ public void deviceRemoved(AbstractController controller) {
+ // Remove the the controller from our list (if not removed already)
+ controllers.remove(controller);
+
+ // Call through to the client's listener
+ if (listener != null) {
+ listener.deviceRemoved(controller);
+ }
+ }
+
+ @Override
+ public void deviceAdded(AbstractController controller) {
+ // Call through to the client's listener
+ if (listener != null) {
+ listener.deviceAdded(controller);
+ }
+ }
+
+ public class UsbEventReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+
+ // Initial attachment broadcast
+ if (action.equals(UsbManager.ACTION_USB_DEVICE_ATTACHED)) {
+ final UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
+
+ // shouldClaimDevice() looks at the kernel's enumerated input
+ // devices to make its decision about whether to prompt to take
+ // control of the device. The kernel bringing up the input stack
+ // may race with this callback and cause us to prompt when the
+ // kernel is capable of running the device. Let's post a delayed
+ // message to process this state change to allow the kernel
+ // some time to bring up the stack.
+ new Handler().postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ // Continue the state machine
+ handleUsbDeviceState(device);
+ }
+ }, 1000);
+ }
+ // Subsequent permission dialog completion intent
+ else if (action.equals(ACTION_USB_PERMISSION)) {
+ UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
+
+ // Permission dialog is now closed
+ if (stateListener != null) {
+ stateListener.onUsbPermissionPromptCompleted();
+ }
+
+ // If we got this far, we've already found we're able to handle this device
+ if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
+ handleUsbDeviceState(device);
+ }
+ }
+ }
+ }
+
+ public class UsbDriverBinder extends Binder {
+ public void setListener(UsbDriverListener listener) {
+ UsbDriverService.this.listener = listener;
+
+ // Report all controllerMap that already exist
+ if (listener != null) {
+ for (AbstractController controller : controllers) {
+ listener.deviceAdded(controller);
+ }
+ }
+ }
+
+ public void setStateListener(UsbDriverStateListener stateListener) {
+ UsbDriverService.this.stateListener = stateListener;
+ }
+
+ public void start() {
+ UsbDriverService.this.start();
+ }
+
+ public void stop() {
+ UsbDriverService.this.stop();
+ }
+ }
+
+ private void handleUsbDeviceState(UsbDevice device) {
+ // Are we able to operate it?
+ if (shouldClaimDevice(device, prefConfig.bindAllUsb)) {
+ // Do we have permission yet?
+ if (!usbManager.hasPermission(device)) {
+ // Let's ask for permission
+ try {
+ // Tell the state listener that we're about to display a permission dialog
+ if (stateListener != null) {
+ stateListener.onUsbPermissionPromptStarting();
+ }
+
+ int intentFlags = 0;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ // This PendingIntent must be mutable to allow the framework to populate EXTRA_DEVICE and EXTRA_PERMISSION_GRANTED.
+ intentFlags |= PendingIntent.FLAG_MUTABLE;
+ }
+
+ // This function is not documented as throwing any exceptions (denying access
+ // is indicated by calling the PendingIntent with a false result). However,
+ // Samsung Knox has some policies which block this request, but rather than
+ // just returning a false result or returning 0 enumerated devices,
+ // they throw an undocumented SecurityException from this call, crashing
+ // the whole app. :(
+
+ // Use an explicit intent to activate our unexported broadcast receiver, as required on Android 14+
+ Intent i = new Intent(ACTION_USB_PERMISSION);
+ i.setPackage(getPackageName());
+
+ usbManager.requestPermission(device, PendingIntent.getBroadcast(UsbDriverService.this, 0, i, intentFlags));
+ } catch (SecurityException e) {
+ Toast.makeText(this, this.getText(R.string.error_usb_prohibited), Toast.LENGTH_LONG).show();
+ if (stateListener != null) {
+ stateListener.onUsbPermissionPromptCompleted();
+ }
+ }
+ return;
+ }
+
+ // Open the device
+ UsbDeviceConnection connection = usbManager.openDevice(device);
+ if (connection == null) {
+ LimeLog.warning("Unable to open USB device: "+device.getDeviceName());
+ return;
+ }
+
+
+ AbstractController controller;
+
+ if (XboxOneController.canClaimDevice(device)) {
+ controller = new XboxOneController(device, connection, nextDeviceId++, this);
+ }
+ else if (Xbox360Controller.canClaimDevice(device)) {
+ controller = new Xbox360Controller(device, connection, nextDeviceId++, this);
+ }
+ else if (Xbox360WirelessDongle.canClaimDevice(device)) {
+ controller = new Xbox360WirelessDongle(device, connection, nextDeviceId++, this);
+ }
+ else if (ProConController.canClaimDevice(device)) {
+ controller = new ProConController(device, connection, nextDeviceId++, this);
+ }
+ else {
+ // Unreachable
+ return;
+ }
+
+ // Start the controller
+ if (!controller.start()) {
+ connection.close();
+ return;
+ }
+
+ // Add this controller to the list
+ controllers.add(controller);
+ }
+ }
+
+ public static boolean isRecognizedInputDevice(UsbDevice device) {
+ // Determine if this VID and PID combo matches an existing input device
+ // and defer to the built-in controller support in that case.
+ for (int id : InputDevice.getDeviceIds()) {
+ InputDevice inputDev = InputDevice.getDevice(id);
+ if (inputDev == null) {
+ // Device was removed while looping
+ continue;
+ }
+
+ if (inputDev.getVendorId() == device.getVendorId() &&
+ inputDev.getProductId() == device.getProductId()) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public static boolean kernelSupportsXboxOne() {
+ String kernelVersion = System.getProperty("os.version");
+ LimeLog.info("Kernel Version: "+kernelVersion);
+
+ if (kernelVersion == null) {
+ // We'll assume this is some newer version of Android
+ // that doesn't let you read the kernel version this way.
+ return true;
+ }
+ else if (kernelVersion.startsWith("2.") || kernelVersion.startsWith("3.")) {
+ // These are old kernels that definitely don't support Xbox One controllers properly
+ return false;
+ }
+ else if (kernelVersion.startsWith("4.4.") || kernelVersion.startsWith("4.9.")) {
+ // These aren't guaranteed to have backported kernel patches for proper Xbox One
+ // support (though some devices will).
+ return false;
+ }
+ else {
+ // The next AOSP common kernel is 4.14 which has working Xbox One controller support
+ return true;
+ }
+ }
+
+ public static boolean kernelSupportsXbox360W() {
+ // Check if this kernel is 4.2+ to see if the xpad driver sets Xbox 360 wireless LEDs
+ // https://github.com/torvalds/linux/commit/75b7f05d2798ee3a1cc5bbdd54acd0e318a80396
+ String kernelVersion = System.getProperty("os.version");
+ if (kernelVersion != null) {
+ if (kernelVersion.startsWith("2.") || kernelVersion.startsWith("3.") ||
+ kernelVersion.startsWith("4.0.") || kernelVersion.startsWith("4.1.")) {
+ // Even if LED devices are present, the driver won't set the initial LED state.
+ return false;
+ }
+ }
+
+ // We know we have a kernel that should set Xbox 360 wireless LEDs, but we still don't
+ // know if CONFIG_JOYSTICK_XPAD_LEDS was enabled during the kernel build. Unfortunately
+ // it's not possible to detect this reliably due to Android's app sandboxing. Reading
+ // /proc/config.gz and enumerating /sys/class/leds are both blocked by SELinux on any
+ // relatively modern device. We will assume that CONFIG_JOYSTICK_XPAD_LEDS=y on these
+ // kernels and users can override by using the settings option to claim all devices.
+ return true;
+ }
+
+ public static boolean shouldClaimDevice(UsbDevice device, boolean claimAllAvailable) {
+ LimeLog.info("UsbDevice info: "+device.toString());
+ return ((!kernelSupportsXboxOne() || !isRecognizedInputDevice(device) || claimAllAvailable) && XboxOneController.canClaimDevice(device)) ||
+ ((!isRecognizedInputDevice(device) || claimAllAvailable) && Xbox360Controller.canClaimDevice(device)) ||
+ // We must not call isRecognizedInputDevice() because wireless controllers don't share the same product ID as the dongle
+ ((!kernelSupportsXbox360W() || claimAllAvailable) && Xbox360WirelessDongle.canClaimDevice(device)) ||
+ ((!isRecognizedInputDevice(device) || claimAllAvailable) && ProConController.canClaimDevice(device));
+ }
+
+ @SuppressLint("UnspecifiedRegisterReceiverFlag")
+ private void start() {
+ if (started || usbManager == null) {
+ return;
+ }
+
+ started = true;
+
+ // Register for USB attach broadcasts and permission completions
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED);
+ filter.addAction(ACTION_USB_PERMISSION);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ registerReceiver(receiver, filter, RECEIVER_NOT_EXPORTED);
+ }
+ else {
+ registerReceiver(receiver, filter);
+ }
+
+ // Enumerate existing devices
+ for (UsbDevice dev : usbManager.getDeviceList().values()) {
+ if (shouldClaimDevice(dev, prefConfig.bindAllUsb)) {
+ // Start the process of claiming this device
+ handleUsbDeviceState(dev);
+ }
+ }
+ }
+
+ private void stop() {
+ if (!started) {
+ return;
+ }
+
+ started = false;
+
+ // Stop the attachment receiver
+ unregisterReceiver(receiver);
+
+ // Stop all controllers
+ while (controllers.size() > 0) {
+ // Stop and remove the controller
+ controllers.remove(0).stop();
+ }
+ }
+
+ @Override
+ public void onCreate() {
+ this.usbManager = (UsbManager) getSystemService(Context.USB_SERVICE);
+ this.prefConfig = PreferenceConfiguration.readPreferences(this);
+ }
+
+ @Override
+ public void onDestroy() {
+ stop();
+
+ // Remove listeners
+ listener = null;
+ stateListener = null;
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return binder;
+ }
+
+ public interface UsbDriverStateListener {
+ void onUsbPermissionPromptStarting();
+ void onUsbPermissionPromptCompleted();
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/driver/Xbox360Controller.java b/app/src/main/java/com/limelight/binding/input/driver/Xbox360Controller.java
old mode 100644
new mode 100755
index cc4b744e6f..0dd1dca70c
--- a/app/src/main/java/com/limelight/binding/input/driver/Xbox360Controller.java
+++ b/app/src/main/java/com/limelight/binding/input/driver/Xbox360Controller.java
@@ -1,165 +1,167 @@
-package com.limelight.binding.input.driver;
-
-import android.hardware.usb.UsbConstants;
-import android.hardware.usb.UsbDevice;
-import android.hardware.usb.UsbDeviceConnection;
-
-import com.limelight.LimeLog;
-import com.limelight.nvstream.input.ControllerPacket;
-
-import java.nio.ByteBuffer;
-
-public class Xbox360Controller extends AbstractXboxController {
- private static final int XB360_IFACE_SUBCLASS = 93;
- private static final int XB360_IFACE_PROTOCOL = 1; // Wired only
-
- private static final int[] SUPPORTED_VENDORS = {
- 0x0079, // GPD Win 2
- 0x044f, // Thrustmaster
- 0x045e, // Microsoft
- 0x046d, // Logitech
- 0x056e, // Elecom
- 0x06a3, // Saitek
- 0x0738, // Mad Catz
- 0x07ff, // Mad Catz
- 0x0e6f, // Unknown
- 0x0f0d, // Hori
- 0x1038, // SteelSeries
- 0x11c9, // Nacon
- 0x1209, // Ardwiino
- 0x12ab, // Unknown
- 0x1430, // RedOctane
- 0x146b, // BigBen
- 0x1532, // Razer Sabertooth
- 0x15e4, // Numark
- 0x162e, // Joytech
- 0x1689, // Razer Onza
- 0x1949, // Lab126 (Amazon Luna)
- 0x1bad, // Harmonix
- 0x20d6, // PowerA
- 0x24c6, // PowerA
- 0x2f24, // GameSir
- 0x2dc8, // 8BitDo
- };
-
- public static boolean canClaimDevice(UsbDevice device) {
- for (int supportedVid : SUPPORTED_VENDORS) {
- if (device.getVendorId() == supportedVid &&
- device.getInterfaceCount() >= 1 &&
- device.getInterface(0).getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC &&
- device.getInterface(0).getInterfaceSubclass() == XB360_IFACE_SUBCLASS &&
- device.getInterface(0).getInterfaceProtocol() == XB360_IFACE_PROTOCOL) {
- return true;
- }
- }
-
- return false;
- }
-
- public Xbox360Controller(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) {
- super(device, connection, deviceId, listener);
- }
-
- private int unsignByte(byte b) {
- if (b < 0) {
- return b + 256;
- }
- else {
- return b;
- }
- }
-
- @Override
- protected boolean handleRead(ByteBuffer buffer) {
- if (buffer.remaining() < 14) {
- LimeLog.severe("Read too small: "+buffer.remaining());
- return false;
- }
-
- // Skip first short
- buffer.position(buffer.position() + 2);
-
- // DPAD
- byte b = buffer.get();
- setButtonFlag(ControllerPacket.LEFT_FLAG, b & 0x04);
- setButtonFlag(ControllerPacket.RIGHT_FLAG, b & 0x08);
- setButtonFlag(ControllerPacket.UP_FLAG, b & 0x01);
- setButtonFlag(ControllerPacket.DOWN_FLAG, b & 0x02);
-
- // Start/Select
- setButtonFlag(ControllerPacket.PLAY_FLAG, b & 0x10);
- setButtonFlag(ControllerPacket.BACK_FLAG, b & 0x20);
-
- // LS/RS
- setButtonFlag(ControllerPacket.LS_CLK_FLAG, b & 0x40);
- setButtonFlag(ControllerPacket.RS_CLK_FLAG, b & 0x80);
-
- // ABXY buttons
- b = buffer.get();
- setButtonFlag(ControllerPacket.A_FLAG, b & 0x10);
- setButtonFlag(ControllerPacket.B_FLAG, b & 0x20);
- setButtonFlag(ControllerPacket.X_FLAG, b & 0x40);
- setButtonFlag(ControllerPacket.Y_FLAG, b & 0x80);
-
- // LB/RB
- setButtonFlag(ControllerPacket.LB_FLAG, b & 0x01);
- setButtonFlag(ControllerPacket.RB_FLAG, b & 0x02);
-
- // Xbox button
- setButtonFlag(ControllerPacket.SPECIAL_BUTTON_FLAG, b & 0x04);
-
- // Triggers
- leftTrigger = unsignByte(buffer.get()) / 255.0f;
- rightTrigger = unsignByte(buffer.get()) / 255.0f;
-
- // Left stick
- leftStickX = buffer.getShort() / 32767.0f;
- leftStickY = ~buffer.getShort() / 32767.0f;
-
- // Right stick
- rightStickX = buffer.getShort() / 32767.0f;
- rightStickY = ~buffer.getShort() / 32767.0f;
-
- // Return true to send input
- return true;
- }
-
- private boolean sendLedCommand(byte command) {
- byte[] commandBuffer = {0x01, 0x03, command};
-
- int res = connection.bulkTransfer(outEndpt, commandBuffer, commandBuffer.length, 3000);
- if (res != commandBuffer.length) {
- LimeLog.warning("LED set transfer failed: "+res);
- return false;
- }
-
- return true;
- }
-
- @Override
- protected boolean doInit() {
- // Turn the LED on corresponding to our device ID
- sendLedCommand((byte)(2 + (getControllerId() % 4)));
-
- // No need to fail init if the LED command fails
- return true;
- }
-
- @Override
- public void rumble(short lowFreqMotor, short highFreqMotor) {
- byte[] data = {
- 0x00, 0x08, 0x00,
- (byte)(lowFreqMotor >> 8), (byte)(highFreqMotor >> 8),
- 0x00, 0x00, 0x00
- };
- int res = connection.bulkTransfer(outEndpt, data, data.length, 100);
- if (res != data.length) {
- LimeLog.warning("Rumble transfer failed: "+res);
- }
- }
-
- @Override
- public void rumbleTriggers(short leftTrigger, short rightTrigger) {
- // Trigger motors not present on Xbox 360 controllers
- }
-}
+package com.limelight.binding.input.driver;
+
+import android.hardware.usb.UsbConstants;
+import android.hardware.usb.UsbDevice;
+import android.hardware.usb.UsbDeviceConnection;
+
+import com.limelight.LimeLog;
+import com.limelight.nvstream.input.ControllerPacket;
+
+import java.nio.ByteBuffer;
+
+public class Xbox360Controller extends AbstractXboxController {
+ private static final int XB360_IFACE_SUBCLASS = 93;
+ private static final int XB360_IFACE_PROTOCOL = 1; // Wired only
+
+ private static final int[] SUPPORTED_VENDORS = {
+ 0x0079, // GPD Win 2
+ 0x044f, // Thrustmaster
+ 0x045e, // Microsoft
+ 0x046d, // Logitech
+ 0x056e, // Elecom
+ 0x06a3, // Saitek
+ 0x0738, // Mad Catz
+ 0x07ff, // Mad Catz
+ 0x0e6f, // Unknown
+ 0x0f0d, // Hori
+ 0x1038, // SteelSeries
+ 0x11c9, // Nacon
+ 0x1209, // Ardwiino
+ 0x12ab, // Unknown
+ 0x1430, // RedOctane
+ 0x146b, // BigBen
+ 0x1532, // Razer Sabertooth
+ 0x15e4, // Numark
+ 0x162e, // Joytech
+ 0x1689, // Razer Onza
+ 0x1949, // Lab126 (Amazon Luna)
+ 0x1bad, // Harmonix
+ 0x20d6, // PowerA
+ 0x24c6, // PowerA
+ 0x2f24, // GameSir
+ 0x2dc8, // 8BitDo
+ 0x413d, // 小鸡启明星
+ 0x3537,//小鸡启明星6300固件
+ };
+
+ public static boolean canClaimDevice(UsbDevice device) {
+ for (int supportedVid : SUPPORTED_VENDORS) {
+ if (device.getVendorId() == supportedVid &&
+ device.getInterfaceCount() >= 1 &&
+ device.getInterface(0).getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC &&
+ device.getInterface(0).getInterfaceSubclass() == XB360_IFACE_SUBCLASS &&
+ device.getInterface(0).getInterfaceProtocol() == XB360_IFACE_PROTOCOL) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public Xbox360Controller(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) {
+ super(device, connection, deviceId, listener);
+ }
+
+ private int unsignByte(byte b) {
+ if (b < 0) {
+ return b + 256;
+ }
+ else {
+ return b;
+ }
+ }
+
+ @Override
+ protected boolean handleRead(ByteBuffer buffer) {
+ if (buffer.remaining() < 14) {
+ LimeLog.severe("Read too small: "+buffer.remaining());
+ return false;
+ }
+
+ // Skip first short
+ buffer.position(buffer.position() + 2);
+
+ // DPAD
+ byte b = buffer.get();
+ setButtonFlag(ControllerPacket.LEFT_FLAG, b & 0x04);
+ setButtonFlag(ControllerPacket.RIGHT_FLAG, b & 0x08);
+ setButtonFlag(ControllerPacket.UP_FLAG, b & 0x01);
+ setButtonFlag(ControllerPacket.DOWN_FLAG, b & 0x02);
+
+ // Start/Select
+ setButtonFlag(ControllerPacket.PLAY_FLAG, b & 0x10);
+ setButtonFlag(ControllerPacket.BACK_FLAG, b & 0x20);
+
+ // LS/RS
+ setButtonFlag(ControllerPacket.LS_CLK_FLAG, b & 0x40);
+ setButtonFlag(ControllerPacket.RS_CLK_FLAG, b & 0x80);
+
+ // ABXY buttons
+ b = buffer.get();
+ setButtonFlag(ControllerPacket.A_FLAG, b & 0x10);
+ setButtonFlag(ControllerPacket.B_FLAG, b & 0x20);
+ setButtonFlag(ControllerPacket.X_FLAG, b & 0x40);
+ setButtonFlag(ControllerPacket.Y_FLAG, b & 0x80);
+
+ // LB/RB
+ setButtonFlag(ControllerPacket.LB_FLAG, b & 0x01);
+ setButtonFlag(ControllerPacket.RB_FLAG, b & 0x02);
+
+ // Xbox button
+ setButtonFlag(ControllerPacket.SPECIAL_BUTTON_FLAG, b & 0x04);
+
+ // Triggers
+ leftTrigger = unsignByte(buffer.get()) / 255.0f;
+ rightTrigger = unsignByte(buffer.get()) / 255.0f;
+
+ // Left stick
+ leftStickX = buffer.getShort() / 32767.0f;
+ leftStickY = ~buffer.getShort() / 32767.0f;
+
+ // Right stick
+ rightStickX = buffer.getShort() / 32767.0f;
+ rightStickY = ~buffer.getShort() / 32767.0f;
+
+ // Return true to send input
+ return true;
+ }
+
+ private boolean sendLedCommand(byte command) {
+ byte[] commandBuffer = {0x01, 0x03, command};
+
+ int res = connection.bulkTransfer(outEndpt, commandBuffer, commandBuffer.length, 3000);
+ if (res != commandBuffer.length) {
+ LimeLog.warning("LED set transfer failed: "+res);
+ return false;
+ }
+
+ return true;
+ }
+
+ @Override
+ protected boolean doInit() {
+ // Turn the LED on corresponding to our device ID
+ sendLedCommand((byte)(2 + (getControllerId() % 4)));
+
+ // No need to fail init if the LED command fails
+ return true;
+ }
+
+ @Override
+ public void rumble(short lowFreqMotor, short highFreqMotor) {
+ byte[] data = {
+ 0x00, 0x08, 0x00,
+ (byte)(lowFreqMotor >> 8), (byte)(highFreqMotor >> 8),
+ 0x00, 0x00, 0x00
+ };
+ int res = connection.bulkTransfer(outEndpt, data, data.length, 100);
+ if (res != data.length) {
+ LimeLog.warning("Rumble transfer failed: "+res);
+ }
+ }
+
+ @Override
+ public void rumbleTriggers(short leftTrigger, short rightTrigger) {
+ // Trigger motors not present on Xbox 360 controllers
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/driver/Xbox360WirelessDongle.java b/app/src/main/java/com/limelight/binding/input/driver/Xbox360WirelessDongle.java
old mode 100644
new mode 100755
index 0aa311a613..91ae2be1fc
--- a/app/src/main/java/com/limelight/binding/input/driver/Xbox360WirelessDongle.java
+++ b/app/src/main/java/com/limelight/binding/input/driver/Xbox360WirelessDongle.java
@@ -1,148 +1,148 @@
-package com.limelight.binding.input.driver;
-
-import android.hardware.usb.UsbConstants;
-import android.hardware.usb.UsbDevice;
-import android.hardware.usb.UsbDeviceConnection;
-import android.hardware.usb.UsbEndpoint;
-import android.hardware.usb.UsbInterface;
-import android.os.Build;
-import android.view.InputDevice;
-
-import com.limelight.LimeLog;
-
-import java.nio.ByteBuffer;
-
-public class Xbox360WirelessDongle extends AbstractController {
- private UsbDevice device;
- private UsbDeviceConnection connection;
-
- private static final int XB360W_IFACE_SUBCLASS = 93;
- private static final int XB360W_IFACE_PROTOCOL = 129; // Wireless only
-
- private static final int[] SUPPORTED_VENDORS = {
- 0x045e, // Microsoft
- };
-
- public static boolean canClaimDevice(UsbDevice device) {
- for (int supportedVid : SUPPORTED_VENDORS) {
- if (device.getVendorId() == supportedVid &&
- device.getInterfaceCount() >= 1 &&
- device.getInterface(0).getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC &&
- device.getInterface(0).getInterfaceSubclass() == XB360W_IFACE_SUBCLASS &&
- device.getInterface(0).getInterfaceProtocol() == XB360W_IFACE_PROTOCOL) {
- return true;
- }
- }
-
- return false;
- }
-
- public Xbox360WirelessDongle(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) {
- super(deviceId, listener, device.getVendorId(), device.getProductId());
- this.device = device;
- this.connection = connection;
- }
-
- private void sendLedCommandToEndpoint(UsbEndpoint endpoint, int controllerIndex) {
- byte[] commandBuffer = {
- 0x00,
- 0x00,
- 0x08,
- (byte) (0x40 + (2 + (controllerIndex % 4))),
- 0x00,
- 0x00,
- 0x00,
- 0x00,
- 0x00,
- 0x00,
- 0x00,
- 0x00};
-
- int res = connection.bulkTransfer(endpoint, commandBuffer, commandBuffer.length, 3000);
- if (res != commandBuffer.length) {
- LimeLog.warning("LED set transfer failed: "+res);
- }
- }
-
- private void sendLedCommandToInterface(UsbInterface iface, int controllerIndex) {
- // Claim this interface to kick xpad off it (temporarily)
- if (!connection.claimInterface(iface, true)) {
- LimeLog.warning("Failed to claim interface: "+iface.getId());
- return;
- }
-
- // Find the out endpoint for this interface
- for (int i = 0; i < iface.getEndpointCount(); i++) {
- UsbEndpoint endpt = iface.getEndpoint(i);
- if (endpt.getDirection() == UsbConstants.USB_DIR_OUT) {
- // Send the LED command
- sendLedCommandToEndpoint(endpt, controllerIndex);
- break;
- }
- }
-
- // Release the interface to allow xpad to take over again
- connection.releaseInterface(iface);
- }
-
- @Override
- public boolean start() {
- int controllerIndex = 0;
-
- // On Android, there is a controller number associated with input devices.
- // We can use this to approximate the likely controller number. This won't
- // be completely accurate because there's no guarantee the order of interfaces
- // matches the order that devices were enumerated by xpad, but it's probably
- // better than nothing.
- for (int id : InputDevice.getDeviceIds()) {
- InputDevice inputDev = InputDevice.getDevice(id);
- if (inputDev == null) {
- // Device was removed while looping
- continue;
- }
-
- // Newer xpad versions use a special product ID (0x02a1) for controllers
- // rather than copying the product ID of the dongle itself.
- if (inputDev.getVendorId() == device.getVendorId() &&
- (inputDev.getProductId() == device.getProductId() ||
- inputDev.getProductId() == 0x02a1) &&
- inputDev.getControllerNumber() > 0) {
- controllerIndex = inputDev.getControllerNumber() - 1;
- break;
- }
- }
-
- // Send LED commands on the out endpoint of each interface. There is one interface
- // corresponding to each possible attached controller.
- for (int i = 0; i < device.getInterfaceCount(); i++) {
- UsbInterface iface = device.getInterface(i);
-
- // Skip the non-input interfaces
- if (iface.getInterfaceClass() != UsbConstants.USB_CLASS_VENDOR_SPEC ||
- iface.getInterfaceSubclass() != XB360W_IFACE_SUBCLASS ||
- iface.getInterfaceProtocol() != XB360W_IFACE_PROTOCOL) {
- continue;
- }
-
- sendLedCommandToInterface(iface, controllerIndex++);
- }
-
- // "Fail" to give control back to the kernel driver
- return false;
- }
-
- @Override
- public void stop() {
- // Nothing to do
- }
-
- @Override
- public void rumble(short lowFreqMotor, short highFreqMotor) {
- // Unreachable.
- }
-
- @Override
- public void rumbleTriggers(short leftTrigger, short rightTrigger) {
- // Unreachable.
- }
-}
+package com.limelight.binding.input.driver;
+
+import android.hardware.usb.UsbConstants;
+import android.hardware.usb.UsbDevice;
+import android.hardware.usb.UsbDeviceConnection;
+import android.hardware.usb.UsbEndpoint;
+import android.hardware.usb.UsbInterface;
+import android.os.Build;
+import android.view.InputDevice;
+
+import com.limelight.LimeLog;
+
+import java.nio.ByteBuffer;
+
+public class Xbox360WirelessDongle extends AbstractController {
+ private UsbDevice device;
+ private UsbDeviceConnection connection;
+
+ private static final int XB360W_IFACE_SUBCLASS = 93;
+ private static final int XB360W_IFACE_PROTOCOL = 129; // Wireless only
+
+ private static final int[] SUPPORTED_VENDORS = {
+ 0x045e, // Microsoft
+ };
+
+ public static boolean canClaimDevice(UsbDevice device) {
+ for (int supportedVid : SUPPORTED_VENDORS) {
+ if (device.getVendorId() == supportedVid &&
+ device.getInterfaceCount() >= 1 &&
+ device.getInterface(0).getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC &&
+ device.getInterface(0).getInterfaceSubclass() == XB360W_IFACE_SUBCLASS &&
+ device.getInterface(0).getInterfaceProtocol() == XB360W_IFACE_PROTOCOL) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public Xbox360WirelessDongle(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) {
+ super(deviceId, listener, device.getVendorId(), device.getProductId());
+ this.device = device;
+ this.connection = connection;
+ }
+
+ private void sendLedCommandToEndpoint(UsbEndpoint endpoint, int controllerIndex) {
+ byte[] commandBuffer = {
+ 0x00,
+ 0x00,
+ 0x08,
+ (byte) (0x40 + (2 + (controllerIndex % 4))),
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00};
+
+ int res = connection.bulkTransfer(endpoint, commandBuffer, commandBuffer.length, 3000);
+ if (res != commandBuffer.length) {
+ LimeLog.warning("LED set transfer failed: "+res);
+ }
+ }
+
+ private void sendLedCommandToInterface(UsbInterface iface, int controllerIndex) {
+ // Claim this interface to kick xpad off it (temporarily)
+ if (!connection.claimInterface(iface, true)) {
+ LimeLog.warning("Failed to claim interface: "+iface.getId());
+ return;
+ }
+
+ // Find the out endpoint for this interface
+ for (int i = 0; i < iface.getEndpointCount(); i++) {
+ UsbEndpoint endpt = iface.getEndpoint(i);
+ if (endpt.getDirection() == UsbConstants.USB_DIR_OUT) {
+ // Send the LED command
+ sendLedCommandToEndpoint(endpt, controllerIndex);
+ break;
+ }
+ }
+
+ // Release the interface to allow xpad to take over again
+ connection.releaseInterface(iface);
+ }
+
+ @Override
+ public boolean start() {
+ int controllerIndex = 0;
+
+ // On Android, there is a controller number associated with input devices.
+ // We can use this to approximate the likely controller number. This won't
+ // be completely accurate because there's no guarantee the order of interfaces
+ // matches the order that devices were enumerated by xpad, but it's probably
+ // better than nothing.
+ for (int id : InputDevice.getDeviceIds()) {
+ InputDevice inputDev = InputDevice.getDevice(id);
+ if (inputDev == null) {
+ // Device was removed while looping
+ continue;
+ }
+
+ // Newer xpad versions use a special product ID (0x02a1) for controllers
+ // rather than copying the product ID of the dongle itself.
+ if (inputDev.getVendorId() == device.getVendorId() &&
+ (inputDev.getProductId() == device.getProductId() ||
+ inputDev.getProductId() == 0x02a1) &&
+ inputDev.getControllerNumber() > 0) {
+ controllerIndex = inputDev.getControllerNumber() - 1;
+ break;
+ }
+ }
+
+ // Send LED commands on the out endpoint of each interface. There is one interface
+ // corresponding to each possible attached controller.
+ for (int i = 0; i < device.getInterfaceCount(); i++) {
+ UsbInterface iface = device.getInterface(i);
+
+ // Skip the non-input interfaces
+ if (iface.getInterfaceClass() != UsbConstants.USB_CLASS_VENDOR_SPEC ||
+ iface.getInterfaceSubclass() != XB360W_IFACE_SUBCLASS ||
+ iface.getInterfaceProtocol() != XB360W_IFACE_PROTOCOL) {
+ continue;
+ }
+
+ sendLedCommandToInterface(iface, controllerIndex++);
+ }
+
+ // "Fail" to give control back to the kernel driver
+ return false;
+ }
+
+ @Override
+ public void stop() {
+ // Nothing to do
+ }
+
+ @Override
+ public void rumble(short lowFreqMotor, short highFreqMotor) {
+ // Unreachable.
+ }
+
+ @Override
+ public void rumbleTriggers(short leftTrigger, short rightTrigger) {
+ // Unreachable.
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java b/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java
old mode 100644
new mode 100755
index b84f2cc749..456367e62d
--- a/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java
+++ b/app/src/main/java/com/limelight/binding/input/driver/XboxOneController.java
@@ -1,226 +1,239 @@
-package com.limelight.binding.input.driver;
-
-import android.hardware.usb.UsbConstants;
-import android.hardware.usb.UsbDevice;
-import android.hardware.usb.UsbDeviceConnection;
-
-import com.limelight.LimeLog;
-import com.limelight.nvstream.input.ControllerPacket;
-import com.limelight.nvstream.jni.MoonBridge;
-
-import java.nio.ByteBuffer;
-import java.util.Arrays;
-
-public class XboxOneController extends AbstractXboxController {
-
- private static final int XB1_IFACE_SUBCLASS = 71;
- private static final int XB1_IFACE_PROTOCOL = 208;
-
- private static final int[] SUPPORTED_VENDORS = {
- 0x045e, // Microsoft
- 0x0738, // Mad Catz
- 0x0e6f, // Unknown
- 0x0f0d, // Hori
- 0x1532, // Razer Wildcat
- 0x20d6, // PowerA
- 0x24c6, // PowerA
- 0x2e24, // Hyperkin
- };
-
- private static final byte[] FW2015_INIT = {0x05, 0x20, 0x00, 0x01, 0x00};
- private static final byte[] ONE_S_INIT = {0x05, 0x20, 0x00, 0x0f, 0x06};
- private static final byte[] HORI_INIT = {0x01, 0x20, 0x00, 0x09, 0x00, 0x04, 0x20, 0x3a,
- 0x00, 0x00, 0x00, (byte)0x80, 0x00};
- private static final byte[] PDP_INIT1 = {0x0a, 0x20, 0x00, 0x03, 0x00, 0x01, 0x14};
- private static final byte[] PDP_INIT2 = {0x06, 0x20, 0x00, 0x02, 0x01, 0x00};
- private static final byte[] RUMBLE_INIT1 = {0x09, 0x00, 0x00, 0x09, 0x00, 0x0F, 0x00, 0x00,
- 0x1D, 0x1D, (byte)0xFF, 0x00, 0x00};
- private static final byte[] RUMBLE_INIT2 = {0x09, 0x00, 0x00, 0x09, 0x00, 0x0F, 0x00, 0x00,
- 0x00, 0x00, 0x00, 0x00, 0x00};
-
- private static InitPacket[] INIT_PKTS = {
- new InitPacket(0x0e6f, 0x0165, HORI_INIT),
- new InitPacket(0x0f0d, 0x0067, HORI_INIT),
- new InitPacket(0x0000, 0x0000, FW2015_INIT),
- new InitPacket(0x045e, 0x02ea, ONE_S_INIT),
- new InitPacket(0x045e, 0x0b00, ONE_S_INIT),
- new InitPacket(0x0e6f, 0x0000, PDP_INIT1),
- new InitPacket(0x0e6f, 0x0000, PDP_INIT2),
- new InitPacket(0x24c6, 0x541a, RUMBLE_INIT1),
- new InitPacket(0x24c6, 0x542a, RUMBLE_INIT1),
- new InitPacket(0x24c6, 0x543a, RUMBLE_INIT1),
- new InitPacket(0x24c6, 0x541a, RUMBLE_INIT2),
- new InitPacket(0x24c6, 0x542a, RUMBLE_INIT2),
- new InitPacket(0x24c6, 0x543a, RUMBLE_INIT2),
- };
-
- private byte seqNum = 0;
- private short lowFreqMotor = 0;
- private short highFreqMotor = 0;
- private short leftTriggerMotor = 0;
- private short rightTriggerMotor = 0;
-
- public XboxOneController(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) {
- super(device, connection, deviceId, listener);
- capabilities |= MoonBridge.LI_CCAP_TRIGGER_RUMBLE;
- }
-
- private void processButtons(ByteBuffer buffer) {
- byte b = buffer.get();
-
- setButtonFlag(ControllerPacket.PLAY_FLAG, b & 0x04);
- setButtonFlag(ControllerPacket.BACK_FLAG, b & 0x08);
-
- setButtonFlag(ControllerPacket.A_FLAG, b & 0x10);
- setButtonFlag(ControllerPacket.B_FLAG, b & 0x20);
- setButtonFlag(ControllerPacket.X_FLAG, b & 0x40);
- setButtonFlag(ControllerPacket.Y_FLAG, b & 0x80);
-
- b = buffer.get();
- setButtonFlag(ControllerPacket.LEFT_FLAG, b & 0x04);
- setButtonFlag(ControllerPacket.RIGHT_FLAG, b & 0x08);
- setButtonFlag(ControllerPacket.UP_FLAG, b & 0x01);
- setButtonFlag(ControllerPacket.DOWN_FLAG, b & 0x02);
-
- setButtonFlag(ControllerPacket.LB_FLAG, b & 0x10);
- setButtonFlag(ControllerPacket.RB_FLAG, b & 0x20);
-
- setButtonFlag(ControllerPacket.LS_CLK_FLAG, b & 0x40);
- setButtonFlag(ControllerPacket.RS_CLK_FLAG, b & 0x80);
-
- leftTrigger = buffer.getShort() / 1023.0f;
- rightTrigger = buffer.getShort() / 1023.0f;
-
- leftStickX = buffer.getShort() / 32767.0f;
- leftStickY = ~buffer.getShort() / 32767.0f;
-
- rightStickX = buffer.getShort() / 32767.0f;
- rightStickY = ~buffer.getShort() / 32767.0f;
- }
-
- private void ackModeReport(byte seqNum) {
- byte[] payload = {0x01, 0x20, seqNum, 0x09, 0x00, 0x07, 0x20, 0x02,
- 0x00, 0x00, 0x00, 0x00, 0x00};
- connection.bulkTransfer(outEndpt, payload, payload.length, 3000);
- }
-
- @Override
- protected boolean handleRead(ByteBuffer buffer) {
- switch (buffer.get())
- {
- case 0x20:
- if (buffer.remaining() < 17) {
- LimeLog.severe("XBone button/axis read too small: "+buffer.remaining());
- return false;
- }
-
- buffer.position(buffer.position()+3);
- processButtons(buffer);
- return true;
-
- case 0x07:
- if (buffer.remaining() < 4) {
- LimeLog.severe("XBone mode read too small: "+buffer.remaining());
- return false;
- }
-
- // The Xbox One S controller needs acks for mode reports otherwise
- // it retransmits them forever.
- if (buffer.get() == 0x30) {
- ackModeReport(buffer.get());
- buffer.position(buffer.position() + 1);
- }
- else {
- buffer.position(buffer.position() + 2);
- }
- setButtonFlag(ControllerPacket.SPECIAL_BUTTON_FLAG, buffer.get() & 0x01);
- return true;
- }
-
- return false;
- }
-
- public static boolean canClaimDevice(UsbDevice device) {
- for (int supportedVid : SUPPORTED_VENDORS) {
- if (device.getVendorId() == supportedVid &&
- device.getInterfaceCount() >= 1 &&
- device.getInterface(0).getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC &&
- device.getInterface(0).getInterfaceSubclass() == XB1_IFACE_SUBCLASS &&
- device.getInterface(0).getInterfaceProtocol() == XB1_IFACE_PROTOCOL) {
- return true;
- }
- }
-
- return false;
- }
-
- @Override
- protected boolean doInit() {
- // Send all applicable init packets
- for (InitPacket pkt : INIT_PKTS) {
- if (pkt.vendorId != 0 && device.getVendorId() != pkt.vendorId) {
- continue;
- }
-
- if (pkt.productId != 0 && device.getProductId() != pkt.productId) {
- continue;
- }
-
- byte[] data = Arrays.copyOf(pkt.data, pkt.data.length);
-
- // Populate sequence number
- data[2] = seqNum++;
-
- // Send the initialization packet
- int res = connection.bulkTransfer(outEndpt, data, data.length, 3000);
- if (res != data.length) {
- LimeLog.warning("Initialization transfer failed: "+res);
- return false;
- }
- }
-
- return true;
- }
-
- private void sendRumblePacket() {
- byte[] data = {
- 0x09, 0x00, seqNum++, 0x09, 0x00,
- 0x0F,
- (byte)(leftTriggerMotor >> 9),
- (byte)(rightTriggerMotor >> 9),
- (byte)(lowFreqMotor >> 9),
- (byte)(highFreqMotor >> 9),
- (byte)0xFF, 0x00, (byte)0xFF
- };
- int res = connection.bulkTransfer(outEndpt, data, data.length, 100);
- if (res != data.length) {
- LimeLog.warning("Rumble transfer failed: "+res);
- }
- }
-
- @Override
- public void rumble(short lowFreqMotor, short highFreqMotor) {
- this.lowFreqMotor = lowFreqMotor;
- this.highFreqMotor = highFreqMotor;
- sendRumblePacket();
- }
-
- @Override
- public void rumbleTriggers(short leftTrigger, short rightTrigger) {
- this.leftTriggerMotor = leftTrigger;
- this.rightTriggerMotor = rightTrigger;
- sendRumblePacket();
- }
-
- private static class InitPacket {
- final int vendorId;
- final int productId;
- final byte[] data;
-
- InitPacket(int vendorId, int productId, byte[] data) {
- this.vendorId = vendorId;
- this.productId = productId;
- this.data = data;
- }
- }
-}
+package com.limelight.binding.input.driver;
+
+import android.hardware.usb.UsbConstants;
+import android.hardware.usb.UsbDevice;
+import android.hardware.usb.UsbDeviceConnection;
+
+import com.limelight.LimeLog;
+import com.limelight.nvstream.input.ControllerPacket;
+import com.limelight.nvstream.jni.MoonBridge;
+
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+
+public class XboxOneController extends AbstractXboxController {
+
+ private static final int XB1_IFACE_SUBCLASS = 71;
+ private static final int XB1_IFACE_PROTOCOL = 208;
+
+ private static final int[] SUPPORTED_VENDORS = {
+ 0x045e, // Microsoft
+ 0x0738, // Mad Catz
+ 0x0e6f, // Unknown
+ 0x0f0d, // Hori
+ 0x1532, // Razer Wildcat
+ 0x20d6, // PowerA
+ 0x24c6, // PowerA
+ 0x2e24, // Hyperkin
+ 0x3537, // GameSir
+ 0x2dc8, // 8BitDo
+ };
+
+ private static final byte[] FW2015_INIT = {0x05, 0x20, 0x00, 0x01, 0x00};
+ private static final byte[] ONE_S_INIT = {0x05, 0x20, 0x00, 0x0f, 0x06};
+ private static final byte[] HORI_INIT = {0x01, 0x20, 0x00, 0x09, 0x00, 0x04, 0x20, 0x3a,
+ 0x00, 0x00, 0x00, (byte)0x80, 0x00};
+ private static final byte[] PDP_INIT1 = {0x0a, 0x20, 0x00, 0x03, 0x00, 0x01, 0x14};
+ private static final byte[] PDP_INIT2 = {0x06, 0x20, 0x00, 0x02, 0x01, 0x00};
+ private static final byte[] RUMBLE_INIT1 = {0x09, 0x00, 0x00, 0x09, 0x00, 0x0F, 0x00, 0x00,
+ 0x1D, 0x1D, (byte)0xFF, 0x00, 0x00};
+ private static final byte[] RUMBLE_INIT2 = {0x09, 0x00, 0x00, 0x09, 0x00, 0x0F, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00};
+
+ private static InitPacket[] INIT_PKTS = {
+ new InitPacket(0x0e6f, 0x0165, HORI_INIT),
+ new InitPacket(0x0f0d, 0x0067, HORI_INIT),
+ new InitPacket(0x0000, 0x0000, FW2015_INIT),
+ new InitPacket(0x045e, 0x02ea, ONE_S_INIT),//Xbox Wireless Controller, HWID Model 1708
+ new InitPacket(0x045e, 0x0b00, ONE_S_INIT),
+ new InitPacket(0x0e6f, 0x0000, PDP_INIT1),
+ new InitPacket(0x0e6f, 0x0000, PDP_INIT2),
+ new InitPacket(0x24c6, 0x541a, RUMBLE_INIT1),
+ new InitPacket(0x24c6, 0x542a, RUMBLE_INIT1),
+ new InitPacket(0x24c6, 0x543a, RUMBLE_INIT1),
+ new InitPacket(0x24c6, 0x541a, RUMBLE_INIT2),
+ new InitPacket(0x24c6, 0x542a, RUMBLE_INIT2),
+ new InitPacket(0x24c6, 0x543a, RUMBLE_INIT2),
+ new InitPacket(0x045e, 0x0b12, ONE_S_INIT),//Xbox Wireless Controller, HWID Model 1914
+ new InitPacket(0x045e, 0x02fe, ONE_S_INIT),//Xbox Wireless Controller, HWID Model 1914
+ new InitPacket(0x3537, 0x1012, ONE_S_INIT),//小鸡影舞者
+
+ };
+
+ private byte seqNum = 0;
+ private short lowFreqMotor = 0;
+ private short highFreqMotor = 0;
+ private short leftTriggerMotor = 0;
+ private short rightTriggerMotor = 0;
+
+ public XboxOneController(UsbDevice device, UsbDeviceConnection connection, int deviceId, UsbDriverListener listener) {
+ super(device, connection, deviceId, listener);
+ capabilities |= MoonBridge.LI_CCAP_TRIGGER_RUMBLE;
+ }
+
+ private void processButtons(ByteBuffer buffer) {
+ byte b = buffer.get();
+
+ setButtonFlag(ControllerPacket.PLAY_FLAG, b & 0x04);
+ setButtonFlag(ControllerPacket.BACK_FLAG, b & 0x08);
+
+ setButtonFlag(ControllerPacket.A_FLAG, b & 0x10);
+ setButtonFlag(ControllerPacket.B_FLAG, b & 0x20);
+ setButtonFlag(ControllerPacket.X_FLAG, b & 0x40);
+ setButtonFlag(ControllerPacket.Y_FLAG, b & 0x80);
+
+ b = buffer.get();
+ setButtonFlag(ControllerPacket.LEFT_FLAG, b & 0x04);
+ setButtonFlag(ControllerPacket.RIGHT_FLAG, b & 0x08);
+ setButtonFlag(ControllerPacket.UP_FLAG, b & 0x01);
+ setButtonFlag(ControllerPacket.DOWN_FLAG, b & 0x02);
+
+ setButtonFlag(ControllerPacket.LB_FLAG, b & 0x10);
+ setButtonFlag(ControllerPacket.RB_FLAG, b & 0x20);
+
+ setButtonFlag(ControllerPacket.LS_CLK_FLAG, b & 0x40);
+ setButtonFlag(ControllerPacket.RS_CLK_FLAG, b & 0x80);
+
+ leftTrigger = buffer.getShort() / 1023.0f;
+ rightTrigger = buffer.getShort() / 1023.0f;
+
+ leftStickX = buffer.getShort() / 32767.0f;
+ leftStickY = ~buffer.getShort() / 32767.0f;
+
+ rightStickX = buffer.getShort() / 32767.0f;
+ rightStickY = ~buffer.getShort() / 32767.0f;
+ }
+
+ private void ackModeReport(byte seqNum) {
+ byte[] payload = {0x01, 0x20, seqNum, 0x09, 0x00, 0x07, 0x20, 0x02,
+ 0x00, 0x00, 0x00, 0x00, 0x00};
+ connection.bulkTransfer(outEndpt, payload, payload.length, 3000);
+ }
+
+ @Override
+ protected boolean handleRead(ByteBuffer buffer) {
+ switch (buffer.get())
+ {
+ case 0x20:
+ if (buffer.remaining() < 17) {
+ LimeLog.severe("XBone button/axis read too small: "+buffer.remaining());
+ return false;
+ }
+
+ buffer.position(buffer.position()+3);
+ processButtons(buffer);
+ return true;
+
+ case 0x07:
+ if (buffer.remaining() < 4) {
+ LimeLog.severe("XBone mode read too small: "+buffer.remaining());
+ return false;
+ }
+
+ // The Xbox One S controller needs acks for mode reports otherwise
+ // it retransmits them forever.
+ if (buffer.get() == 0x30) {
+ ackModeReport(buffer.get());
+ buffer.position(buffer.position() + 1);
+ }
+ else {
+ buffer.position(buffer.position() + 2);
+ }
+ setButtonFlag(ControllerPacket.SPECIAL_BUTTON_FLAG, buffer.get() & 0x01);
+ return true;
+ }
+
+ return false;
+ }
+
+ public static boolean canClaimDevice(UsbDevice device) {
+// LimeLog.info("UsbDevice->vid:" + device.getVendorId());
+// LimeLog.info("UsbDevice->count:" + device.getInterfaceCount());
+// if(device.getInterfaceCount()>0){
+// LimeLog.info("UsbDevice->0:" + device.getInterface(0).getInterfaceClass());
+// LimeLog.info("UsbDevice->0:" + device.getInterface(0).getInterfaceSubclass());
+// LimeLog.info("UsbDevice->0:" + device.getInterface(0).getInterfaceProtocol());
+// }
+ for (int supportedVid : SUPPORTED_VENDORS) {
+ if (device.getVendorId() == supportedVid &&
+ device.getInterfaceCount() >= 1 &&
+ device.getInterface(0).getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC &&
+ device.getInterface(0).getInterfaceSubclass() == XB1_IFACE_SUBCLASS &&
+ device.getInterface(0).getInterfaceProtocol() == XB1_IFACE_PROTOCOL) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ @Override
+ protected boolean doInit() {
+ // Send all applicable init packets
+ for (InitPacket pkt : INIT_PKTS) {
+ if (pkt.vendorId != 0 && device.getVendorId() != pkt.vendorId) {
+ continue;
+ }
+
+ if (pkt.productId != 0 && device.getProductId() != pkt.productId) {
+ continue;
+ }
+
+ byte[] data = Arrays.copyOf(pkt.data, pkt.data.length);
+
+ // Populate sequence number
+ data[2] = seqNum++;
+
+ // Send the initialization packet
+ int res = connection.bulkTransfer(outEndpt, data, data.length, 3000);
+ if (res != data.length) {
+ LimeLog.warning("Initialization transfer failed: "+res);
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private void sendRumblePacket() {
+ byte[] data = {
+ 0x09, 0x00, seqNum++, 0x09, 0x00,
+ 0x0F,
+ (byte)(leftTriggerMotor >> 9),
+ (byte)(rightTriggerMotor >> 9),
+ (byte)(lowFreqMotor >> 9),
+ (byte)(highFreqMotor >> 9),
+ (byte)0xFF, 0x00, (byte)0xFF
+ };
+ int res = connection.bulkTransfer(outEndpt, data, data.length, 100);
+ if (res != data.length) {
+ LimeLog.warning("Rumble transfer failed: "+res);
+ }
+ }
+
+ @Override
+ public void rumble(short lowFreqMotor, short highFreqMotor) {
+ this.lowFreqMotor = lowFreqMotor;
+ this.highFreqMotor = highFreqMotor;
+ sendRumblePacket();
+ }
+
+ @Override
+ public void rumbleTriggers(short leftTrigger, short rightTrigger) {
+ this.leftTriggerMotor = leftTrigger;
+ this.rightTriggerMotor = rightTrigger;
+ sendRumblePacket();
+ }
+
+ private static class InitPacket {
+ final int vendorId;
+ final int productId;
+ final byte[] data;
+
+ InitPacket(int vendorId, int productId, byte[] data) {
+ this.vendorId = vendorId;
+ this.productId = productId;
+ this.data = data;
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/evdev/EvdevCaptureProviderShim.java b/app/src/main/java/com/limelight/binding/input/evdev/EvdevCaptureProviderShim.java
old mode 100644
new mode 100755
index 5268cf9920..b2691e8c3f
--- a/app/src/main/java/com/limelight/binding/input/evdev/EvdevCaptureProviderShim.java
+++ b/app/src/main/java/com/limelight/binding/input/evdev/EvdevCaptureProviderShim.java
@@ -1,24 +1,24 @@
-package com.limelight.binding.input.evdev;
-
-
-import android.app.Activity;
-
-import com.limelight.BuildConfig;
-import com.limelight.binding.input.capture.InputCaptureProvider;
-
-public class EvdevCaptureProviderShim {
- public static boolean isCaptureProviderSupported() {
- return BuildConfig.ROOT_BUILD;
- }
-
- // We need to construct our capture provider using reflection because it isn't included in non-root builds
- public static InputCaptureProvider createEvdevCaptureProvider(Activity activity, EvdevListener listener) {
- try {
- Class providerClass = Class.forName("com.limelight.binding.input.evdev.EvdevCaptureProvider");
- return (InputCaptureProvider) providerClass.getConstructors()[0].newInstance(activity, listener);
- } catch (Exception e) {
- e.printStackTrace();
- throw new RuntimeException(e);
- }
- }
-}
+package com.limelight.binding.input.evdev;
+
+
+import android.app.Activity;
+
+import com.limelight.BuildConfig;
+import com.limelight.binding.input.capture.InputCaptureProvider;
+
+public class EvdevCaptureProviderShim {
+ public static boolean isCaptureProviderSupported() {
+ return BuildConfig.ROOT_BUILD;
+ }
+
+ // We need to construct our capture provider using reflection because it isn't included in non-root builds
+ public static InputCaptureProvider createEvdevCaptureProvider(Activity activity, EvdevListener listener) {
+ try {
+ Class providerClass = Class.forName("com.limelight.binding.input.evdev.EvdevCaptureProvider");
+ return (InputCaptureProvider) providerClass.getConstructors()[0].newInstance(activity, listener);
+ } catch (Exception e) {
+ e.printStackTrace();
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/evdev/EvdevListener.java b/app/src/main/java/com/limelight/binding/input/evdev/EvdevListener.java
old mode 100644
new mode 100755
index 6205426b61..6dfaae71f9
--- a/app/src/main/java/com/limelight/binding/input/evdev/EvdevListener.java
+++ b/app/src/main/java/com/limelight/binding/input/evdev/EvdevListener.java
@@ -1,15 +1,15 @@
-package com.limelight.binding.input.evdev;
-
-public interface EvdevListener {
- int BUTTON_LEFT = 1;
- int BUTTON_MIDDLE = 2;
- int BUTTON_RIGHT = 3;
- int BUTTON_X1 = 4;
- int BUTTON_X2 = 5;
-
- void mouseMove(int deltaX, int deltaY);
- void mouseButtonEvent(int buttonId, boolean down);
- void mouseVScroll(byte amount);
- void mouseHScroll(byte amount);
- void keyboardEvent(boolean buttonDown, short keyCode);
-}
+package com.limelight.binding.input.evdev;
+
+public interface EvdevListener {
+ int BUTTON_LEFT = 1;
+ int BUTTON_MIDDLE = 2;
+ int BUTTON_RIGHT = 3;
+ int BUTTON_X1 = 4;
+ int BUTTON_X2 = 5;
+
+ void mouseMove(int deltaX, int deltaY);
+ void mouseButtonEvent(int buttonId, boolean down);
+ void mouseVScroll(byte amount);
+ void mouseHScroll(byte amount);
+ void keyboardEvent(boolean buttonDown, short keyCode);
+}
diff --git a/app/src/main/java/com/limelight/binding/input/touch/AbsoluteTouchContext.java b/app/src/main/java/com/limelight/binding/input/touch/AbsoluteTouchContext.java
old mode 100644
new mode 100755
index d5fb4708b1..e233f40640
--- a/app/src/main/java/com/limelight/binding/input/touch/AbsoluteTouchContext.java
+++ b/app/src/main/java/com/limelight/binding/input/touch/AbsoluteTouchContext.java
@@ -1,249 +1,261 @@
-package com.limelight.binding.input.touch;
-
-import android.os.Handler;
-import android.os.Looper;
-import android.view.View;
-
-import com.limelight.nvstream.NvConnection;
-import com.limelight.nvstream.input.MouseButtonPacket;
-
-public class AbsoluteTouchContext implements TouchContext {
- private int lastTouchDownX = 0;
- private int lastTouchDownY = 0;
- private long lastTouchDownTime = 0;
- private int lastTouchUpX = 0;
- private int lastTouchUpY = 0;
- private long lastTouchUpTime = 0;
- private int lastTouchLocationX = 0;
- private int lastTouchLocationY = 0;
- private boolean cancelled;
- private boolean confirmedLongPress;
- private boolean confirmedTap;
-
- private final Runnable longPressRunnable = new Runnable() {
- @Override
- public void run() {
- // This timer should have already expired, but cancel it just in case
- cancelTapDownTimer();
-
- // Switch from a left click to a right click after a long press
- confirmedLongPress = true;
- if (confirmedTap) {
- conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT);
- }
- conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_RIGHT);
- }
- };
-
- private final Runnable tapDownRunnable = new Runnable() {
- @Override
- public void run() {
- // Start our tap
- tapConfirmed();
- }
- };
-
- private final NvConnection conn;
- private final int actionIndex;
- private final View targetView;
- private final Handler handler;
-
- private final Runnable leftButtonUpRunnable = new Runnable() {
- @Override
- public void run() {
- conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT);
- }
- };
-
- private static final int SCROLL_SPEED_FACTOR = 3;
-
- private static final int LONG_PRESS_TIME_THRESHOLD = 650;
- private static final int LONG_PRESS_DISTANCE_THRESHOLD = 30;
-
- private static final int DOUBLE_TAP_TIME_THRESHOLD = 250;
- private static final int DOUBLE_TAP_DISTANCE_THRESHOLD = 60;
-
- private static final int TOUCH_DOWN_DEAD_ZONE_TIME_THRESHOLD = 100;
- private static final int TOUCH_DOWN_DEAD_ZONE_DISTANCE_THRESHOLD = 20;
-
- public AbsoluteTouchContext(NvConnection conn, int actionIndex, View view)
- {
- this.conn = conn;
- this.actionIndex = actionIndex;
- this.targetView = view;
- this.handler = new Handler(Looper.getMainLooper());
- }
-
- @Override
- public int getActionIndex()
- {
- return actionIndex;
- }
-
- @Override
- public boolean touchDownEvent(int eventX, int eventY, long eventTime, boolean isNewFinger)
- {
- if (!isNewFinger) {
- // We don't handle finger transitions for absolute mode
- return true;
- }
-
- lastTouchLocationX = lastTouchDownX = eventX;
- lastTouchLocationY = lastTouchDownY = eventY;
- lastTouchDownTime = eventTime;
- cancelled = confirmedTap = confirmedLongPress = false;
-
- if (actionIndex == 0) {
- // Start the timers
- startTapDownTimer();
- startLongPressTimer();
- }
-
- return true;
- }
-
- private boolean distanceExceeds(int deltaX, int deltaY, double limit) {
- return Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2)) > limit;
- }
-
- private void updatePosition(int eventX, int eventY) {
- // We may get values slightly outside our view region on ACTION_HOVER_ENTER and ACTION_HOVER_EXIT.
- // Normalize these to the view size. We can't just drop them because we won't always get an event
- // right at the boundary of the view, so dropping them would result in our cursor never really
- // reaching the sides of the screen.
- eventX = Math.min(Math.max(eventX, 0), targetView.getWidth());
- eventY = Math.min(Math.max(eventY, 0), targetView.getHeight());
-
- conn.sendMousePosition((short)eventX, (short)eventY, (short)targetView.getWidth(), (short)targetView.getHeight());
- }
-
- @Override
- public void touchUpEvent(int eventX, int eventY, long eventTime)
- {
- if (cancelled) {
- return;
- }
-
- if (actionIndex == 0) {
- // Cancel the timers
- cancelLongPressTimer();
- cancelTapDownTimer();
-
- // Raise the mouse buttons that we currently have down
- if (confirmedLongPress) {
- conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT);
- }
- else if (confirmedTap) {
- conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT);
- }
- else {
- // If we get here, this means that the tap completed within the touch down
- // deadzone time. We'll need to send the touch down and up events now at the
- // original touch down position.
- tapConfirmed();
-
- // Release the left mouse button in 100ms to allow for apps that use polling
- // to detect mouse button presses.
- handler.removeCallbacks(leftButtonUpRunnable);
- handler.postDelayed(leftButtonUpRunnable, 100);
- }
- }
-
- lastTouchLocationX = lastTouchUpX = eventX;
- lastTouchLocationY = lastTouchUpY = eventY;
- lastTouchUpTime = eventTime;
- }
-
- private void startLongPressTimer() {
- cancelLongPressTimer();
- handler.postDelayed(longPressRunnable, LONG_PRESS_TIME_THRESHOLD);
- }
-
- private void cancelLongPressTimer() {
- handler.removeCallbacks(longPressRunnable);
- }
-
- private void startTapDownTimer() {
- cancelTapDownTimer();
- handler.postDelayed(tapDownRunnable, TOUCH_DOWN_DEAD_ZONE_TIME_THRESHOLD);
- }
-
- private void cancelTapDownTimer() {
- handler.removeCallbacks(tapDownRunnable);
- }
-
- private void tapConfirmed() {
- if (confirmedTap || confirmedLongPress) {
- return;
- }
-
- confirmedTap = true;
- cancelTapDownTimer();
-
- // Left button down at original position
- if (lastTouchDownTime - lastTouchUpTime > DOUBLE_TAP_TIME_THRESHOLD ||
- distanceExceeds(lastTouchDownX - lastTouchUpX, lastTouchDownY - lastTouchUpY, DOUBLE_TAP_DISTANCE_THRESHOLD)) {
- // Don't reposition for finger down events within the deadzone. This makes double-clicking easier.
- updatePosition(lastTouchDownX, lastTouchDownY);
- }
- conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_LEFT);
- }
-
- @Override
- public boolean touchMoveEvent(int eventX, int eventY, long eventTime)
- {
- if (cancelled) {
- return true;
- }
-
- if (actionIndex == 0) {
- if (distanceExceeds(eventX - lastTouchDownX, eventY - lastTouchDownY, LONG_PRESS_DISTANCE_THRESHOLD)) {
- // Moved too far since touch down. Cancel the long press timer.
- cancelLongPressTimer();
- }
-
- // Ignore motion within the deadzone period after touch down
- if (confirmedTap || distanceExceeds(eventX - lastTouchDownX, eventY - lastTouchDownY, TOUCH_DOWN_DEAD_ZONE_DISTANCE_THRESHOLD)) {
- tapConfirmed();
- updatePosition(eventX, eventY);
- }
- }
- else if (actionIndex == 1) {
- conn.sendMouseHighResScroll((short)((eventY - lastTouchLocationY) * SCROLL_SPEED_FACTOR));
- }
-
- lastTouchLocationX = eventX;
- lastTouchLocationY = eventY;
-
- return true;
- }
-
- @Override
- public void cancelTouch() {
- cancelled = true;
-
- // Cancel the timers
- cancelLongPressTimer();
- cancelTapDownTimer();
-
- // Raise the mouse buttons
- if (confirmedLongPress) {
- conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT);
- }
- else if (confirmedTap) {
- conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT);
- }
- }
-
- @Override
- public boolean isCancelled() {
- return cancelled;
- }
-
- @Override
- public void setPointerCount(int pointerCount) {
- if (actionIndex == 0 && pointerCount > 1) {
- cancelTouch();
- }
- }
-}
+package com.limelight.binding.input.touch;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.view.View;
+
+import com.limelight.nvstream.NvConnection;
+import com.limelight.nvstream.input.MouseButtonPacket;
+
+public class AbsoluteTouchContext implements TouchContext {
+ private int lastTouchDownX = 0;
+ private int lastTouchDownY = 0;
+ private long lastTouchDownTime = 0;
+ private int lastTouchUpX = 0;
+ private int lastTouchUpY = 0;
+ private long lastTouchUpTime = 0;
+ private int lastTouchLocationX = 0;
+ private int lastTouchLocationY = 0;
+ private boolean cancelled;
+ private boolean confirmedLongPress;
+ private boolean confirmedTap;
+
+ private final byte buttonPrimary;
+ private final byte buttonSecondary;
+
+ private final Runnable longPressRunnable = new Runnable() {
+ @Override
+ public void run() {
+ // This timer should have already expired, but cancel it just in case
+ cancelTapDownTimer();
+
+ // Switch from a left click to a right click after a long press
+ confirmedLongPress = true;
+ if (confirmedTap) {
+ conn.sendMouseButtonUp(buttonPrimary);
+ }
+ conn.sendMouseButtonDown(buttonSecondary);
+ }
+ };
+
+ private final Runnable tapDownRunnable = new Runnable() {
+ @Override
+ public void run() {
+ // Start our tap
+ tapConfirmed();
+ }
+ };
+
+ private final NvConnection conn;
+ private final int actionIndex;
+ private final View targetView;
+ private final Handler handler;
+
+ private final Runnable leftButtonUpRunnable = new Runnable() {
+ @Override
+ public void run() {
+ conn.sendMouseButtonUp(buttonPrimary);
+ }
+ };
+
+ private static final int SCROLL_SPEED_FACTOR = 3;
+
+ private static final int LONG_PRESS_TIME_THRESHOLD = 650;
+ private static final int LONG_PRESS_DISTANCE_THRESHOLD = 30;
+
+ private static final int DOUBLE_TAP_TIME_THRESHOLD = 250;
+ private static final int DOUBLE_TAP_DISTANCE_THRESHOLD = 60;
+
+ private static final int TOUCH_DOWN_DEAD_ZONE_TIME_THRESHOLD = 100;
+ private static final int TOUCH_DOWN_DEAD_ZONE_DISTANCE_THRESHOLD = 20;
+
+ public AbsoluteTouchContext(NvConnection conn, int actionIndex, View view, boolean swapped)
+ {
+ this.conn = conn;
+ this.actionIndex = actionIndex;
+ this.targetView = view;
+ this.handler = new Handler(Looper.getMainLooper());
+
+ if (swapped) {
+ buttonPrimary = MouseButtonPacket.BUTTON_RIGHT;
+ buttonSecondary = MouseButtonPacket.BUTTON_LEFT;
+ }
+ else {
+ buttonPrimary = MouseButtonPacket.BUTTON_LEFT;
+ buttonSecondary = MouseButtonPacket.BUTTON_RIGHT;
+ }
+ }
+
+ @Override
+ public int getActionIndex()
+ {
+ return actionIndex;
+ }
+
+ @Override
+ public boolean touchDownEvent(int eventX, int eventY, long eventTime, boolean isNewFinger)
+ {
+ if (!isNewFinger) {
+ // We don't handle finger transitions for absolute mode
+ return true;
+ }
+
+ lastTouchLocationX = lastTouchDownX = eventX;
+ lastTouchLocationY = lastTouchDownY = eventY;
+ lastTouchDownTime = eventTime;
+ cancelled = confirmedTap = confirmedLongPress = false;
+
+ if (actionIndex == 0) {
+ // Start the timers
+ startTapDownTimer();
+ startLongPressTimer();
+ }
+
+ return true;
+ }
+
+ private boolean distanceExceeds(int deltaX, int deltaY, double limit) {
+ return Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2)) > limit;
+ }
+
+ private void updatePosition(int eventX, int eventY) {
+ // We may get values slightly outside our view region on ACTION_HOVER_ENTER and ACTION_HOVER_EXIT.
+ // Normalize these to the view size. We can't just drop them because we won't always get an event
+ // right at the boundary of the view, so dropping them would result in our cursor never really
+ // reaching the sides of the screen.
+ eventX = Math.min(Math.max(eventX, 0), targetView.getWidth());
+ eventY = Math.min(Math.max(eventY, 0), targetView.getHeight());
+
+ conn.sendMousePosition((short)eventX, (short)eventY, (short)targetView.getWidth(), (short)targetView.getHeight());
+ }
+
+ @Override
+ public void touchUpEvent(int eventX, int eventY, long eventTime)
+ {
+ if (cancelled) {
+ return;
+ }
+
+ if (actionIndex == 0) {
+ // Cancel the timers
+ cancelLongPressTimer();
+ cancelTapDownTimer();
+
+ // Raise the mouse buttons that we currently have down
+ if (confirmedLongPress) {
+ conn.sendMouseButtonUp(buttonSecondary);
+ }
+ else if (confirmedTap) {
+ conn.sendMouseButtonUp(buttonPrimary);
+ }
+ else {
+ // If we get here, this means that the tap completed within the touch down
+ // deadzone time. We'll need to send the touch down and up events now at the
+ // original touch down position.
+ tapConfirmed();
+
+ // Release the left mouse button in 100ms to allow for apps that use polling
+ // to detect mouse button presses.
+ handler.removeCallbacks(leftButtonUpRunnable);
+ handler.postDelayed(leftButtonUpRunnable, 100);
+ }
+ }
+
+ lastTouchLocationX = lastTouchUpX = eventX;
+ lastTouchLocationY = lastTouchUpY = eventY;
+ lastTouchUpTime = eventTime;
+ }
+
+ private void startLongPressTimer() {
+ cancelLongPressTimer();
+ handler.postDelayed(longPressRunnable, LONG_PRESS_TIME_THRESHOLD);
+ }
+
+ private void cancelLongPressTimer() {
+ handler.removeCallbacks(longPressRunnable);
+ }
+
+ private void startTapDownTimer() {
+ cancelTapDownTimer();
+ handler.postDelayed(tapDownRunnable, TOUCH_DOWN_DEAD_ZONE_TIME_THRESHOLD);
+ }
+
+ private void cancelTapDownTimer() {
+ handler.removeCallbacks(tapDownRunnable);
+ }
+
+ private void tapConfirmed() {
+ if (confirmedTap || confirmedLongPress) {
+ return;
+ }
+
+ confirmedTap = true;
+ cancelTapDownTimer();
+
+ // Left button down at original position
+ if (lastTouchDownTime - lastTouchUpTime > DOUBLE_TAP_TIME_THRESHOLD ||
+ distanceExceeds(lastTouchDownX - lastTouchUpX, lastTouchDownY - lastTouchUpY, DOUBLE_TAP_DISTANCE_THRESHOLD)) {
+ // Don't reposition for finger down events within the deadzone. This makes double-clicking easier.
+ updatePosition(lastTouchDownX, lastTouchDownY);
+ }
+ conn.sendMouseButtonDown(buttonPrimary);
+ }
+
+ @Override
+ public boolean touchMoveEvent(int eventX, int eventY, long eventTime)
+ {
+ if (cancelled) {
+ return true;
+ }
+
+ if (actionIndex == 0) {
+ if (distanceExceeds(eventX - lastTouchDownX, eventY - lastTouchDownY, LONG_PRESS_DISTANCE_THRESHOLD)) {
+ // Moved too far since touch down. Cancel the long press timer.
+ cancelLongPressTimer();
+ }
+
+ // Ignore motion within the deadzone period after touch down
+ if (confirmedTap || distanceExceeds(eventX - lastTouchDownX, eventY - lastTouchDownY, TOUCH_DOWN_DEAD_ZONE_DISTANCE_THRESHOLD)) {
+ tapConfirmed();
+ updatePosition(eventX, eventY);
+ }
+ }
+ else if (actionIndex == 1) {
+ conn.sendMouseHighResScroll((short)((eventY - lastTouchLocationY) * SCROLL_SPEED_FACTOR));
+ }
+
+ lastTouchLocationX = eventX;
+ lastTouchLocationY = eventY;
+
+ return true;
+ }
+
+ @Override
+ public void cancelTouch() {
+ cancelled = true;
+
+ // Cancel the timers
+ cancelLongPressTimer();
+ cancelTapDownTimer();
+
+ // Raise the mouse buttons
+ if (confirmedLongPress) {
+ conn.sendMouseButtonUp(buttonSecondary);
+ }
+ else if (confirmedTap) {
+ conn.sendMouseButtonUp(buttonPrimary);
+ }
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return cancelled;
+ }
+
+ @Override
+ public void setPointerCount(int pointerCount) {
+ if (actionIndex == 0 && pointerCount > 1) {
+ cancelTouch();
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/touch/RelativeTouchContext.java b/app/src/main/java/com/limelight/binding/input/touch/RelativeTouchContext.java
old mode 100644
new mode 100755
index 7ed8f09674..527fa6c252
--- a/app/src/main/java/com/limelight/binding/input/touch/RelativeTouchContext.java
+++ b/app/src/main/java/com/limelight/binding/input/touch/RelativeTouchContext.java
@@ -1,331 +1,331 @@
-package com.limelight.binding.input.touch;
-
-import android.os.Handler;
-import android.os.Looper;
-import android.view.View;
-
-import com.limelight.nvstream.NvConnection;
-import com.limelight.nvstream.input.MouseButtonPacket;
-import com.limelight.preferences.PreferenceConfiguration;
-
-public class RelativeTouchContext implements TouchContext {
- private int lastTouchX = 0;
- private int lastTouchY = 0;
- private int originalTouchX = 0;
- private int originalTouchY = 0;
- private long originalTouchTime = 0;
- private boolean cancelled;
- private boolean confirmedMove;
- private boolean confirmedDrag;
- private boolean confirmedScroll;
- private double distanceMoved;
- private double xFactor, yFactor;
- private int pointerCount;
- private int maxPointerCountInGesture;
-
- private final NvConnection conn;
- private final int actionIndex;
- private final int referenceWidth;
- private final int referenceHeight;
- private final View targetView;
- private final PreferenceConfiguration prefConfig;
- private final Handler handler;
-
- private final Runnable dragTimerRunnable = new Runnable() {
- @Override
- public void run() {
- // Check if someone already set move
- if (confirmedMove) {
- return;
- }
-
- // The drag should only be processed for the primary finger
- if (actionIndex != maxPointerCountInGesture - 1) {
- return;
- }
-
- // We haven't been cancelled before the timer expired so begin dragging
- confirmedDrag = true;
- conn.sendMouseButtonDown(getMouseButtonIndex());
- }
- };
-
- // Indexed by MouseButtonPacket.BUTTON_XXX - 1
- private final Runnable[] buttonUpRunnables = new Runnable[] {
- new Runnable() {
- @Override
- public void run() {
- conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT);
- }
- },
- new Runnable() {
- @Override
- public void run() {
- conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_MIDDLE);
- }
- },
- new Runnable() {
- @Override
- public void run() {
- conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT);
- }
- },
- new Runnable() {
- @Override
- public void run() {
- conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_X1);
- }
- },
- new Runnable() {
- @Override
- public void run() {
- conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_X2);
- }
- }
- };
-
- private static final int TAP_MOVEMENT_THRESHOLD = 20;
- private static final int TAP_DISTANCE_THRESHOLD = 25;
- private static final int TAP_TIME_THRESHOLD = 250;
- private static final int DRAG_TIME_THRESHOLD = 650;
-
- private static final int SCROLL_SPEED_FACTOR = 5;
-
- public RelativeTouchContext(NvConnection conn, int actionIndex,
- int referenceWidth, int referenceHeight,
- View view, PreferenceConfiguration prefConfig)
- {
- this.conn = conn;
- this.actionIndex = actionIndex;
- this.referenceWidth = referenceWidth;
- this.referenceHeight = referenceHeight;
- this.targetView = view;
- this.prefConfig = prefConfig;
- this.handler = new Handler(Looper.getMainLooper());
- }
-
- @Override
- public int getActionIndex()
- {
- return actionIndex;
- }
-
- private boolean isWithinTapBounds(int touchX, int touchY)
- {
- int xDelta = Math.abs(touchX - originalTouchX);
- int yDelta = Math.abs(touchY - originalTouchY);
- return xDelta <= TAP_MOVEMENT_THRESHOLD &&
- yDelta <= TAP_MOVEMENT_THRESHOLD;
- }
-
- private boolean isTap(long eventTime)
- {
- if (confirmedDrag || confirmedMove || confirmedScroll) {
- return false;
- }
-
- // If this input wasn't the last finger down, do not report
- // a tap. This ensures we don't report duplicate taps for each
- // finger on a multi-finger tap gesture
- if (actionIndex + 1 != maxPointerCountInGesture) {
- return false;
- }
-
- long timeDelta = eventTime - originalTouchTime;
- return isWithinTapBounds(lastTouchX, lastTouchY) && timeDelta <= TAP_TIME_THRESHOLD;
- }
-
- private byte getMouseButtonIndex()
- {
- if (actionIndex == 1) {
- return MouseButtonPacket.BUTTON_RIGHT;
- }
- else {
- return MouseButtonPacket.BUTTON_LEFT;
- }
- }
-
- @Override
- public boolean touchDownEvent(int eventX, int eventY, long eventTime, boolean isNewFinger)
- {
- // Get the view dimensions to scale inputs on this touch
- xFactor = referenceWidth / (double)targetView.getWidth();
- yFactor = referenceHeight / (double)targetView.getHeight();
-
- originalTouchX = lastTouchX = eventX;
- originalTouchY = lastTouchY = eventY;
-
- if (isNewFinger) {
- maxPointerCountInGesture = pointerCount;
- originalTouchTime = eventTime;
- cancelled = confirmedDrag = confirmedMove = confirmedScroll = false;
- distanceMoved = 0;
-
- if (actionIndex == 0) {
- // Start the timer for engaging a drag
- startDragTimer();
- }
- }
-
- return true;
- }
-
- @Override
- public void touchUpEvent(int eventX, int eventY, long eventTime)
- {
- if (cancelled) {
- return;
- }
-
- // Cancel the drag timer
- cancelDragTimer();
-
- byte buttonIndex = getMouseButtonIndex();
-
- if (confirmedDrag) {
- // Raise the button after a drag
- conn.sendMouseButtonUp(buttonIndex);
- }
- else if (isTap(eventTime))
- {
- // Lower the mouse button
- conn.sendMouseButtonDown(buttonIndex);
-
- // Release the mouse button in 100ms to allow for apps that use polling
- // to detect mouse button presses.
- Runnable buttonUpRunnable = buttonUpRunnables[buttonIndex - 1];
- handler.removeCallbacks(buttonUpRunnable);
- handler.postDelayed(buttonUpRunnable, 100);
- }
- }
-
- private void startDragTimer() {
- cancelDragTimer();
- handler.postDelayed(dragTimerRunnable, DRAG_TIME_THRESHOLD);
- }
-
- private void cancelDragTimer() {
- handler.removeCallbacks(dragTimerRunnable);
- }
-
- private void checkForConfirmedMove(int eventX, int eventY) {
- // If we've already confirmed something, get out now
- if (confirmedMove || confirmedDrag) {
- return;
- }
-
- // If it leaves the tap bounds before the drag time expires, it's a move.
- if (!isWithinTapBounds(eventX, eventY)) {
- confirmedMove = true;
- cancelDragTimer();
- return;
- }
-
- // Check if we've exceeded the maximum distance moved
- distanceMoved += Math.sqrt(Math.pow(eventX - lastTouchX, 2) + Math.pow(eventY - lastTouchY, 2));
- if (distanceMoved >= TAP_DISTANCE_THRESHOLD) {
- confirmedMove = true;
- cancelDragTimer();
- return;
- }
- }
-
- private void checkForConfirmedScroll() {
- // Enter scrolling mode if we've already left the tap zone
- // and we have 2 fingers on screen. Leave scroll mode if
- // we no longer have 2 fingers on screen
- confirmedScroll = (actionIndex == 0 && pointerCount == 2 && confirmedMove);
- }
-
- @Override
- public boolean touchMoveEvent(int eventX, int eventY, long eventTime)
- {
- if (cancelled) {
- return true;
- }
-
- if (eventX != lastTouchX || eventY != lastTouchY)
- {
- checkForConfirmedMove(eventX, eventY);
- checkForConfirmedScroll();
-
- // We only send moves and drags for the primary touch point
- if (actionIndex == 0) {
- int deltaX = eventX - lastTouchX;
- int deltaY = eventY - lastTouchY;
-
- // Scale the deltas based on the factors passed to our constructor
- deltaX = (int) Math.round((double) Math.abs(deltaX) * xFactor);
- deltaY = (int) Math.round((double) Math.abs(deltaY) * yFactor);
-
- // Fix up the signs
- if (eventX < lastTouchX) {
- deltaX = -deltaX;
- }
- if (eventY < lastTouchY) {
- deltaY = -deltaY;
- }
-
- if (pointerCount == 2) {
- if (confirmedScroll) {
- conn.sendMouseHighResScroll((short)(deltaY * SCROLL_SPEED_FACTOR));
- }
- } else {
- if (prefConfig.absoluteMouseMode) {
- conn.sendMouseMoveAsMousePosition(
- (short) deltaX,
- (short) deltaY,
- (short) targetView.getWidth(),
- (short) targetView.getHeight());
- }
- else {
- conn.sendMouseMove((short) deltaX, (short) deltaY);
- }
- }
-
- // If the scaling factor ended up rounding deltas to zero, wait until they are
- // non-zero to update lastTouch that way devices that report small touch events often
- // will work correctly
- if (deltaX != 0) {
- lastTouchX = eventX;
- }
- if (deltaY != 0) {
- lastTouchY = eventY;
- }
- }
- else {
- lastTouchX = eventX;
- lastTouchY = eventY;
- }
- }
-
- return true;
- }
-
- @Override
- public void cancelTouch() {
- cancelled = true;
-
- // Cancel the drag timer
- cancelDragTimer();
-
- // If it was a confirmed drag, we'll need to raise the button now
- if (confirmedDrag) {
- conn.sendMouseButtonUp(getMouseButtonIndex());
- }
- }
-
- @Override
- public boolean isCancelled() {
- return cancelled;
- }
-
- @Override
- public void setPointerCount(int pointerCount) {
- this.pointerCount = pointerCount;
-
- if (pointerCount > maxPointerCountInGesture) {
- maxPointerCountInGesture = pointerCount;
- }
- }
-}
+package com.limelight.binding.input.touch;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.view.View;
+
+import com.limelight.nvstream.NvConnection;
+import com.limelight.nvstream.input.MouseButtonPacket;
+import com.limelight.preferences.PreferenceConfiguration;
+
+public class RelativeTouchContext implements TouchContext {
+ private int lastTouchX = 0;
+ private int lastTouchY = 0;
+ private int originalTouchX = 0;
+ private int originalTouchY = 0;
+ private long originalTouchTime = 0;
+ private boolean cancelled;
+ private boolean confirmedMove;
+ private boolean confirmedDrag;
+ private boolean confirmedScroll;
+ private double distanceMoved;
+ private double xFactor, yFactor;
+ private int pointerCount;
+ private int maxPointerCountInGesture;
+
+ private final NvConnection conn;
+ private final int actionIndex;
+ private final int referenceWidth;
+ private final int referenceHeight;
+ private final View targetView;
+ private final PreferenceConfiguration prefConfig;
+ private final Handler handler;
+
+ private final Runnable dragTimerRunnable = new Runnable() {
+ @Override
+ public void run() {
+ // Check if someone already set move
+ if (confirmedMove) {
+ return;
+ }
+
+ // The drag should only be processed for the primary finger
+ if (actionIndex != maxPointerCountInGesture - 1) {
+ return;
+ }
+
+ // We haven't been cancelled before the timer expired so begin dragging
+ confirmedDrag = true;
+ conn.sendMouseButtonDown(getMouseButtonIndex());
+ }
+ };
+
+ // Indexed by MouseButtonPacket.BUTTON_XXX - 1
+ private final Runnable[] buttonUpRunnables = new Runnable[] {
+ new Runnable() {
+ @Override
+ public void run() {
+ conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT);
+ }
+ },
+ new Runnable() {
+ @Override
+ public void run() {
+ conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_MIDDLE);
+ }
+ },
+ new Runnable() {
+ @Override
+ public void run() {
+ conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT);
+ }
+ },
+ new Runnable() {
+ @Override
+ public void run() {
+ conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_X1);
+ }
+ },
+ new Runnable() {
+ @Override
+ public void run() {
+ conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_X2);
+ }
+ }
+ };
+
+ private static final int TAP_MOVEMENT_THRESHOLD = 20;
+ private static final int TAP_DISTANCE_THRESHOLD = 25;
+ private static final int TAP_TIME_THRESHOLD = 250;
+ private static final int DRAG_TIME_THRESHOLD = 650;
+
+ private static final int SCROLL_SPEED_FACTOR = 5;
+
+ public RelativeTouchContext(NvConnection conn, int actionIndex,
+ int referenceWidth, int referenceHeight,
+ View view, PreferenceConfiguration prefConfig)
+ {
+ this.conn = conn;
+ this.actionIndex = actionIndex;
+ this.referenceWidth = referenceWidth;
+ this.referenceHeight = referenceHeight;
+ this.targetView = view;
+ this.prefConfig = prefConfig;
+ this.handler = new Handler(Looper.getMainLooper());
+ }
+
+ @Override
+ public int getActionIndex()
+ {
+ return actionIndex;
+ }
+
+ private boolean isWithinTapBounds(int touchX, int touchY)
+ {
+ int xDelta = Math.abs(touchX - originalTouchX);
+ int yDelta = Math.abs(touchY - originalTouchY);
+ return xDelta <= TAP_MOVEMENT_THRESHOLD &&
+ yDelta <= TAP_MOVEMENT_THRESHOLD;
+ }
+
+ private boolean isTap(long eventTime)
+ {
+ if (confirmedDrag || confirmedMove || confirmedScroll) {
+ return false;
+ }
+
+ // If this input wasn't the last finger down, do not report
+ // a tap. This ensures we don't report duplicate taps for each
+ // finger on a multi-finger tap gesture
+ if (actionIndex + 1 != maxPointerCountInGesture) {
+ return false;
+ }
+
+ long timeDelta = eventTime - originalTouchTime;
+ return isWithinTapBounds(lastTouchX, lastTouchY) && timeDelta <= TAP_TIME_THRESHOLD;
+ }
+
+ private byte getMouseButtonIndex()
+ {
+ if (actionIndex == 1) {
+ return MouseButtonPacket.BUTTON_RIGHT;
+ }
+ else {
+ return MouseButtonPacket.BUTTON_LEFT;
+ }
+ }
+
+ @Override
+ public boolean touchDownEvent(int eventX, int eventY, long eventTime, boolean isNewFinger)
+ {
+ // Get the view dimensions to scale inputs on this touch
+ xFactor = referenceWidth / (double)targetView.getWidth();
+ yFactor = referenceHeight / (double)targetView.getHeight();
+
+ originalTouchX = lastTouchX = eventX;
+ originalTouchY = lastTouchY = eventY;
+
+ if (isNewFinger) {
+ maxPointerCountInGesture = pointerCount;
+ originalTouchTime = eventTime;
+ cancelled = confirmedDrag = confirmedMove = confirmedScroll = false;
+ distanceMoved = 0;
+
+ if (actionIndex == 0) {
+ // Start the timer for engaging a drag
+ startDragTimer();
+ }
+ }
+
+ return true;
+ }
+
+ @Override
+ public void touchUpEvent(int eventX, int eventY, long eventTime)
+ {
+ if (cancelled) {
+ return;
+ }
+
+ // Cancel the drag timer
+ cancelDragTimer();
+
+ byte buttonIndex = getMouseButtonIndex();
+
+ if (confirmedDrag) {
+ // Raise the button after a drag
+ conn.sendMouseButtonUp(buttonIndex);
+ }
+ else if (isTap(eventTime))
+ {
+ // Lower the mouse button
+ conn.sendMouseButtonDown(buttonIndex);
+
+ // Release the mouse button in 100ms to allow for apps that use polling
+ // to detect mouse button presses.
+ Runnable buttonUpRunnable = buttonUpRunnables[buttonIndex - 1];
+ handler.removeCallbacks(buttonUpRunnable);
+ handler.postDelayed(buttonUpRunnable, 100);
+ }
+ }
+
+ private void startDragTimer() {
+ cancelDragTimer();
+ handler.postDelayed(dragTimerRunnable, DRAG_TIME_THRESHOLD);
+ }
+
+ private void cancelDragTimer() {
+ handler.removeCallbacks(dragTimerRunnable);
+ }
+
+ private void checkForConfirmedMove(int eventX, int eventY) {
+ // If we've already confirmed something, get out now
+ if (confirmedMove || confirmedDrag) {
+ return;
+ }
+
+ // If it leaves the tap bounds before the drag time expires, it's a move.
+ if (!isWithinTapBounds(eventX, eventY)) {
+ confirmedMove = true;
+ cancelDragTimer();
+ return;
+ }
+
+ // Check if we've exceeded the maximum distance moved
+ distanceMoved += Math.sqrt(Math.pow(eventX - lastTouchX, 2) + Math.pow(eventY - lastTouchY, 2));
+ if (distanceMoved >= TAP_DISTANCE_THRESHOLD) {
+ confirmedMove = true;
+ cancelDragTimer();
+ return;
+ }
+ }
+
+ private void checkForConfirmedScroll() {
+ // Enter scrolling mode if we've already left the tap zone
+ // and we have 2 fingers on screen. Leave scroll mode if
+ // we no longer have 2 fingers on screen
+ confirmedScroll = (actionIndex == 0 && pointerCount == 2 && confirmedMove);
+ }
+
+ @Override
+ public boolean touchMoveEvent(int eventX, int eventY, long eventTime)
+ {
+ if (cancelled) {
+ return true;
+ }
+
+ if (eventX != lastTouchX || eventY != lastTouchY)
+ {
+ checkForConfirmedMove(eventX, eventY);
+ checkForConfirmedScroll();
+
+ // We only send moves and drags for the primary touch point
+ if (actionIndex == 0) {
+ int deltaX = eventX - lastTouchX;
+ int deltaY = eventY - lastTouchY;
+
+ // Scale the deltas based on the factors passed to our constructor
+ deltaX = (int) Math.round((double) Math.abs(deltaX) * xFactor);
+ deltaY = (int) Math.round((double) Math.abs(deltaY) * yFactor);
+
+ // Fix up the signs
+ if (eventX < lastTouchX) {
+ deltaX = -deltaX;
+ }
+ if (eventY < lastTouchY) {
+ deltaY = -deltaY;
+ }
+
+ if (pointerCount == 2) {
+ if (confirmedScroll) {
+ conn.sendMouseHighResScroll((short)(deltaY * SCROLL_SPEED_FACTOR));
+ }
+ } else {
+ if (prefConfig.absoluteMouseMode) {
+ conn.sendMouseMoveAsMousePosition(
+ (short) deltaX,
+ (short) deltaY,
+ (short) targetView.getWidth(),
+ (short) targetView.getHeight());
+ }
+ else {
+ conn.sendMouseMove((short) (deltaX*prefConfig.touchPadSensitivity*0.01f), (short) (deltaY*prefConfig.touchPadYSensitity*0.01f));
+ }
+ }
+
+ // If the scaling factor ended up rounding deltas to zero, wait until they are
+ // non-zero to update lastTouch that way devices that report small touch events often
+ // will work correctly
+ if (deltaX != 0) {
+ lastTouchX = eventX;
+ }
+ if (deltaY != 0) {
+ lastTouchY = eventY;
+ }
+ }
+ else {
+ lastTouchX = eventX;
+ lastTouchY = eventY;
+ }
+ }
+
+ return true;
+ }
+
+ @Override
+ public void cancelTouch() {
+ cancelled = true;
+
+ // Cancel the drag timer
+ cancelDragTimer();
+
+ // If it was a confirmed drag, we'll need to raise the button now
+ if (confirmedDrag) {
+ conn.sendMouseButtonUp(getMouseButtonIndex());
+ }
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return cancelled;
+ }
+
+ @Override
+ public void setPointerCount(int pointerCount) {
+ this.pointerCount = pointerCount;
+
+ if (pointerCount > maxPointerCountInGesture) {
+ maxPointerCountInGesture = pointerCount;
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/touch/TouchContext.java b/app/src/main/java/com/limelight/binding/input/touch/TouchContext.java
old mode 100644
new mode 100755
index a04388fd41..431ea18162
--- a/app/src/main/java/com/limelight/binding/input/touch/TouchContext.java
+++ b/app/src/main/java/com/limelight/binding/input/touch/TouchContext.java
@@ -1,11 +1,11 @@
-package com.limelight.binding.input.touch;
-
-public interface TouchContext {
- int getActionIndex();
- void setPointerCount(int pointerCount);
- boolean touchDownEvent(int eventX, int eventY, long eventTime, boolean isNewFinger);
- boolean touchMoveEvent(int eventX, int eventY, long eventTime);
- void touchUpEvent(int eventX, int eventY, long eventTime);
- void cancelTouch();
- boolean isCancelled();
-}
+package com.limelight.binding.input.touch;
+
+public interface TouchContext {
+ int getActionIndex();
+ void setPointerCount(int pointerCount);
+ boolean touchDownEvent(int eventX, int eventY, long eventTime, boolean isNewFinger);
+ boolean touchMoveEvent(int eventX, int eventY, long eventTime);
+ void touchUpEvent(int eventX, int eventY, long eventTime);
+ void cancelTouch();
+ boolean isCancelled();
+}
diff --git a/app/src/main/java/com/limelight/binding/input/touch/TrackpadContext.java b/app/src/main/java/com/limelight/binding/input/touch/TrackpadContext.java
new file mode 100644
index 0000000000..ec2a013846
--- /dev/null
+++ b/app/src/main/java/com/limelight/binding/input/touch/TrackpadContext.java
@@ -0,0 +1,265 @@
+package com.limelight.binding.input.touch;
+
+import android.os.Handler;
+import android.os.Looper;
+
+import com.limelight.nvstream.NvConnection;
+import com.limelight.nvstream.input.MouseButtonPacket;
+
+public class TrackpadContext implements TouchContext {
+ private int lastTouchX = 0;
+ private int lastTouchY = 0;
+ private int originalTouchX = 0;
+ private int originalTouchY = 0;
+ private long originalTouchTime = 0;
+ private boolean cancelled;
+ private boolean confirmedMove;
+ private boolean confirmedDrag;
+ private boolean confirmedScroll;
+ private double distanceMoved;
+ private int pointerCount;
+ private int maxPointerCountInGesture;
+ private boolean isClickPending;
+ private boolean isDblClickPending;
+
+ private final NvConnection conn;
+ private final int actionIndex;
+ private final Handler handler;
+
+ private boolean swapAxis = false;
+ private float sensitivityX = 1;
+ private float sensitivityY = 1;
+
+ private static final int TAP_MOVEMENT_THRESHOLD = 20;
+ private static final int TAP_DISTANCE_THRESHOLD = 25;
+ private static final int TAP_TIME_THRESHOLD = 230;
+ private static final int CLICK_RELEASE_DELAY = TAP_TIME_THRESHOLD;
+ private static final int SCROLL_SPEED_FACTOR_X = 2;
+ private static final int SCROLL_SPEED_FACTOR_Y = 3;
+
+ public TrackpadContext(NvConnection conn, int actionIndex) {
+ this.conn = conn;
+ this.actionIndex = actionIndex;
+ this.handler = new Handler(Looper.getMainLooper());
+ }
+
+ public TrackpadContext(NvConnection conn, int actionIndex, boolean swapAxis, int sensitivityX, int sensitivityY) {
+ this(conn, actionIndex);
+ this.swapAxis = swapAxis;
+ this.sensitivityX = (float) sensitivityX / 100;
+ this.sensitivityY = (float) sensitivityY / 100;
+ }
+
+ @Override
+ public int getActionIndex() {
+ return actionIndex;
+ }
+
+ private boolean isWithinTapBounds(int touchX, int touchY) {
+ int xDelta = Math.abs(touchX - originalTouchX);
+ int yDelta = Math.abs(touchY - originalTouchY);
+ return xDelta <= TAP_MOVEMENT_THRESHOLD && yDelta <= TAP_MOVEMENT_THRESHOLD;
+ }
+
+ private boolean isTap(long eventTime) {
+ if (confirmedDrag || confirmedMove || confirmedScroll) {
+ return false;
+ }
+
+ if (actionIndex + 1 != maxPointerCountInGesture) {
+ return false;
+ }
+
+ long timeDelta = eventTime - originalTouchTime;
+ return isWithinTapBounds(lastTouchX, lastTouchY) && timeDelta <= TAP_TIME_THRESHOLD;
+ }
+
+ private byte getMouseButtonIndex() {
+ if (pointerCount == 2) {
+ return MouseButtonPacket.BUTTON_RIGHT;
+ } else {
+ return MouseButtonPacket.BUTTON_LEFT;
+ }
+ }
+
+ @Override
+ public boolean touchDownEvent(int eventX, int eventY, long eventTime, boolean isNewFinger) {
+ originalTouchX = lastTouchX = eventX;
+ originalTouchY = lastTouchY = eventY;
+
+ if (isNewFinger) {
+ maxPointerCountInGesture = pointerCount;
+ originalTouchTime = eventTime;
+ cancelled = confirmedMove = confirmedScroll = false;
+ distanceMoved = 0;
+ if (isClickPending) {
+ isClickPending = false;
+ isDblClickPending = true;
+ confirmedDrag = true;
+ }
+ } else {
+ // Second finger released, should trigger right click immediately
+ if (pointerCount == 1 && !confirmedMove) {
+ conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_RIGHT);
+ conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT);
+ isClickPending = false;
+ isDblClickPending = false;
+ confirmedDrag = false;
+ }
+ }
+
+ return true;
+ }
+
+ @Override
+ public void touchUpEvent(int eventX, int eventY, long eventTime) {
+ if (cancelled) {
+ return;
+ }
+
+ byte buttonIndex = getMouseButtonIndex();
+
+ if (isDblClickPending) {
+ handler.removeCallbacksAndMessages(null);
+ conn.sendMouseButtonUp(buttonIndex);
+ conn.sendMouseButtonDown(buttonIndex);
+ conn.sendMouseButtonUp(buttonIndex);
+ isClickPending = false;
+ confirmedDrag = false;
+ }
+ else if (confirmedDrag) {
+ handler.removeCallbacksAndMessages(null);
+ conn.sendMouseButtonUp(buttonIndex);
+ confirmedDrag = false;
+ }
+ else if (isTap(eventTime)) {
+ conn.sendMouseButtonDown(buttonIndex);
+ isClickPending = true;
+
+ handler.removeCallbacksAndMessages(null);
+ handler.postDelayed(() -> {
+ if (isClickPending) {
+ conn.sendMouseButtonUp(buttonIndex);
+ isClickPending = false;
+ }
+ isDblClickPending = false;
+ }, CLICK_RELEASE_DELAY);
+ }
+ }
+
+ @Override
+ public boolean touchMoveEvent(int eventX, int eventY, long eventTime) {
+ if (cancelled) {
+ return true;
+ }
+
+ if (eventX != lastTouchX || eventY != lastTouchY) {
+ checkForConfirmedMove(eventX, eventY);
+
+ if (isDblClickPending) {
+ isDblClickPending = false;
+ confirmedDrag = true;
+ }
+
+ int absDeltaX = Math.abs(eventX - lastTouchX);
+ int absDeltaY = Math.abs(eventY - lastTouchY);
+
+ float deltaX, deltaY;
+ if (swapAxis) {
+ deltaY = (eventX < lastTouchX) ? -absDeltaX : absDeltaX;
+ deltaX = (eventY < lastTouchY) ? -absDeltaY : absDeltaY;
+ } else {
+ deltaX = (eventX < lastTouchX) ? -absDeltaX : absDeltaX;
+ deltaY = (eventY < lastTouchY) ? -absDeltaY : absDeltaY;
+ }
+
+ deltaX *= sensitivityX;
+ deltaY *= sensitivityY;
+
+ lastTouchX = eventX;
+ lastTouchY = eventY;
+
+ if (pointerCount == 1) {
+ conn.sendMouseMove((short) deltaX, (short) deltaY);
+ } else {
+ if (actionIndex == 1) {
+ if (confirmedDrag) {
+ conn.sendMouseMove((short) deltaX, (short) deltaY);
+ } else if (pointerCount == 2) {
+ checkForConfirmedScroll();
+ if (confirmedScroll) {
+ if (absDeltaX > absDeltaY) {
+ conn.sendMouseHighResHScroll((short)(-deltaX * SCROLL_SPEED_FACTOR_X));
+ if (absDeltaY * 1.05 > absDeltaX) {
+ conn.sendMouseHighResScroll((short)(deltaY * SCROLL_SPEED_FACTOR_Y));
+ }
+ } else {
+ conn.sendMouseHighResScroll((short)(deltaY * SCROLL_SPEED_FACTOR_Y));
+ if (absDeltaX * 1.05 >= absDeltaY) {
+ conn.sendMouseHighResHScroll((short)(-deltaX * SCROLL_SPEED_FACTOR_X));
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return true;
+ }
+
+ @Override
+ public void cancelTouch() {
+ cancelled = true;
+
+ if (confirmedDrag) {
+ conn.sendMouseButtonUp(getMouseButtonIndex());
+ }
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return cancelled;
+ }
+
+ @Override
+ public void setPointerCount(int pointerCount) {
+ if (pointerCount < this.pointerCount && confirmedDrag) {
+ conn.sendMouseButtonUp(getMouseButtonIndex());
+ confirmedDrag = false;
+ confirmedMove = false;
+ confirmedScroll = false;
+ isClickPending = false;
+ isDblClickPending = false;
+ }
+
+ this.pointerCount = pointerCount;
+
+ if (pointerCount > maxPointerCountInGesture) {
+ maxPointerCountInGesture = pointerCount;
+ }
+ }
+
+ private void checkForConfirmedMove(int eventX, int eventY) {
+ // If we've already confirmed something, get out now
+ if (confirmedMove || confirmedDrag) {
+ return;
+ }
+
+ // If it leaves the tap bounds before the drag time expires, it's a move.
+ if (!isWithinTapBounds(eventX, eventY)) {
+ confirmedMove = true;
+ return;
+ }
+
+ // Check if we've exceeded the maximum distance moved
+ distanceMoved += Math.sqrt(Math.pow(eventX - lastTouchX, 2) + Math.pow(eventY - lastTouchY, 2));
+ if (distanceMoved >= TAP_DISTANCE_THRESHOLD) {
+ confirmedMove = true;
+ }
+ }
+
+ private void checkForConfirmedScroll() {
+ confirmedScroll = (actionIndex == 1 && pointerCount == 2 && confirmedMove);
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/AnalogStick.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/AnalogStick.java
old mode 100644
new mode 100755
index ceec138dc7..f4f26830a6
--- a/app/src/main/java/com/limelight/binding/input/virtual_controller/AnalogStick.java
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/AnalogStick.java
@@ -1,349 +1,349 @@
-/**
- * Created by Karim Mreisi.
- */
-
-package com.limelight.binding.input.virtual_controller;
-
-import android.content.Context;
-import android.graphics.Canvas;
-import android.graphics.Color;
-import android.graphics.Paint;
-import android.view.MotionEvent;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * This is a analog stick on screen element. It is used to get 2-Axis user input.
- */
-public class AnalogStick extends VirtualControllerElement {
-
- /**
- * outer radius size in percent of the ui element
- */
- public static final int SIZE_RADIUS_COMPLETE = 90;
- /**
- * analog stick size in percent of the ui element
- */
- public static final int SIZE_RADIUS_ANALOG_STICK = 90;
- /**
- * dead zone size in percent of the ui element
- */
- public static final int SIZE_RADIUS_DEADZONE = 90;
- /**
- * time frame for a double click
- */
- public final static long timeoutDoubleClick = 350;
-
- /**
- * touch down time until the deadzone is lifted to allow precise movements with the analog sticks
- */
- public final static long timeoutDeadzone = 150;
-
- /**
- * Listener interface to update registered observers.
- */
- public interface AnalogStickListener {
-
- /**
- * onMovement event will be fired on real analog stick movement (outside of the deadzone).
- *
- * @param x horizontal position, value from -1.0 ... 0 .. 1.0
- * @param y vertical position, value from -1.0 ... 0 .. 1.0
- */
- void onMovement(float x, float y);
-
- /**
- * onClick event will be fired on click on the analog stick
- */
- void onClick();
-
- /**
- * onDoubleClick event will be fired on a double click in a short time frame on the analog
- * stick.
- */
- void onDoubleClick();
-
- /**
- * onRevoke event will be fired on unpress of the analog stick.
- */
- void onRevoke();
- }
-
- /**
- * Movement states of the analog sick.
- */
- private enum STICK_STATE {
- NO_MOVEMENT,
- MOVED_IN_DEAD_ZONE,
- MOVED_ACTIVE
- }
-
- /**
- * Click type states.
- */
- private enum CLICK_STATE {
- SINGLE,
- DOUBLE
- }
-
- /**
- * configuration if the analog stick should be displayed as circle or square
- */
- private boolean circle_stick = true; // TODO: implement square sick for simulations
-
- /**
- * outer radius, this size will be automatically updated on resize
- */
- private float radius_complete = 0;
- /**
- * analog stick radius, this size will be automatically updated on resize
- */
- private float radius_analog_stick = 0;
- /**
- * dead zone radius, this size will be automatically updated on resize
- */
- private float radius_dead_zone = 0;
-
- /**
- * horizontal position in relation to the center of the element
- */
- private float relative_x = 0;
- /**
- * vertical position in relation to the center of the element
- */
- private float relative_y = 0;
-
-
- private double movement_radius = 0;
- private double movement_angle = 0;
-
- private float position_stick_x = 0;
- private float position_stick_y = 0;
-
- private final Paint paint = new Paint();
-
- private STICK_STATE stick_state = STICK_STATE.NO_MOVEMENT;
- private CLICK_STATE click_state = CLICK_STATE.SINGLE;
-
- private List listeners = new ArrayList<>();
- private long timeLastClick = 0;
-
- private static double getMovementRadius(float x, float y) {
- return Math.sqrt(x * x + y * y);
- }
-
- private static double getAngle(float way_x, float way_y) {
- // prevent divisions by zero for corner cases
- if (way_x == 0) {
- return way_y < 0 ? Math.PI : 0;
- } else if (way_y == 0) {
- if (way_x > 0) {
- return Math.PI * 3 / 2;
- } else if (way_x < 0) {
- return Math.PI * 1 / 2;
- }
- }
- // return correct calculated angle for each quadrant
- if (way_x > 0) {
- if (way_y < 0) {
- // first quadrant
- return 3 * Math.PI / 2 + Math.atan((double) (-way_y / way_x));
- } else {
- // second quadrant
- return Math.PI + Math.atan((double) (way_x / way_y));
- }
- } else {
- if (way_y > 0) {
- // third quadrant
- return Math.PI / 2 + Math.atan((double) (way_y / -way_x));
- } else {
- // fourth quadrant
- return 0 + Math.atan((double) (-way_x / -way_y));
- }
- }
- }
-
- public AnalogStick(VirtualController controller, Context context, int elementId) {
- super(controller, context, elementId);
- // reset stick position
- position_stick_x = getWidth() / 2;
- position_stick_y = getHeight() / 2;
- }
-
- public void addAnalogStickListener(AnalogStickListener listener) {
- listeners.add(listener);
- }
-
- private void notifyOnMovement(float x, float y) {
- _DBG("movement x: " + x + " movement y: " + y);
- // notify listeners
- for (AnalogStickListener listener : listeners) {
- listener.onMovement(x, y);
- }
- }
-
- private void notifyOnClick() {
- _DBG("click");
- // notify listeners
- for (AnalogStickListener listener : listeners) {
- listener.onClick();
- }
- }
-
- private void notifyOnDoubleClick() {
- _DBG("double click");
- // notify listeners
- for (AnalogStickListener listener : listeners) {
- listener.onDoubleClick();
- }
- }
-
- private void notifyOnRevoke() {
- _DBG("revoke");
- // notify listeners
- for (AnalogStickListener listener : listeners) {
- listener.onRevoke();
- }
- }
-
- @Override
- protected void onSizeChanged(int w, int h, int oldw, int oldh) {
- // calculate new radius sizes depending
- radius_complete = getPercent(getCorrectWidth() / 2, 100) - 2 * getDefaultStrokeWidth();
- radius_dead_zone = getPercent(getCorrectWidth() / 2, 30);
- radius_analog_stick = getPercent(getCorrectWidth() / 2, 20);
-
- super.onSizeChanged(w, h, oldw, oldh);
- }
-
- @Override
- protected void onElementDraw(Canvas canvas) {
- // set transparent background
- canvas.drawColor(Color.TRANSPARENT);
-
- paint.setStyle(Paint.Style.STROKE);
- paint.setStrokeWidth(getDefaultStrokeWidth());
-
- // draw outer circle
- if (!isPressed() || click_state == CLICK_STATE.SINGLE) {
- paint.setColor(getDefaultColor());
- } else {
- paint.setColor(pressedColor);
- }
- canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_complete, paint);
-
- paint.setColor(getDefaultColor());
- // draw dead zone
- canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_dead_zone, paint);
-
- // draw stick depending on state
- switch (stick_state) {
- case NO_MOVEMENT: {
- paint.setColor(getDefaultColor());
- canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_analog_stick, paint);
- break;
- }
- case MOVED_IN_DEAD_ZONE:
- case MOVED_ACTIVE: {
- paint.setColor(pressedColor);
- canvas.drawCircle(position_stick_x, position_stick_y, radius_analog_stick, paint);
- break;
- }
- }
- }
-
- private void updatePosition(long eventTime) {
- // get 100% way
- float complete = radius_complete - radius_analog_stick;
-
- // calculate relative way
- float correlated_y = (float) (Math.sin(Math.PI / 2 - movement_angle) * (movement_radius));
- float correlated_x = (float) (Math.cos(Math.PI / 2 - movement_angle) * (movement_radius));
-
- // update positions
- position_stick_x = getWidth() / 2 - correlated_x;
- position_stick_y = getHeight() / 2 - correlated_y;
-
- // Stay active even if we're back in the deadzone because we know the user is actively
- // giving analog stick input and we don't want to snap back into the deadzone.
- // We also release the deadzone if the user keeps the stick pressed for a bit to allow
- // them to make precise movements.
- stick_state = (stick_state == STICK_STATE.MOVED_ACTIVE ||
- eventTime - timeLastClick > timeoutDeadzone ||
- movement_radius > radius_dead_zone) ?
- STICK_STATE.MOVED_ACTIVE : STICK_STATE.MOVED_IN_DEAD_ZONE;
-
- // trigger move event if state active
- if (stick_state == STICK_STATE.MOVED_ACTIVE) {
- notifyOnMovement(-correlated_x / complete, correlated_y / complete);
- }
- }
-
- @Override
- public boolean onElementTouchEvent(MotionEvent event) {
- // save last click state
- CLICK_STATE lastClickState = click_state;
-
- // get absolute way for each axis
- relative_x = -(getWidth() / 2 - event.getX());
- relative_y = -(getHeight() / 2 - event.getY());
-
- // get radius and angel of movement from center
- movement_radius = getMovementRadius(relative_x, relative_y);
- movement_angle = getAngle(relative_x, relative_y);
-
- // pass touch event to parent if out of outer circle
- if (movement_radius > radius_complete && !isPressed())
- return false;
-
- // chop radius if out of outer circle or near the edge
- if (movement_radius > (radius_complete - radius_analog_stick)) {
- movement_radius = radius_complete - radius_analog_stick;
- }
-
- // handle event depending on action
- switch (event.getActionMasked()) {
- // down event (touch event)
- case MotionEvent.ACTION_DOWN: {
- // set to dead zoned, will be corrected in update position if necessary
- stick_state = STICK_STATE.MOVED_IN_DEAD_ZONE;
- // check for double click
- if (lastClickState == CLICK_STATE.SINGLE &&
- event.getEventTime() - timeLastClick <= timeoutDoubleClick) {
- click_state = CLICK_STATE.DOUBLE;
- notifyOnDoubleClick();
- } else {
- click_state = CLICK_STATE.SINGLE;
- notifyOnClick();
- }
- // reset last click timestamp
- timeLastClick = event.getEventTime();
- // set item pressed and update
- setPressed(true);
- break;
- }
- // up event (revoke touch)
- case MotionEvent.ACTION_CANCEL:
- case MotionEvent.ACTION_UP: {
- setPressed(false);
- break;
- }
- }
-
- if (isPressed()) {
- // when is pressed calculate new positions (will trigger movement if necessary)
- updatePosition(event.getEventTime());
- } else {
- stick_state = STICK_STATE.NO_MOVEMENT;
- notifyOnRevoke();
-
- // not longer pressed reset analog stick
- notifyOnMovement(0, 0);
- }
- // refresh view
- invalidate();
- // accept the touch event
- return true;
- }
-}
+/**
+ * Created by Karim Mreisi.
+ */
+
+package com.limelight.binding.input.virtual_controller;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.view.MotionEvent;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * This is a analog stick on screen element. It is used to get 2-Axis user input.
+ */
+public class AnalogStick extends VirtualControllerElement {
+
+ /**
+ * outer radius size in percent of the ui element
+ */
+ public static final int SIZE_RADIUS_COMPLETE = 90;
+ /**
+ * analog stick size in percent of the ui element
+ */
+ public static final int SIZE_RADIUS_ANALOG_STICK = 90;
+ /**
+ * dead zone size in percent of the ui element
+ */
+ public static final int SIZE_RADIUS_DEADZONE = 90;
+ /**
+ * time frame for a double click
+ */
+ public final static long timeoutDoubleClick = 350;
+
+ /**
+ * touch down time until the deadzone is lifted to allow precise movements with the analog sticks
+ */
+ public final static long timeoutDeadzone = 150;
+
+ /**
+ * Listener interface to update registered observers.
+ */
+ public interface AnalogStickListener {
+
+ /**
+ * onMovement event will be fired on real analog stick movement (outside of the deadzone).
+ *
+ * @param x horizontal position, value from -1.0 ... 0 .. 1.0
+ * @param y vertical position, value from -1.0 ... 0 .. 1.0
+ */
+ void onMovement(float x, float y);
+
+ /**
+ * onClick event will be fired on click on the analog stick
+ */
+ void onClick();
+
+ /**
+ * onDoubleClick event will be fired on a double click in a short time frame on the analog
+ * stick.
+ */
+ void onDoubleClick();
+
+ /**
+ * onRevoke event will be fired on unpress of the analog stick.
+ */
+ void onRevoke();
+ }
+
+ /**
+ * Movement states of the analog sick.
+ */
+ private enum STICK_STATE {
+ NO_MOVEMENT,
+ MOVED_IN_DEAD_ZONE,
+ MOVED_ACTIVE
+ }
+
+ /**
+ * Click type states.
+ */
+ private enum CLICK_STATE {
+ SINGLE,
+ DOUBLE
+ }
+
+ /**
+ * configuration if the analog stick should be displayed as circle or square
+ */
+ private boolean circle_stick = true; // TODO: implement square sick for simulations
+
+ /**
+ * outer radius, this size will be automatically updated on resize
+ */
+ private float radius_complete = 0;
+ /**
+ * analog stick radius, this size will be automatically updated on resize
+ */
+ private float radius_analog_stick = 0;
+ /**
+ * dead zone radius, this size will be automatically updated on resize
+ */
+ private float radius_dead_zone = 0;
+
+ /**
+ * horizontal position in relation to the center of the element
+ */
+ private float relative_x = 0;
+ /**
+ * vertical position in relation to the center of the element
+ */
+ private float relative_y = 0;
+
+
+ private double movement_radius = 0;
+ private double movement_angle = 0;
+
+ private float position_stick_x = 0;
+ private float position_stick_y = 0;
+
+ private final Paint paint = new Paint();
+
+ private STICK_STATE stick_state = STICK_STATE.NO_MOVEMENT;
+ private CLICK_STATE click_state = CLICK_STATE.SINGLE;
+
+ private List listeners = new ArrayList<>();
+ private long timeLastClick = 0;
+
+ private static double getMovementRadius(float x, float y) {
+ return Math.sqrt(x * x + y * y);
+ }
+
+ private static double getAngle(float way_x, float way_y) {
+ // prevent divisions by zero for corner cases
+ if (way_x == 0) {
+ return way_y < 0 ? Math.PI : 0;
+ } else if (way_y == 0) {
+ if (way_x > 0) {
+ return Math.PI * 3 / 2;
+ } else if (way_x < 0) {
+ return Math.PI * 1 / 2;
+ }
+ }
+ // return correct calculated angle for each quadrant
+ if (way_x > 0) {
+ if (way_y < 0) {
+ // first quadrant
+ return 3 * Math.PI / 2 + Math.atan((double) (-way_y / way_x));
+ } else {
+ // second quadrant
+ return Math.PI + Math.atan((double) (way_x / way_y));
+ }
+ } else {
+ if (way_y > 0) {
+ // third quadrant
+ return Math.PI / 2 + Math.atan((double) (way_y / -way_x));
+ } else {
+ // fourth quadrant
+ return 0 + Math.atan((double) (-way_x / -way_y));
+ }
+ }
+ }
+
+ public AnalogStick(VirtualController controller, Context context, int elementId) {
+ super(controller, context, elementId);
+ // reset stick position
+ position_stick_x = getWidth() / 2;
+ position_stick_y = getHeight() / 2;
+ }
+
+ public void addAnalogStickListener(AnalogStickListener listener) {
+ listeners.add(listener);
+ }
+
+ private void notifyOnMovement(float x, float y) {
+ _DBG("movement x: " + x + " movement y: " + y);
+ // notify listeners
+ for (AnalogStickListener listener : listeners) {
+ listener.onMovement(x, y);
+ }
+ }
+
+ private void notifyOnClick() {
+ _DBG("click");
+ // notify listeners
+ for (AnalogStickListener listener : listeners) {
+ listener.onClick();
+ }
+ }
+
+ private void notifyOnDoubleClick() {
+ _DBG("double click");
+ // notify listeners
+ for (AnalogStickListener listener : listeners) {
+ listener.onDoubleClick();
+ }
+ }
+
+ private void notifyOnRevoke() {
+ _DBG("revoke");
+ // notify listeners
+ for (AnalogStickListener listener : listeners) {
+ listener.onRevoke();
+ }
+ }
+
+ @Override
+ protected void onSizeChanged(int w, int h, int oldw, int oldh) {
+ // calculate new radius sizes depending
+ radius_complete = getPercent(getCorrectWidth() / 2, 100) - 2 * getDefaultStrokeWidth();
+ radius_dead_zone = getPercent(getCorrectWidth() / 2, 30);
+ radius_analog_stick = getPercent(getCorrectWidth() / 2, 20);
+
+ super.onSizeChanged(w, h, oldw, oldh);
+ }
+
+ @Override
+ protected void onElementDraw(Canvas canvas) {
+ // set transparent background
+ canvas.drawColor(Color.TRANSPARENT);
+
+ paint.setStyle(Paint.Style.STROKE);
+ paint.setStrokeWidth(getDefaultStrokeWidth());
+
+ // draw outer circle
+ if (!isPressed() || click_state == CLICK_STATE.SINGLE) {
+ paint.setColor(getDefaultColor());
+ } else {
+ paint.setColor(pressedColor);
+ }
+ canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_complete, paint);
+
+ paint.setColor(getDefaultColor());
+ // draw dead zone
+ canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_dead_zone, paint);
+
+ // draw stick depending on state
+ switch (stick_state) {
+ case NO_MOVEMENT: {
+ paint.setColor(getDefaultColor());
+ canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_analog_stick, paint);
+ break;
+ }
+ case MOVED_IN_DEAD_ZONE:
+ case MOVED_ACTIVE: {
+ paint.setColor(pressedColor);
+ canvas.drawCircle(position_stick_x, position_stick_y, radius_analog_stick, paint);
+ break;
+ }
+ }
+ }
+
+ private void updatePosition(long eventTime) {
+ // get 100% way
+ float complete = radius_complete - radius_analog_stick;
+
+ // calculate relative way
+ float correlated_y = (float) (Math.sin(Math.PI / 2 - movement_angle) * (movement_radius));
+ float correlated_x = (float) (Math.cos(Math.PI / 2 - movement_angle) * (movement_radius));
+
+ // update positions
+ position_stick_x = getWidth() / 2 - correlated_x;
+ position_stick_y = getHeight() / 2 - correlated_y;
+
+ // Stay active even if we're back in the deadzone because we know the user is actively
+ // giving analog stick input and we don't want to snap back into the deadzone.
+ // We also release the deadzone if the user keeps the stick pressed for a bit to allow
+ // them to make precise movements.
+ stick_state = (stick_state == STICK_STATE.MOVED_ACTIVE ||
+ eventTime - timeLastClick > timeoutDeadzone ||
+ movement_radius > radius_dead_zone) ?
+ STICK_STATE.MOVED_ACTIVE : STICK_STATE.MOVED_IN_DEAD_ZONE;
+
+ // trigger move event if state active
+ if (stick_state == STICK_STATE.MOVED_ACTIVE) {
+ notifyOnMovement(-correlated_x / complete, correlated_y / complete);
+ }
+ }
+
+ @Override
+ public boolean onElementTouchEvent(MotionEvent event) {
+ // save last click state
+ CLICK_STATE lastClickState = click_state;
+
+ // get absolute way for each axis
+ relative_x = -(getWidth() / 2 - event.getX());
+ relative_y = -(getHeight() / 2 - event.getY());
+
+ // get radius and angel of movement from center
+ movement_radius = getMovementRadius(relative_x, relative_y);
+ movement_angle = getAngle(relative_x, relative_y);
+
+ // pass touch event to parent if out of outer circle
+ if (movement_radius > radius_complete && !isPressed())
+ return false;
+
+ // chop radius if out of outer circle or near the edge
+ if (movement_radius > (radius_complete - radius_analog_stick)) {
+ movement_radius = radius_complete - radius_analog_stick;
+ }
+
+ // handle event depending on action
+ switch (event.getActionMasked()) {
+ // down event (touch event)
+ case MotionEvent.ACTION_DOWN: {
+ // set to dead zoned, will be corrected in update position if necessary
+ stick_state = STICK_STATE.MOVED_IN_DEAD_ZONE;
+ // check for double click
+ if (lastClickState == CLICK_STATE.SINGLE &&
+ event.getEventTime() - timeLastClick <= timeoutDoubleClick) {
+ click_state = CLICK_STATE.DOUBLE;
+ notifyOnDoubleClick();
+ } else {
+ click_state = CLICK_STATE.SINGLE;
+ notifyOnClick();
+ }
+ // reset last click timestamp
+ timeLastClick = event.getEventTime();
+ // set item pressed and update
+ setPressed(true);
+ break;
+ }
+ // up event (revoke touch)
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP: {
+ setPressed(false);
+ break;
+ }
+ }
+
+ if (isPressed()) {
+ // when is pressed calculate new positions (will trigger movement if necessary)
+ updatePosition(event.getEventTime());
+ } else {
+ stick_state = STICK_STATE.NO_MOVEMENT;
+ notifyOnRevoke();
+
+ // not longer pressed reset analog stick
+ notifyOnMovement(0, 0);
+ }
+ // refresh view
+ invalidate();
+ // accept the touch event
+ return true;
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/AnalogStickFree.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/AnalogStickFree.java
new file mode 100755
index 0000000000..fc0d8ce7d0
--- /dev/null
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/AnalogStickFree.java
@@ -0,0 +1,512 @@
+/**
+ * Created by Karim Mreisi.
+ */
+
+package com.limelight.binding.input.virtual_controller;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.view.MotionEvent;
+
+import com.limelight.preferences.PreferenceConfiguration;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * This is a analog stick on screen element. It is used to get 2-Axis user input.
+ */
+public class AnalogStickFree extends VirtualControllerElement {
+
+ /**
+ * outer radius size in percent of the ui element
+ */
+ public static final int SIZE_RADIUS_COMPLETE = 90;
+ /**
+ * analog stick size in percent of the ui element
+ */
+ public static final int SIZE_RADIUS_ANALOG_STICK = 90;
+ /**
+ * dead zone size in percent of the ui element
+ */
+ public static final int SIZE_RADIUS_DEADZONE = 90;
+ /**
+ * time frame for a double click
+ */
+ public final static long timeoutDoubleClick = 350;
+
+ /**
+ * touch down time until the deadzone is lifted to allow precise movements with the analog sticks
+ */
+ public final static long timeoutDeadzone = 150;
+
+ /**
+ * Listener interface to update registered observers.
+ */
+ public interface AnalogStickListener {
+
+ /**
+ * onMovement event will be fired on real analog stick movement (outside of the deadzone).
+ *
+ * @param x horizontal position, value from -1.0 ... 0 .. 1.0
+ * @param y vertical position, value from -1.0 ... 0 .. 1.0
+ */
+ void onMovement(float x, float y);
+
+ /**
+ * onClick event will be fired on click on the analog stick
+ */
+ void onClick();
+
+ /**
+ * onDoubleClick event will be fired on a double click in a short time frame on the analog
+ * stick.
+ */
+ void onDoubleClick();
+
+ /**
+ * onRevoke event will be fired on unpress of the analog stick.
+ */
+ void onRevoke();
+ }
+
+ /**
+ * Movement states of the analog sick.
+ */
+ private enum STICK_STATE {
+ NO_MOVEMENT,
+ MOVED_IN_DEAD_ZONE,
+ MOVED_ACTIVE
+ }
+
+ /**
+ * Click type states.
+ */
+ private enum CLICK_STATE {
+ SINGLE,
+ DOUBLE
+ }
+
+ /**
+ * configuration if the analog stick should be displayed as circle or square
+ */
+ private boolean circle_stick = true; // TODO: implement square sick for simulations
+
+ /**
+ * outer radius, this size will be automatically updated on resize
+ */
+ private float radius_complete = 0;
+ /**
+ * analog stick radius, this size will be automatically updated on resize
+ */
+ private float radius_analog_stick = 0;
+ /**
+ * dead zone radius, this size will be automatically updated on resize
+ */
+ private float radius_dead_zone = 0;
+
+ /**
+ * horizontal position in relation to the center of the element
+ */
+ private float relative_x = 0;
+ /**
+ * vertical position in relation to the center of the element
+ */
+ private float relative_y = 0;
+
+ private boolean bIsFingerOnScreen = false;
+
+ private double movement_radius = 0;
+ private double movement_angle = 0;
+
+ private float position_stick_x = 0;
+ private float position_stick_y = 0;
+
+ private final Paint paint = new Paint();
+
+ private STICK_STATE stick_state = STICK_STATE.NO_MOVEMENT;
+ private CLICK_STATE click_state = CLICK_STATE.SINGLE;
+
+ private List listeners = new ArrayList<>();
+ private long timeLastClick = 0;
+
+ private int touchID;
+ private float touchStartX;
+ private float touchStartY;
+ private float touchX;
+ private float touchY;
+
+ private float touchMaxDistance = 120;
+ private float touchDeadZone = 20;
+ private float fDeadzoneSave = 0.01f;
+
+ protected String strStickSide = "L";
+
+ private static double getMovementRadius(float x, float y) {
+ return Math.sqrt(x * x + y * y);
+ }
+
+ private static double getAngle(float way_x, float way_y) {
+ // prevent divisions by zero for corner cases
+ if (way_x == 0) {
+ return way_y < 0 ? Math.PI : 0;
+ } else if (way_y == 0) {
+ if (way_x > 0) {
+ return Math.PI * 3 / 2;
+ } else if (way_x < 0) {
+ return Math.PI * 1 / 2;
+ }
+ }
+ // return correct calculated angle for each quadrant
+ if (way_x > 0) {
+ if (way_y < 0) {
+ // first quadrant
+ return 3 * Math.PI / 2 + Math.atan((double) (-way_y / way_x));
+ } else {
+ // second quadrant
+ return Math.PI + Math.atan((double) (way_x / way_y));
+ }
+ } else {
+ if (way_y > 0) {
+ // third quadrant
+ return Math.PI / 2 + Math.atan((double) (way_y / -way_x));
+ } else {
+ // fourth quadrant
+ return 0 + Math.atan((double) (-way_x / -way_y));
+ }
+ }
+ }
+
+ public AnalogStickFree(VirtualController controller, Context context, int elementId) {
+ super(controller, context, elementId);
+ // reset stick position
+ position_stick_x = getWidth() / 2;
+ position_stick_y = getHeight() / 2;
+ }
+
+ public void addAnalogStickListener(AnalogStickListener listener) {
+ listeners.add(listener);
+ }
+
+ private void notifyOnMovement(float x, float y) {
+ _DBG("movement x: " + x + " movement y: " + y);
+ // notify listeners
+ for (AnalogStickListener listener : listeners) {
+ listener.onMovement(x, y);
+ }
+ }
+
+ private void notifyOnClick() {
+ _DBG("click");
+ // notify listeners
+ for (AnalogStickListener listener : listeners) {
+ listener.onClick();
+ }
+ }
+
+ private void notifyOnDoubleClick() {
+ _DBG("double click");
+ // notify listeners
+ for (AnalogStickListener listener : listeners) {
+ listener.onDoubleClick();
+ }
+ }
+
+ private void notifyOnRevoke() {
+ _DBG("revoke");
+ // notify listeners
+ for (AnalogStickListener listener : listeners) {
+ listener.onRevoke();
+ }
+ }
+
+ @Override
+ protected void onSizeChanged(int w, int h, int oldw, int oldh) {
+ // calculate new radius sizes depending
+ radius_complete = getPercent(getCorrectWidth() / 2, 100) - 2 * getDefaultStrokeWidth();
+ radius_dead_zone = getPercent(getCorrectWidth() / 2, 30);
+ radius_analog_stick = getPercent(getCorrectWidth() / 2, 20);
+
+ super.onSizeChanged(w, h, oldw, oldh);
+ }
+
+
+ @Override
+ protected void onElementDraw(Canvas canvas) {
+ boolean bIsMoving = virtualController.getControllerMode() == VirtualController.ControllerMode.MoveButtons;
+ boolean bIsResizing = virtualController.getControllerMode() == VirtualController.ControllerMode.ResizeButtons;
+ boolean bIsEnable = virtualController.getControllerMode() == VirtualController.ControllerMode.DisableEnableButtons;
+
+ if (bIsMoving || bIsResizing || bIsEnable) {
+ canvas.drawColor(getDefaultColor());
+ paint.setColor(Color.WHITE);
+ int nWidth = getWidth();
+ int nHeight = getHeight();
+
+ paint.setStyle(Paint.Style.FILL);
+ paint.setTextSize(Math.min(nWidth, nHeight) / 2);
+ canvas.drawText(strStickSide, nWidth / 2, nHeight / 2, paint);
+ }
+
+ paint.setStyle(Paint.Style.STROKE);
+ paint.setStrokeWidth(getDefaultStrokeWidth());
+
+ if (bIsFingerOnScreen) {
+ // set transparent background
+ canvas.drawColor(Color.TRANSPARENT);
+ //canvas.drawCircle(touchX, touchY, 50, paint);
+
+ // draw outer circle
+// if (!isPressed() || click_state == CLICK_STATE.SINGLE) {
+// //paint.setColor(getDefaultColor());
+// } else {
+// //paint.setColor(pressedColor);
+// }
+// //canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_complete, paint);
+//
+// //paint.setColor(getDefaultColor());
+// // draw dead zone
+// //canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_dead_zone, paint);
+ // draw stick depending on state
+ switch (stick_state) {
+ case NO_MOVEMENT: {
+ paint.setColor(Color.MAGENTA);
+ canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_analog_stick, paint);
+ break;
+ }
+
+ case MOVED_IN_DEAD_ZONE:
+ case MOVED_ACTIVE: {
+
+ paint.setColor(bgCircleColor);
+
+ paint.setStyle(Paint.Style.FILL_AND_STROKE);
+
+ canvas.drawCircle(touchStartX, touchStartY, radius_complete, paint);
+
+ paint.setStyle(Paint.Style.STROKE);
+
+ paint.setColor(strokeCircleColor);
+
+ // draw start touch point circle
+ canvas.drawCircle(touchStartX, touchStartY, radius_dead_zone, paint);
+ //paint.setColor(Color.RED);
+ // line from start point to current touch point
+// canvas.drawLine(touchStartX, touchStartY, position_stick_x, position_stick_y, paint);
+
+ //paint.setColor(pressedColor);
+ canvas.drawCircle(position_stick_x, position_stick_y, radius_analog_stick, paint);
+// float distance = (float) Math.sqrt(Math.pow(touchStartY - position_stick_y, 2) + Math.pow(touchStartX - position_stick_x, 2));
+
+// canvas.drawCircle(touchStartX, touchStartY, touchMaxDistance, paint);
+ break;
+ }
+ }
+ }
+ }
+
+ private int bgCircleColor=0x2BF5F5F9;
+ private int strokeCircleColor=0xFF8F8F8F;
+ public void setBgOpacity() {
+ int hexOpacity = PreferenceConfiguration.readPreferences(getContext()).enableNewAnalogStickOpacity * 255 / 100;
+ this.bgCircleColor = (hexOpacity << 24) | (bgCircleColor & 0x00FFFFFF);
+ this.strokeCircleColor = (hexOpacity << 24) | (pressedColor & 0x00FFFFFF);
+ invalidate();
+ }
+ @Override
+ public void setOpacity(int opacity) {
+ super.setOpacity(opacity);
+ setBgOpacity();
+ }
+
+ private void updatePosition(long eventTime) {
+ float complete = radius_complete - radius_analog_stick;
+
+ // calculate relative way
+ float correlated_y = (float) (Math.sin(Math.PI / 2 - movement_angle) * (movement_radius));
+ float correlated_x = (float) (Math.cos(Math.PI / 2 - movement_angle) * (movement_radius));
+
+ // update positions
+ position_stick_x = touchStartX - correlated_x;
+ position_stick_y = touchStartY - correlated_y;
+
+ // Stay active even if we're back in the deadzone because we know the user is actively
+ // giving analog stick input and we don't want to snap back into the deadzone.
+ // We also release the deadzone if the user keeps the stick pressed for a bit to allow
+ // them to make precise movements.
+ stick_state = (stick_state == AnalogStickFree.STICK_STATE.MOVED_ACTIVE ||
+ eventTime - timeLastClick > timeoutDeadzone ||
+ movement_radius > radius_dead_zone) ?
+ AnalogStickFree.STICK_STATE.MOVED_ACTIVE : AnalogStickFree.STICK_STATE.MOVED_IN_DEAD_ZONE;
+
+ // trigger move event if state active
+ if (stick_state == AnalogStickFree.STICK_STATE.MOVED_ACTIVE) {
+ notifyOnMovement(-correlated_x / complete, correlated_y / complete);
+ }
+ }
+
+ @Override
+ public boolean onElementTouchEvent(MotionEvent event) {
+ // save last click state
+ CLICK_STATE lastClickState = click_state;
+
+ // get absolute way for each axis
+ relative_x = -(touchStartX - event.getX());
+ relative_y = -(touchStartY - event.getY());
+
+ // get radius and angel of movement from center
+ movement_radius = getMovementRadius(relative_x, relative_y);
+ movement_angle = getAngle(relative_x, relative_y);
+
+ // pass touch event to parent if out of outer circle
+// if (movement_radius > radius_complete && !isPressed())
+// return false;
+
+ // chop radius if out of outer circle or near the edge
+ if (movement_radius > (radius_complete - radius_analog_stick)) {
+ movement_radius = radius_complete - radius_analog_stick;
+ }
+ // handle event depending on action
+ switch (event.getActionMasked()) {
+ // down event (touch event)
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_POINTER_DOWN: {
+ if (!bIsFingerOnScreen) {
+ touchID = event.getPointerId(event.getActionIndex());
+ touchStartX = event.getX();
+ touchStartY = event.getY();
+ bIsFingerOnScreen = true;
+ }
+
+ if (touchID == event.getPointerId(event.getActionIndex())) {
+ touchX = event.getX();
+ touchY = event.getY();
+
+ // set to dead zoned, will be corrected in update position if necessary
+ stick_state = STICK_STATE.MOVED_IN_DEAD_ZONE;
+ // check for double click
+ if (lastClickState == CLICK_STATE.SINGLE &&
+ timeLastClick + timeoutDoubleClick > System.currentTimeMillis()) {
+ click_state = CLICK_STATE.DOUBLE;
+ notifyOnDoubleClick();
+ } else {
+ click_state = CLICK_STATE.SINGLE;
+ notifyOnClick();
+ }
+ // reset last click timestamp
+ timeLastClick = System.currentTimeMillis();
+ // set item pressed and update
+ setPressed(true);
+
+ updatePosition(event.getEventTime());
+ }
+ break;
+ }
+ case MotionEvent.ACTION_MOVE: {
+ for (int i = 0; i < event.getPointerCount(); i++) {
+ if (touchID == event.getPointerId(i)) {
+ touchX = event.getX();
+ touchY = event.getY();
+
+ updatePosition(event.getEventTime());
+ }
+ }
+ break;
+ }
+ // up event (revoke touch)
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_POINTER_UP: {
+ if (touchID == event.getPointerId(event.getActionIndex())) {
+ setPressed(false);
+ bIsFingerOnScreen = false;
+ }
+ break;
+ }
+ }
+
+ if (isPressed()) {
+ updatePosition(event.getEventTime());
+ // when is pressed calculate new positions (will trigger movement if necessary)
+ } else {
+ stick_state = STICK_STATE.NO_MOVEMENT;
+ notifyOnRevoke();
+
+ // not longer pressed reset analog stick
+ notifyOnMovement(0, 0);
+ }
+ // refresh view
+ invalidate();
+ // accept the touch event
+ return true;
+ }
+
+
+// @Override
+// public boolean onElementTouchEvent(MotionEvent event) {
+// // save last click state
+// AnalogStick2.CLICK_STATE lastClickState = click_state;
+//
+// // get absolute way for each axis
+// relative_x = -(getWidth() / 2 - event.getX());
+// relative_y = -(getHeight() / 2 - event.getY());
+//
+// // get radius and angel of movement from center
+// movement_radius = getMovementRadius(relative_x, relative_y);
+// movement_angle = getAngle(relative_x, relative_y);
+//
+// // pass touch event to parent if out of outer circle
+// if (movement_radius > radius_complete && !isPressed())
+// return false;
+//
+// // chop radius if out of outer circle or near the edge
+// if (movement_radius > (radius_complete - radius_analog_stick)) {
+// movement_radius = radius_complete - radius_analog_stick;
+// }
+//
+// // handle event depending on action
+// switch (event.getActionMasked()) {
+// // down event (touch event)
+// case MotionEvent.ACTION_DOWN: {
+// // set to dead zoned, will be corrected in update position if necessary
+// stick_state = AnalogStick2.STICK_STATE.MOVED_IN_DEAD_ZONE;
+// // check for double click
+// if (lastClickState == AnalogStick2.CLICK_STATE.SINGLE &&
+// event.getEventTime() - timeLastClick <= timeoutDoubleClick) {
+// click_state = AnalogStick2.CLICK_STATE.DOUBLE;
+// notifyOnDoubleClick();
+// } else {
+// click_state = AnalogStick2.CLICK_STATE.SINGLE;
+// notifyOnClick();
+// }
+// // reset last click timestamp
+// timeLastClick = event.getEventTime();
+// // set item pressed and update
+// setPressed(true);
+// break;
+// }
+// // up event (revoke touch)
+// case MotionEvent.ACTION_CANCEL:
+// case MotionEvent.ACTION_UP: {
+// setPressed(false);
+// break;
+// }
+// }
+//
+// if (isPressed()) {
+// // when is pressed calculate new positions (will trigger movement if necessary)
+// updatePosition(event.getEventTime());
+// } else {
+// stick_state = AnalogStick2.STICK_STATE.NO_MOVEMENT;
+// notifyOnRevoke();
+//
+// // not longer pressed reset analog stick
+// notifyOnMovement(0, 0);
+// }
+// // refresh view
+// invalidate();
+// // accept the touch event
+// return true;
+// }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalButton.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalButton.java
old mode 100644
new mode 100755
index e24484b096..ecc7258a64
--- a/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalButton.java
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalButton.java
@@ -1,233 +1,267 @@
-/**
- * Created by Karim Mreisi.
- */
-
-package com.limelight.binding.input.virtual_controller;
-
-import android.content.Context;
-import android.graphics.Canvas;
-import android.graphics.Color;
-import android.graphics.Paint;
-import android.graphics.RectF;
-import android.graphics.drawable.Drawable;
-import android.view.MotionEvent;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * This is a digital button on screen element. It is used to get click and double click user input.
- */
-public class DigitalButton extends VirtualControllerElement {
-
- /**
- * Listener interface to update registered observers.
- */
- public interface DigitalButtonListener {
-
- /**
- * onClick event will be fired on button click.
- */
- void onClick();
-
- /**
- * onLongClick event will be fired on button long click.
- */
- void onLongClick();
-
- /**
- * onRelease event will be fired on button unpress.
- */
- void onRelease();
- }
-
- private List listeners = new ArrayList<>();
- private String text = "";
- private int icon = -1;
- private long timerLongClickTimeout = 3000;
- private final Runnable longClickRunnable = new Runnable() {
- @Override
- public void run() {
- onLongClickCallback();
- }
- };
-
- private final Paint paint = new Paint();
- private final RectF rect = new RectF();
-
- private int layer;
- private DigitalButton movingButton = null;
-
- boolean inRange(float x, float y) {
- return (this.getX() < x && this.getX() + this.getWidth() > x) &&
- (this.getY() < y && this.getY() + this.getHeight() > y);
- }
-
- public boolean checkMovement(float x, float y, DigitalButton movingButton) {
- // check if the movement happened in the same layer
- if (movingButton.layer != this.layer) {
- return false;
- }
-
- // save current pressed state
- boolean wasPressed = isPressed();
-
- // check if the movement directly happened on the button
- if ((this.movingButton == null || movingButton == this.movingButton)
- && this.inRange(x, y)) {
- // set button pressed state depending on moving button pressed state
- if (this.isPressed() != movingButton.isPressed()) {
- this.setPressed(movingButton.isPressed());
- }
- }
- // check if the movement is outside of the range and the movement button
- // is the saved moving button
- else if (movingButton == this.movingButton) {
- this.setPressed(false);
- }
-
- // check if a change occurred
- if (wasPressed != isPressed()) {
- if (isPressed()) {
- // is pressed set moving button and emit click event
- this.movingButton = movingButton;
- onClickCallback();
- } else {
- // no longer pressed reset moving button and emit release event
- this.movingButton = null;
- onReleaseCallback();
- }
-
- invalidate();
-
- return true;
- }
-
- return false;
- }
-
- private void checkMovementForAllButtons(float x, float y) {
- for (VirtualControllerElement element : virtualController.getElements()) {
- if (element != this && element instanceof DigitalButton) {
- ((DigitalButton) element).checkMovement(x, y, this);
- }
- }
- }
-
- public DigitalButton(VirtualController controller, int elementId, int layer, Context context) {
- super(controller, context, elementId);
- this.layer = layer;
- }
-
- public void addDigitalButtonListener(DigitalButtonListener listener) {
- listeners.add(listener);
- }
-
- public void setText(String text) {
- this.text = text;
- invalidate();
- }
-
- public void setIcon(int id) {
- this.icon = id;
- invalidate();
- }
-
- @Override
- protected void onElementDraw(Canvas canvas) {
- // set transparent background
- canvas.drawColor(Color.TRANSPARENT);
-
- paint.setTextSize(getPercent(getWidth(), 25));
- paint.setTextAlign(Paint.Align.CENTER);
- paint.setStrokeWidth(getDefaultStrokeWidth());
-
- paint.setColor(isPressed() ? pressedColor : getDefaultColor());
- paint.setStyle(Paint.Style.STROKE);
-
- rect.left = rect.top = paint.getStrokeWidth();
- rect.right = getWidth() - rect.left;
- rect.bottom = getHeight() - rect.top;
-
- canvas.drawOval(rect, paint);
-
- if (icon != -1) {
- Drawable d = getResources().getDrawable(icon);
- d.setBounds(5, 5, getWidth() - 5, getHeight() - 5);
- d.draw(canvas);
- } else {
- paint.setStyle(Paint.Style.FILL_AND_STROKE);
- paint.setStrokeWidth(getDefaultStrokeWidth()/2);
- canvas.drawText(text, getPercent(getWidth(), 50), getPercent(getHeight(), 63), paint);
- }
- }
-
- private void onClickCallback() {
- _DBG("clicked");
- // notify listeners
- for (DigitalButtonListener listener : listeners) {
- listener.onClick();
- }
-
- virtualController.getHandler().removeCallbacks(longClickRunnable);
- virtualController.getHandler().postDelayed(longClickRunnable, timerLongClickTimeout);
- }
-
- private void onLongClickCallback() {
- _DBG("long click");
- // notify listeners
- for (DigitalButtonListener listener : listeners) {
- listener.onLongClick();
- }
- }
-
- private void onReleaseCallback() {
- _DBG("released");
- // notify listeners
- for (DigitalButtonListener listener : listeners) {
- listener.onRelease();
- }
-
- // We may be called for a release without a prior click
- virtualController.getHandler().removeCallbacks(longClickRunnable);
- }
-
- @Override
- public boolean onElementTouchEvent(MotionEvent event) {
- // get masked (not specific to a pointer) action
- float x = getX() + event.getX();
- float y = getY() + event.getY();
- int action = event.getActionMasked();
-
- switch (action) {
- case MotionEvent.ACTION_DOWN: {
- movingButton = null;
- setPressed(true);
- onClickCallback();
-
- invalidate();
-
- return true;
- }
- case MotionEvent.ACTION_MOVE: {
- checkMovementForAllButtons(x, y);
-
- return true;
- }
- case MotionEvent.ACTION_CANCEL:
- case MotionEvent.ACTION_UP: {
- setPressed(false);
- onReleaseCallback();
-
- checkMovementForAllButtons(x, y);
-
- invalidate();
-
- return true;
- }
- default: {
- }
- }
- return true;
- }
-}
+/**
+ * Created by Karim Mreisi.
+ */
+
+package com.limelight.binding.input.virtual_controller;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.RectF;
+import android.graphics.drawable.Drawable;
+import android.view.MotionEvent;
+
+import com.limelight.preferences.PreferenceConfiguration;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * This is a digital button on screen element. It is used to get click and double click user input.
+ */
+public class DigitalButton extends VirtualControllerElement {
+
+ /**
+ * Listener interface to update registered observers.
+ */
+ public interface DigitalButtonListener {
+
+ /**
+ * onClick event will be fired on button click.
+ */
+ void onClick();
+
+ /**
+ * onLongClick event will be fired on button long click.
+ */
+ void onLongClick();
+
+ /**
+ * onRelease event will be fired on button unpress.
+ */
+ void onRelease();
+ }
+
+ private List listeners = new ArrayList<>();
+ private String text = "";
+ private int icon = -1;
+
+ private int iconPress=-1;
+ private long timerLongClickTimeout = 3000;
+ private final Runnable longClickRunnable = new Runnable() {
+ @Override
+ public void run() {
+ onLongClickCallback();
+ }
+ };
+
+ private final Paint paint = new Paint();
+ private final RectF rect = new RectF();
+
+ private int layer;
+ private DigitalButton movingButton = null;
+
+ boolean inRange(float x, float y) {
+ return (this.getX() < x && this.getX() + this.getWidth() > x) &&
+ (this.getY() < y && this.getY() + this.getHeight() > y);
+ }
+
+ public boolean checkMovement(float x, float y, DigitalButton movingButton) {
+ // check if the movement happened in the same layer
+ if (movingButton.layer != this.layer) {
+ return false;
+ }
+
+ // save current pressed state
+ boolean wasPressed = isPressed();
+
+ // check if the movement directly happened on the button
+ if ((this.movingButton == null || movingButton == this.movingButton)
+ && this.inRange(x, y)) {
+ // set button pressed state depending on moving button pressed state
+ if (this.isPressed() != movingButton.isPressed()) {
+ this.setPressed(movingButton.isPressed());
+ }
+ }
+ // check if the movement is outside of the range and the movement button
+ // is the saved moving button
+ else if (movingButton == this.movingButton) {
+ this.setPressed(false);
+ }
+
+ // check if a change occurred
+ if (wasPressed != isPressed()) {
+ if (isPressed()) {
+ // is pressed set moving button and emit click event
+ this.movingButton = movingButton;
+ onClickCallback();
+ } else {
+ // no longer pressed reset moving button and emit release event
+ this.movingButton = null;
+ onReleaseCallback();
+ }
+
+ invalidate();
+
+ return true;
+ }
+
+ return false;
+ }
+
+ private void checkMovementForAllButtons(float x, float y) {
+ for (VirtualControllerElement element : virtualController.getElements()) {
+ if (element != this && element instanceof DigitalButton) {
+ ((DigitalButton) element).checkMovement(x, y, this);
+ }
+ }
+ }
+
+ public DigitalButton(VirtualController controller, int elementId, int layer, Context context) {
+ super(controller, context, elementId);
+ this.layer = layer;
+ }
+
+ public void addDigitalButtonListener(DigitalButtonListener listener) {
+ listeners.add(listener);
+ }
+
+ public void setText(String text) {
+ this.text = text;
+ invalidate();
+ }
+
+ public void setIcon(int id) {
+ this.icon = id;
+ invalidate();
+ }
+
+ public void setIconPress(int iconPress) {
+ this.iconPress = iconPress;
+ }
+
+ @Override
+ protected void onElementDraw(Canvas canvas) {
+ // set transparent background
+ canvas.drawColor(Color.TRANSPARENT);
+
+ paint.setTextSize(getPercent(getWidth(), 25));
+
+ paint.setTextAlign(Paint.Align.CENTER);
+
+ paint.setStrokeWidth(getDefaultStrokeWidth());
+
+ paint.setColor(isPressed() ? pressedColor:getDefaultColor());
+
+ rect.left = rect.top = paint.getStrokeWidth();
+ rect.right = getWidth() - rect.left;
+ rect.bottom = getHeight() - rect.top;
+
+ //皮肤选择 官方皮肤
+ if(PreferenceConfiguration.readPreferences(getContext()).enableOnScreenStyleOfficial){
+ paint.setStyle(Paint.Style.STROKE);
+ //方形
+ if(PreferenceConfiguration.readPreferences(getContext()).enableKeyboardSquare){
+ canvas.drawRect(rect,paint);
+ }else{
+ canvas.drawOval(rect, paint);
+ }
+ paint.setStyle(Paint.Style.FILL_AND_STROKE);
+ paint.setStrokeWidth(getDefaultStrokeWidth()/2);
+ canvas.drawText(text, getPercent(getWidth(), 50), getPercent(getHeight(), 63), paint);
+ return;
+ }
+ int oscOpacity=PreferenceConfiguration.readPreferences(getContext()).oscOpacity;
+ //虚拟手柄皮肤
+ if (icon != -1) {
+ Drawable d = getResources().getDrawable(isPressed()?iconPress:icon);
+ d.setBounds(5, 5, getWidth() - 5, getHeight() - 5);
+ d.setAlpha((int) (oscOpacity*2.55));
+ d.draw(canvas);
+ }else{
+ paint.setStyle(Paint.Style.STROKE);
+ paint.setStrokeWidth(getDefaultStrokeWidth()/2);
+ canvas.drawText(text, getPercent(getWidth(), 50), getPercent(getHeight(), 63), paint);
+ }
+
+ boolean bIsMoving = virtualController.getControllerMode() == VirtualController.ControllerMode.MoveButtons;
+ boolean bIsResizing = virtualController.getControllerMode() == VirtualController.ControllerMode.ResizeButtons;
+ boolean bIsEnable = virtualController.getControllerMode() == VirtualController.ControllerMode.DisableEnableButtons;
+
+ if (bIsMoving || bIsResizing || bIsEnable ||icon==-1) {
+ paint.setStyle(Paint.Style.STROKE);
+ canvas.drawRect(rect,paint);
+ }
+
+ }
+
+ private void onClickCallback() {
+ _DBG("clicked");
+ // notify listeners
+ for (DigitalButtonListener listener : listeners) {
+ listener.onClick();
+ }
+
+ virtualController.getHandler().removeCallbacks(longClickRunnable);
+ virtualController.getHandler().postDelayed(longClickRunnable, timerLongClickTimeout);
+ }
+
+ private void onLongClickCallback() {
+ _DBG("long click");
+ // notify listeners
+ for (DigitalButtonListener listener : listeners) {
+ listener.onLongClick();
+ }
+ }
+
+ private void onReleaseCallback() {
+ _DBG("released");
+ // notify listeners
+ for (DigitalButtonListener listener : listeners) {
+ listener.onRelease();
+ }
+
+ // We may be called for a release without a prior click
+ virtualController.getHandler().removeCallbacks(longClickRunnable);
+ }
+
+ @Override
+ public boolean onElementTouchEvent(MotionEvent event) {
+ // get masked (not specific to a pointer) action
+ float x = getX() + event.getX();
+ float y = getY() + event.getY();
+ int action = event.getActionMasked();
+
+ switch (action) {
+ case MotionEvent.ACTION_DOWN: {
+ movingButton = null;
+ setPressed(true);
+ onClickCallback();
+
+ invalidate();
+
+ return true;
+ }
+ case MotionEvent.ACTION_MOVE: {
+ checkMovementForAllButtons(x, y);
+
+ return true;
+ }
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP: {
+ setPressed(false);
+ onReleaseCallback();
+
+ checkMovementForAllButtons(x, y);
+
+ invalidate();
+
+ return true;
+ }
+ default: {
+ }
+ }
+ return true;
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalPad.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalPad.java
old mode 100644
new mode 100755
index 1f3d9fed8c..33f4d1e4d6
--- a/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalPad.java
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/DigitalPad.java
@@ -1,203 +1,316 @@
-/**
- * Created by Karim Mreisi.
- */
-
-package com.limelight.binding.input.virtual_controller;
-
-import android.content.Context;
-import android.graphics.Canvas;
-import android.graphics.Color;
-import android.graphics.Paint;
-import android.view.MotionEvent;
-
-import java.util.ArrayList;
-import java.util.List;
-
-public class DigitalPad extends VirtualControllerElement {
- public final static int DIGITAL_PAD_DIRECTION_NO_DIRECTION = 0;
- int direction = DIGITAL_PAD_DIRECTION_NO_DIRECTION;
- public final static int DIGITAL_PAD_DIRECTION_LEFT = 1;
- public final static int DIGITAL_PAD_DIRECTION_UP = 2;
- public final static int DIGITAL_PAD_DIRECTION_RIGHT = 4;
- public final static int DIGITAL_PAD_DIRECTION_DOWN = 8;
- List listeners = new ArrayList<>();
-
- private static final int DPAD_MARGIN = 5;
-
- private final Paint paint = new Paint();
-
- public DigitalPad(VirtualController controller, Context context) {
- super(controller, context, EID_DPAD);
- }
-
- public void addDigitalPadListener(DigitalPadListener listener) {
- listeners.add(listener);
- }
-
- @Override
- protected void onElementDraw(Canvas canvas) {
- // set transparent background
- canvas.drawColor(Color.TRANSPARENT);
-
- paint.setTextSize(getPercent(getCorrectWidth(), 20));
- paint.setTextAlign(Paint.Align.CENTER);
- paint.setStrokeWidth(getDefaultStrokeWidth());
-
- if (direction == DIGITAL_PAD_DIRECTION_NO_DIRECTION) {
- // draw no direction rect
- paint.setStyle(Paint.Style.STROKE);
- paint.setColor(getDefaultColor());
- canvas.drawRect(
- getPercent(getWidth(), 36), getPercent(getHeight(), 36),
- getPercent(getWidth(), 63), getPercent(getHeight(), 63),
- paint
- );
- }
-
- // draw left rect
- paint.setColor(
- (direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 ? pressedColor : getDefaultColor());
- paint.setStyle(Paint.Style.STROKE);
- canvas.drawRect(
- paint.getStrokeWidth()+DPAD_MARGIN, getPercent(getHeight(), 33),
- getPercent(getWidth(), 33), getPercent(getHeight(), 66),
- paint
- );
-
-
- // draw up rect
- paint.setColor(
- (direction & DIGITAL_PAD_DIRECTION_UP) > 0 ? pressedColor : getDefaultColor());
- paint.setStyle(Paint.Style.STROKE);
- canvas.drawRect(
- getPercent(getWidth(), 33), paint.getStrokeWidth()+DPAD_MARGIN,
- getPercent(getWidth(), 66), getPercent(getHeight(), 33),
- paint
- );
-
- // draw right rect
- paint.setColor(
- (direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 ? pressedColor : getDefaultColor());
- paint.setStyle(Paint.Style.STROKE);
- canvas.drawRect(
- getPercent(getWidth(), 66), getPercent(getHeight(), 33),
- getWidth() - (paint.getStrokeWidth()+DPAD_MARGIN), getPercent(getHeight(), 66),
- paint
- );
-
- // draw down rect
- paint.setColor(
- (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0 ? pressedColor : getDefaultColor());
- paint.setStyle(Paint.Style.STROKE);
- canvas.drawRect(
- getPercent(getWidth(), 33), getPercent(getHeight(), 66),
- getPercent(getWidth(), 66), getHeight() - (paint.getStrokeWidth()+DPAD_MARGIN),
- paint
- );
-
- // draw left up line
- paint.setColor((
- (direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 &&
- (direction & DIGITAL_PAD_DIRECTION_UP) > 0
- ) ? pressedColor : getDefaultColor()
- );
- paint.setStyle(Paint.Style.STROKE);
- canvas.drawLine(
- paint.getStrokeWidth()+DPAD_MARGIN, getPercent(getHeight(), 33),
- getPercent(getWidth(), 33), paint.getStrokeWidth()+DPAD_MARGIN,
- paint
- );
-
- // draw up right line
- paint.setColor((
- (direction & DIGITAL_PAD_DIRECTION_UP) > 0 &&
- (direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0
- ) ? pressedColor : getDefaultColor()
- );
- paint.setStyle(Paint.Style.STROKE);
- canvas.drawLine(
- getPercent(getWidth(), 66), paint.getStrokeWidth()+DPAD_MARGIN,
- getWidth() - (paint.getStrokeWidth()+DPAD_MARGIN), getPercent(getHeight(), 33),
- paint
- );
-
- // draw right down line
- paint.setColor((
- (direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 &&
- (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0
- ) ? pressedColor : getDefaultColor()
- );
- paint.setStyle(Paint.Style.STROKE);
- canvas.drawLine(
- getWidth()-paint.getStrokeWidth(), getPercent(getHeight(), 66),
- getPercent(getWidth(), 66), getHeight()-(paint.getStrokeWidth()+DPAD_MARGIN),
- paint
- );
-
- // draw down left line
- paint.setColor((
- (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0 &&
- (direction & DIGITAL_PAD_DIRECTION_LEFT) > 0
- ) ? pressedColor : getDefaultColor()
- );
- paint.setStyle(Paint.Style.STROKE);
- canvas.drawLine(
- getPercent(getWidth(), 33), getHeight()-(paint.getStrokeWidth()+DPAD_MARGIN),
- paint.getStrokeWidth()+DPAD_MARGIN, getPercent(getHeight(), 66),
- paint
- );
- }
-
- private void newDirectionCallback(int direction) {
- _DBG("direction: " + direction);
-
- // notify listeners
- for (DigitalPadListener listener : listeners) {
- listener.onDirectionChange(direction);
- }
- }
-
- @Override
- public boolean onElementTouchEvent(MotionEvent event) {
- // get masked (not specific to a pointer) action
- switch (event.getActionMasked()) {
- case MotionEvent.ACTION_DOWN:
- case MotionEvent.ACTION_MOVE: {
- direction = 0;
-
- if (event.getX() < getPercent(getWidth(), 33)) {
- direction |= DIGITAL_PAD_DIRECTION_LEFT;
- }
- if (event.getX() > getPercent(getWidth(), 66)) {
- direction |= DIGITAL_PAD_DIRECTION_RIGHT;
- }
- if (event.getY() > getPercent(getHeight(), 66)) {
- direction |= DIGITAL_PAD_DIRECTION_DOWN;
- }
- if (event.getY() < getPercent(getHeight(), 33)) {
- direction |= DIGITAL_PAD_DIRECTION_UP;
- }
- newDirectionCallback(direction);
- invalidate();
-
- return true;
- }
- case MotionEvent.ACTION_CANCEL:
- case MotionEvent.ACTION_UP: {
- direction = 0;
- newDirectionCallback(direction);
- invalidate();
-
- return true;
- }
- default: {
- }
- }
-
- return true;
- }
-
- public interface DigitalPadListener {
- void onDirectionChange(int direction);
- }
-}
+/**
+ * Created by Karim Mreisi.
+ */
+
+package com.limelight.binding.input.virtual_controller;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.RectF;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.view.MotionEvent;
+
+import com.limelight.LimeLog;
+import com.limelight.R;
+import com.limelight.preferences.PreferenceConfiguration;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class DigitalPad extends VirtualControllerElement {
+ public final static int DIGITAL_PAD_DIRECTION_NO_DIRECTION = 0;
+ int direction = DIGITAL_PAD_DIRECTION_NO_DIRECTION;
+ public final static int DIGITAL_PAD_DIRECTION_LEFT = 1;
+ public final static int DIGITAL_PAD_DIRECTION_UP = 2;
+ public final static int DIGITAL_PAD_DIRECTION_RIGHT = 4;
+ public final static int DIGITAL_PAD_DIRECTION_DOWN = 8;
+ List listeners = new ArrayList<>();
+
+ private static final int DPAD_MARGIN = 5;
+ private final RectF rect = new RectF();
+
+ private final Paint paint = new Paint();
+
+ public DigitalPad(VirtualController controller, Context context) {
+ super(controller, context, EID_DPAD);
+ }
+
+ public void addDigitalPadListener(DigitalPadListener listener) {
+ listeners.add(listener);
+ }
+
+ @Override
+ protected void onElementDraw(Canvas canvas) {
+ // set transparent background
+ canvas.drawColor(Color.TRANSPARENT);
+
+ paint.setTextSize(getPercent(getCorrectWidth(), 20));
+ paint.setTextAlign(Paint.Align.CENTER);
+ paint.setStrokeWidth(getDefaultStrokeWidth());
+ //虚拟手柄皮肤 yuzu
+ if(!PreferenceConfiguration.readPreferences(getContext()).enableOnScreenStyleOfficial) {
+ int oscOpacity=PreferenceConfiguration.readPreferences(getContext()).oscOpacity;
+
+ paint.setColor(isPressed() ? pressedColor:getDefaultColor());
+ rect.left = rect.top = paint.getStrokeWidth();
+ rect.right = getWidth() - rect.left;
+ rect.bottom = getHeight() - rect.top;
+
+ boolean bIsMoving = virtualController.getControllerMode() == VirtualController.ControllerMode.MoveButtons;
+ boolean bIsResizing = virtualController.getControllerMode() == VirtualController.ControllerMode.ResizeButtons;
+ boolean bIsEnable = virtualController.getControllerMode() == VirtualController.ControllerMode.DisableEnableButtons;
+
+ if (bIsMoving || bIsResizing || bIsEnable) {
+ paint.setStyle(Paint.Style.STROKE);
+ canvas.drawRect(rect,paint);
+ }
+
+ if (direction == DIGITAL_PAD_DIRECTION_NO_DIRECTION) {
+ Drawable d = getResources().getDrawable(R.drawable.facebutton_dpad);
+ d.setBounds(5, 5, getWidth() - 5, getHeight() - 5);
+ d.setAlpha((int) (oscOpacity*2.55));
+ d.draw(canvas);
+ }
+
+ if (direction == DIGITAL_PAD_DIRECTION_UP) {
+ Drawable d = getResources().getDrawable(R.drawable.facebutton_dpad_up);
+ d.setBounds(5, 5, getWidth() - 5, getHeight() - 5);
+ d.setAlpha((int) (oscOpacity*2.55));
+ d.draw(canvas);
+ }
+
+ if (direction == DIGITAL_PAD_DIRECTION_DOWN) {
+ Drawable d = getResources().getDrawable(R.drawable.facebutton_dpad_up);
+ Drawable newD=rotateDrawable(d,180);
+ newD.setBounds(5, 5, getWidth() - 5, getHeight() - 5);
+ newD.setAlpha((int) (oscOpacity*2.55));
+ newD.draw(canvas);
+ }
+
+ if (direction == DIGITAL_PAD_DIRECTION_LEFT) {
+ Drawable d = getResources().getDrawable(R.drawable.facebutton_dpad_up);
+ Drawable newD=rotateDrawable(d,270);
+ newD.setBounds(5, 5, getWidth() - 5, getHeight() - 5);
+ newD.setAlpha((int) (oscOpacity*2.55));
+ newD.draw(canvas);
+ }
+
+ if (direction == DIGITAL_PAD_DIRECTION_RIGHT) {
+ Drawable d = getResources().getDrawable(R.drawable.facebutton_dpad_up);
+ Drawable newD=rotateDrawable(d,90);
+ newD.setBounds(5, 5, getWidth() - 5, getHeight() - 5);
+ newD.setAlpha((int) (oscOpacity*2.55));
+ newD.draw(canvas);
+ }
+ //right up
+ if((direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 && (direction & DIGITAL_PAD_DIRECTION_UP) > 0){
+ Drawable d = getResources().getDrawable(R.drawable.facebutton_dpad_up_right);
+ Drawable newD=rotateDrawable(d,90);
+ newD.setBounds(5, 5, getWidth() - 5, getHeight() - 5);
+ newD.setAlpha((int) (oscOpacity*2.55));
+ newD.draw(canvas);
+ }
+
+ if((direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 && (direction & DIGITAL_PAD_DIRECTION_UP) > 0){
+ Drawable d = getResources().getDrawable(R.drawable.facebutton_dpad_up_right);
+ d.setBounds(5, 5, getWidth() - 5, getHeight() - 5);
+ d.setAlpha((int) (oscOpacity*2.55));
+ d.draw(canvas);
+ }
+
+ if((direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 && (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0){
+ Drawable d = getResources().getDrawable(R.drawable.facebutton_dpad_up_right);
+ Drawable newD=rotateDrawable(d,180);
+ newD.setBounds(5, 5, getWidth() - 5, getHeight() - 5);
+ newD.setAlpha((int) (oscOpacity*2.55));
+ newD.draw(canvas);
+ }
+
+ if((direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 && (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0){
+ Drawable d = getResources().getDrawable(R.drawable.facebutton_dpad_up_right);
+ Drawable newD=rotateDrawable(d,270);
+ newD.setBounds(5, 5, getWidth() - 5, getHeight() - 5);
+ newD.setAlpha((int) (oscOpacity*2.55));
+ newD.draw(canvas);
+ }
+
+ return;
+ }
+ //官方皮肤
+ if (direction == DIGITAL_PAD_DIRECTION_NO_DIRECTION) {
+ // draw no direction rect
+ paint.setStyle(Paint.Style.STROKE);
+ paint.setColor(getDefaultColor());
+ canvas.drawRect(
+ getPercent(getWidth(), 36), getPercent(getHeight(), 36),
+ getPercent(getWidth(), 63), getPercent(getHeight(), 63),
+ paint
+ );
+ }
+
+ // draw left rect
+ paint.setColor(
+ (direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 ? pressedColor : getDefaultColor());
+ paint.setStyle(Paint.Style.STROKE);
+ canvas.drawRect(
+ paint.getStrokeWidth()+DPAD_MARGIN, getPercent(getHeight(), 33),
+ getPercent(getWidth(), 33), getPercent(getHeight(), 66),
+ paint
+ );
+
+
+ // draw up rect
+ paint.setColor(
+ (direction & DIGITAL_PAD_DIRECTION_UP) > 0 ? pressedColor : getDefaultColor());
+ paint.setStyle(Paint.Style.STROKE);
+ canvas.drawRect(
+ getPercent(getWidth(), 33), paint.getStrokeWidth()+DPAD_MARGIN,
+ getPercent(getWidth(), 66), getPercent(getHeight(), 33),
+ paint
+ );
+
+ // draw right rect
+ paint.setColor(
+ (direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 ? pressedColor : getDefaultColor());
+ paint.setStyle(Paint.Style.STROKE);
+ canvas.drawRect(
+ getPercent(getWidth(), 66), getPercent(getHeight(), 33),
+ getWidth() - (paint.getStrokeWidth()+DPAD_MARGIN), getPercent(getHeight(), 66),
+ paint
+ );
+
+ // draw down rect
+ paint.setColor(
+ (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0 ? pressedColor : getDefaultColor());
+ paint.setStyle(Paint.Style.STROKE);
+ canvas.drawRect(
+ getPercent(getWidth(), 33), getPercent(getHeight(), 66),
+ getPercent(getWidth(), 66), getHeight() - (paint.getStrokeWidth()+DPAD_MARGIN),
+ paint
+ );
+
+ // draw left up line
+ paint.setColor((
+ (direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 &&
+ (direction & DIGITAL_PAD_DIRECTION_UP) > 0
+ ) ? pressedColor : getDefaultColor()
+ );
+ paint.setStyle(Paint.Style.STROKE);
+ canvas.drawLine(
+ paint.getStrokeWidth()+DPAD_MARGIN, getPercent(getHeight(), 33),
+ getPercent(getWidth(), 33), paint.getStrokeWidth()+DPAD_MARGIN,
+ paint
+ );
+
+ // draw up right line
+ paint.setColor((
+ (direction & DIGITAL_PAD_DIRECTION_UP) > 0 &&
+ (direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0
+ ) ? pressedColor : getDefaultColor()
+ );
+ paint.setStyle(Paint.Style.STROKE);
+ canvas.drawLine(
+ getPercent(getWidth(), 66), paint.getStrokeWidth()+DPAD_MARGIN,
+ getWidth() - (paint.getStrokeWidth()+DPAD_MARGIN), getPercent(getHeight(), 33),
+ paint
+ );
+
+ // draw right down line
+ paint.setColor((
+ (direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 &&
+ (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0
+ ) ? pressedColor : getDefaultColor()
+ );
+ paint.setStyle(Paint.Style.STROKE);
+ canvas.drawLine(
+ getWidth()-paint.getStrokeWidth(), getPercent(getHeight(), 66),
+ getPercent(getWidth(), 66), getHeight()-(paint.getStrokeWidth()+DPAD_MARGIN),
+ paint
+ );
+
+ // draw down left line
+ paint.setColor((
+ (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0 &&
+ (direction & DIGITAL_PAD_DIRECTION_LEFT) > 0
+ ) ? pressedColor : getDefaultColor()
+ );
+ paint.setStyle(Paint.Style.STROKE);
+ canvas.drawLine(
+ getPercent(getWidth(), 33), getHeight()-(paint.getStrokeWidth()+DPAD_MARGIN),
+ paint.getStrokeWidth()+DPAD_MARGIN, getPercent(getHeight(), 66),
+ paint
+ );
+ }
+
+ public Drawable rotateDrawable(Drawable vectorDrawable, float angle) {
+ int width = vectorDrawable.getIntrinsicWidth();
+ int height = vectorDrawable.getIntrinsicHeight();
+ Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(bitmap);
+ vectorDrawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
+ vectorDrawable.draw(canvas);
+
+ Matrix matrix = new Matrix();
+ matrix.postRotate(angle);
+ Bitmap rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);
+ return new BitmapDrawable(getResources(), rotatedBitmap);
+ }
+
+ private void newDirectionCallback(int direction) {
+ _DBG("direction: " + direction);
+
+ // notify listeners
+ for (DigitalPadListener listener : listeners) {
+ listener.onDirectionChange(direction);
+ }
+ }
+
+ @Override
+ public boolean onElementTouchEvent(MotionEvent event) {
+ // get masked (not specific to a pointer) action
+ switch (event.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_MOVE: {
+ direction = 0;
+
+ if (event.getX() < getPercent(getWidth(), 33)) {
+ direction |= DIGITAL_PAD_DIRECTION_LEFT;
+ }
+ if (event.getX() > getPercent(getWidth(), 66)) {
+ direction |= DIGITAL_PAD_DIRECTION_RIGHT;
+ }
+ if (event.getY() > getPercent(getHeight(), 66)) {
+ direction |= DIGITAL_PAD_DIRECTION_DOWN;
+ }
+ if (event.getY() < getPercent(getHeight(), 33)) {
+ direction |= DIGITAL_PAD_DIRECTION_UP;
+ }
+ newDirectionCallback(direction);
+ invalidate();
+
+ return true;
+ }
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP: {
+ direction = 0;
+ newDirectionCallback(direction);
+ invalidate();
+
+ return true;
+ }
+ default: {
+ }
+ }
+
+ return true;
+ }
+
+ public interface DigitalPadListener {
+ void onDirectionChange(int direction);
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/LeftAnalogStick.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/LeftAnalogStick.java
old mode 100644
new mode 100755
index c8d0d5b657..31882734f1
--- a/app/src/main/java/com/limelight/binding/input/virtual_controller/LeftAnalogStick.java
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/LeftAnalogStick.java
@@ -1,49 +1,49 @@
-/**
- * Created by Karim Mreisi.
- */
-
-package com.limelight.binding.input.virtual_controller;
-
-import android.content.Context;
-
-import com.limelight.nvstream.input.ControllerPacket;
-
-public class LeftAnalogStick extends AnalogStick {
- public LeftAnalogStick(final VirtualController controller, final Context context) {
- super(controller, context, EID_LS);
-
- addAnalogStickListener(new AnalogStick.AnalogStickListener() {
- @Override
- public void onMovement(float x, float y) {
- VirtualController.ControllerInputContext inputContext =
- controller.getControllerInputContext();
- inputContext.leftStickX = (short) (x * 0x7FFE);
- inputContext.leftStickY = (short) (y * 0x7FFE);
-
- controller.sendControllerInputContext();
- }
-
- @Override
- public void onClick() {
- }
-
- @Override
- public void onDoubleClick() {
- VirtualController.ControllerInputContext inputContext =
- controller.getControllerInputContext();
- inputContext.inputMap |= ControllerPacket.LS_CLK_FLAG;
-
- controller.sendControllerInputContext();
- }
-
- @Override
- public void onRevoke() {
- VirtualController.ControllerInputContext inputContext =
- controller.getControllerInputContext();
- inputContext.inputMap &= ~ControllerPacket.LS_CLK_FLAG;
-
- controller.sendControllerInputContext();
- }
- });
- }
-}
+/**
+ * Created by Karim Mreisi.
+ */
+
+package com.limelight.binding.input.virtual_controller;
+
+import android.content.Context;
+
+import com.limelight.nvstream.input.ControllerPacket;
+
+public class LeftAnalogStick extends AnalogStick {
+ public LeftAnalogStick(final VirtualController controller, final Context context) {
+ super(controller, context, EID_LS);
+
+ addAnalogStickListener(new AnalogStick.AnalogStickListener() {
+ @Override
+ public void onMovement(float x, float y) {
+ VirtualController.ControllerInputContext inputContext =
+ controller.getControllerInputContext();
+ inputContext.leftStickX = (short) (x * 0x7FFE);
+ inputContext.leftStickY = (short) (y * 0x7FFE);
+
+ controller.sendControllerInputContext(10, 0x11);
+ }
+
+ @Override
+ public void onClick() {
+ }
+
+ @Override
+ public void onDoubleClick() {
+ VirtualController.ControllerInputContext inputContext =
+ controller.getControllerInputContext();
+ inputContext.inputMap |= ControllerPacket.LS_CLK_FLAG;
+
+ controller.sendControllerInputContext();
+ }
+
+ @Override
+ public void onRevoke() {
+ VirtualController.ControllerInputContext inputContext =
+ controller.getControllerInputContext();
+ inputContext.inputMap &= ~ControllerPacket.LS_CLK_FLAG;
+
+ controller.sendControllerInputContext();
+ }
+ });
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/LeftAnalogStickFree.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/LeftAnalogStickFree.java
new file mode 100755
index 0000000000..6cdc6d62e7
--- /dev/null
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/LeftAnalogStickFree.java
@@ -0,0 +1,51 @@
+/**
+ * Created by Karim Mreisi.
+ */
+
+package com.limelight.binding.input.virtual_controller;
+
+import android.content.Context;
+
+import com.limelight.nvstream.input.ControllerPacket;
+
+public class LeftAnalogStickFree extends AnalogStickFree {
+ public LeftAnalogStickFree(final VirtualController controller, final Context context) {
+ super(controller, context, EID_LS);
+
+ strStickSide = "L";
+
+ addAnalogStickListener(new AnalogStickListener() {
+ @Override
+ public void onMovement(float x, float y) {
+ VirtualController.ControllerInputContext inputContext =
+ controller.getControllerInputContext();
+ inputContext.leftStickX = (short) (x * 0x7FFE);
+ inputContext.leftStickY = (short) (y * 0x7FFE);
+
+ controller.sendControllerInputContext(10, 0x11);
+ }
+
+ @Override
+ public void onClick() {
+ }
+
+ @Override
+ public void onDoubleClick() {
+ VirtualController.ControllerInputContext inputContext =
+ controller.getControllerInputContext();
+ inputContext.inputMap |= ControllerPacket.LS_CLK_FLAG;
+
+ controller.sendControllerInputContext();
+ }
+
+ @Override
+ public void onRevoke() {
+ VirtualController.ControllerInputContext inputContext =
+ controller.getControllerInputContext();
+ inputContext.inputMap &= ~ControllerPacket.LS_CLK_FLAG;
+
+ controller.sendControllerInputContext();
+ }
+ });
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/LeftTrigger.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/LeftTrigger.java
old mode 100644
new mode 100755
index ba71727565..0d4a819f26
--- a/app/src/main/java/com/limelight/binding/input/virtual_controller/LeftTrigger.java
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/LeftTrigger.java
@@ -1,36 +1,36 @@
-/**
- * Created by Karim Mreisi.
- */
-
-package com.limelight.binding.input.virtual_controller;
-
-import android.content.Context;
-
-public class LeftTrigger extends DigitalButton {
- public LeftTrigger(final VirtualController controller, final int layer, final Context context) {
- super(controller, EID_LT, layer, context);
- addDigitalButtonListener(new DigitalButton.DigitalButtonListener() {
- @Override
- public void onClick() {
- VirtualController.ControllerInputContext inputContext =
- controller.getControllerInputContext();
- inputContext.leftTrigger = (byte) 0xFF;
-
- controller.sendControllerInputContext();
- }
-
- @Override
- public void onLongClick() {
- }
-
- @Override
- public void onRelease() {
- VirtualController.ControllerInputContext inputContext =
- controller.getControllerInputContext();
- inputContext.leftTrigger = (byte) 0x00;
-
- controller.sendControllerInputContext();
- }
- });
- }
-}
+/**
+ * Created by Karim Mreisi.
+ */
+
+package com.limelight.binding.input.virtual_controller;
+
+import android.content.Context;
+
+public class LeftTrigger extends DigitalButton {
+ public LeftTrigger(final VirtualController controller, final int layer, final Context context) {
+ super(controller, EID_LT, layer, context);
+ addDigitalButtonListener(new DigitalButton.DigitalButtonListener() {
+ @Override
+ public void onClick() {
+ VirtualController.ControllerInputContext inputContext =
+ controller.getControllerInputContext();
+ inputContext.leftTrigger = (byte) 0xFF;
+
+ controller.sendControllerInputContext();
+ }
+
+ @Override
+ public void onLongClick() {
+ }
+
+ @Override
+ public void onRelease() {
+ VirtualController.ControllerInputContext inputContext =
+ controller.getControllerInputContext();
+ inputContext.leftTrigger = (byte) 0x00;
+
+ controller.sendControllerInputContext();
+ }
+ });
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/RightAnalogStick.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/RightAnalogStick.java
old mode 100644
new mode 100755
index 91e5937e63..b653368430
--- a/app/src/main/java/com/limelight/binding/input/virtual_controller/RightAnalogStick.java
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/RightAnalogStick.java
@@ -1,49 +1,49 @@
-/**
- * Created by Karim Mreisi.
- */
-
-package com.limelight.binding.input.virtual_controller;
-
-import android.content.Context;
-
-import com.limelight.nvstream.input.ControllerPacket;
-
-public class RightAnalogStick extends AnalogStick {
- public RightAnalogStick(final VirtualController controller, final Context context) {
- super(controller, context, EID_RS);
-
- addAnalogStickListener(new AnalogStick.AnalogStickListener() {
- @Override
- public void onMovement(float x, float y) {
- VirtualController.ControllerInputContext inputContext =
- controller.getControllerInputContext();
- inputContext.rightStickX = (short) (x * 0x7FFE);
- inputContext.rightStickY = (short) (y * 0x7FFE);
-
- controller.sendControllerInputContext();
- }
-
- @Override
- public void onClick() {
- }
-
- @Override
- public void onDoubleClick() {
- VirtualController.ControllerInputContext inputContext =
- controller.getControllerInputContext();
- inputContext.inputMap |= ControllerPacket.RS_CLK_FLAG;
-
- controller.sendControllerInputContext();
- }
-
- @Override
- public void onRevoke() {
- VirtualController.ControllerInputContext inputContext =
- controller.getControllerInputContext();
- inputContext.inputMap &= ~ControllerPacket.RS_CLK_FLAG;
-
- controller.sendControllerInputContext();
- }
- });
- }
-}
+/**
+ * Created by Karim Mreisi.
+ */
+
+package com.limelight.binding.input.virtual_controller;
+
+import android.content.Context;
+
+import com.limelight.nvstream.input.ControllerPacket;
+
+public class RightAnalogStick extends AnalogStick {
+ public RightAnalogStick(final VirtualController controller, final Context context) {
+ super(controller, context, EID_RS);
+
+ addAnalogStickListener(new AnalogStick.AnalogStickListener() {
+ @Override
+ public void onMovement(float x, float y) {
+ VirtualController.ControllerInputContext inputContext =
+ controller.getControllerInputContext();
+ inputContext.rightStickX = (short) (x * 0x7FFE);
+ inputContext.rightStickY = (short) (y * 0x7FFE);
+
+ controller.sendControllerInputContext(10, 0x11);
+ }
+
+ @Override
+ public void onClick() {
+ }
+
+ @Override
+ public void onDoubleClick() {
+ VirtualController.ControllerInputContext inputContext =
+ controller.getControllerInputContext();
+ inputContext.inputMap |= ControllerPacket.RS_CLK_FLAG;
+
+ controller.sendControllerInputContext();
+ }
+
+ @Override
+ public void onRevoke() {
+ VirtualController.ControllerInputContext inputContext =
+ controller.getControllerInputContext();
+ inputContext.inputMap &= ~ControllerPacket.RS_CLK_FLAG;
+
+ controller.sendControllerInputContext();
+ }
+ });
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/RightAnalogStickFree.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/RightAnalogStickFree.java
new file mode 100755
index 0000000000..9662efc3df
--- /dev/null
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/RightAnalogStickFree.java
@@ -0,0 +1,51 @@
+/**
+ * Created by Karim Mreisi.
+ */
+
+package com.limelight.binding.input.virtual_controller;
+
+import android.content.Context;
+
+import com.limelight.nvstream.input.ControllerPacket;
+
+public class RightAnalogStickFree extends AnalogStickFree {
+ public RightAnalogStickFree(final VirtualController controller, final Context context) {
+ super(controller, context, EID_RS);
+
+ strStickSide = "R";
+
+ addAnalogStickListener(new AnalogStickListener() {
+ @Override
+ public void onMovement(float x, float y) {
+ VirtualController.ControllerInputContext inputContext =
+ controller.getControllerInputContext();
+ inputContext.rightStickX = (short) (x * 0x7FFE);
+ inputContext.rightStickY = (short) (y * 0x7FFE);
+
+ controller.sendControllerInputContext(10, 0x11);
+ }
+
+ @Override
+ public void onClick() {
+ }
+
+ @Override
+ public void onDoubleClick() {
+ VirtualController.ControllerInputContext inputContext =
+ controller.getControllerInputContext();
+ inputContext.inputMap |= ControllerPacket.RS_CLK_FLAG;
+
+ controller.sendControllerInputContext();
+ }
+
+ @Override
+ public void onRevoke() {
+ VirtualController.ControllerInputContext inputContext =
+ controller.getControllerInputContext();
+ inputContext.inputMap &= ~ControllerPacket.RS_CLK_FLAG;
+
+ controller.sendControllerInputContext();
+ }
+ });
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/RightTrigger.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/RightTrigger.java
old mode 100644
new mode 100755
index 790ce38367..a501882074
--- a/app/src/main/java/com/limelight/binding/input/virtual_controller/RightTrigger.java
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/RightTrigger.java
@@ -1,36 +1,36 @@
-/**
- * Created by Karim Mreisi.
- */
-
-package com.limelight.binding.input.virtual_controller;
-
-import android.content.Context;
-
-public class RightTrigger extends DigitalButton {
- public RightTrigger(final VirtualController controller, final int layer, final Context context) {
- super(controller, EID_RT, layer, context);
- addDigitalButtonListener(new DigitalButton.DigitalButtonListener() {
- @Override
- public void onClick() {
- VirtualController.ControllerInputContext inputContext =
- controller.getControllerInputContext();
- inputContext.rightTrigger = (byte) 0xFF;
-
- controller.sendControllerInputContext();
- }
-
- @Override
- public void onLongClick() {
- }
-
- @Override
- public void onRelease() {
- VirtualController.ControllerInputContext inputContext =
- controller.getControllerInputContext();
- inputContext.rightTrigger = (byte) 0x00;
-
- controller.sendControllerInputContext();
- }
- });
- }
-}
+/**
+ * Created by Karim Mreisi.
+ */
+
+package com.limelight.binding.input.virtual_controller;
+
+import android.content.Context;
+
+public class RightTrigger extends DigitalButton {
+ public RightTrigger(final VirtualController controller, final int layer, final Context context) {
+ super(controller, EID_RT, layer, context);
+ addDigitalButtonListener(new DigitalButton.DigitalButtonListener() {
+ @Override
+ public void onClick() {
+ VirtualController.ControllerInputContext inputContext =
+ controller.getControllerInputContext();
+ inputContext.rightTrigger = (byte) 0xFF;
+
+ controller.sendControllerInputContext();
+ }
+
+ @Override
+ public void onLongClick() {
+ }
+
+ @Override
+ public void onRelease() {
+ VirtualController.ControllerInputContext inputContext =
+ controller.getControllerInputContext();
+ inputContext.rightTrigger = (byte) 0x00;
+
+ controller.sendControllerInputContext();
+ }
+ });
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualController.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualController.java
old mode 100644
new mode 100755
index ce9f4014e8..c0f7f09fc1
--- a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualController.java
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualController.java
@@ -5,9 +5,13 @@
package com.limelight.binding.input.virtual_controller;
import android.content.Context;
+import android.os.Build;
import android.os.Handler;
import android.os.Looper;
+import android.os.VibrationEffect;
+import android.os.Vibrator;
import android.util.DisplayMetrics;
+import android.view.HapticFeedbackConstants;
import android.view.View;
import android.widget.Button;
import android.widget.FrameLayout;
@@ -16,13 +20,15 @@
import com.limelight.LimeLog;
import com.limelight.R;
import com.limelight.binding.input.ControllerHandler;
+import com.limelight.preferences.PreferenceConfiguration;
import java.util.ArrayList;
import java.util.List;
public class VirtualController {
public static class ControllerInputContext {
- public short inputMap = 0x0000;
+// public short inputMap = 0x0000;
+ public int inputMap = 0;
public byte leftTrigger = 0x00;
public byte rightTrigger = 0x00;
public short rightStickX = 0x0000;
@@ -34,7 +40,8 @@ public static class ControllerInputContext {
public enum ControllerMode {
Active,
MoveButtons,
- ResizeButtons
+ ResizeButtons,
+ DisableEnableButtons
}
private static final boolean _PRINT_DEBUG_INFORMATION = false;
@@ -59,12 +66,23 @@ public void run() {
private List elements = new ArrayList<>();
+ private Vibrator vibrator;
+
+ private final VibrationEffect defaultVibrationEffect;
+
public VirtualController(final ControllerHandler controllerHandler, FrameLayout layout, final Context context) {
this.controllerHandler = controllerHandler;
this.frame_layout = layout;
this.context = context;
this.handler = new Handler(Looper.getMainLooper());
+ this.vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ defaultVibrationEffect = VibrationEffect.createOneShot(10, VibrationEffect.DEFAULT_AMPLITUDE);
+ } else {
+ defaultVibrationEffect = null;
+ }
+
buttonConfigure = new Button(context);
buttonConfigure.setAlpha(0.25f);
buttonConfigure.setFocusable(false);
@@ -74,16 +92,21 @@ public VirtualController(final ControllerHandler controllerHandler, FrameLayout
public void onClick(View v) {
String message;
- if (currentMode == ControllerMode.Active){
+ if (currentMode == ControllerMode.Active) {
+ currentMode = ControllerMode.DisableEnableButtons;
+ showElements();
+ message = context.getString(R.string.configuration_mode_disable_enable_buttons);
+ } else if (currentMode == ControllerMode.DisableEnableButtons){
currentMode = ControllerMode.MoveButtons;
- message = "Entering configuration mode (Move buttons)";
+ showEnabledElements();
+ message = context.getString(R.string.configuration_mode_move_buttons);
} else if (currentMode == ControllerMode.MoveButtons) {
currentMode = ControllerMode.ResizeButtons;
- message = "Entering configuration mode (Resize buttons)";
+ message = context.getString(R.string.configuration_mode_resize_buttons);
} else {
currentMode = ControllerMode.Active;
VirtualControllerConfigurationLoader.saveProfile(VirtualController.this, context);
- message = "Exiting configuration mode";
+ message = context.getString(R.string.configuration_mode_exiting);
}
Toast.makeText(context, message, Toast.LENGTH_SHORT).show();
@@ -104,18 +127,38 @@ Handler getHandler() {
public void hide() {
for (VirtualControllerElement element : elements) {
- element.setVisibility(View.INVISIBLE);
+ element.setVisibility(View.GONE);
}
- buttonConfigure.setVisibility(View.INVISIBLE);
+ buttonConfigure.setVisibility(View.GONE);
}
public void show() {
- for (VirtualControllerElement element : elements) {
+ showEnabledElements();
+
+ buttonConfigure.setVisibility(View.VISIBLE);
+ }
+
+ public int switchShowHide() {
+ if (buttonConfigure.getVisibility() == View.VISIBLE) {
+ hide();
+ return 0;
+ } else {
+ show();
+ return 1;
+ }
+ }
+
+ public void showElements(){
+ for(VirtualControllerElement element : elements){
element.setVisibility(View.VISIBLE);
}
+ }
- buttonConfigure.setVisibility(View.VISIBLE);
+ public void showEnabledElements(){
+ for(VirtualControllerElement element: elements){
+ element.setVisibility( element.enabled ? View.VISIBLE : View.GONE );
+ }
}
public void removeElements() {
@@ -198,12 +241,27 @@ private void sendControllerInputContextInternal() {
}
}
- void sendControllerInputContext() {
+ public void sendControllerInputContext(long vibrationDuration, int vibrationAmplitude) {
// Cancel retransmissions of prior gamepad inputs
handler.removeCallbacks(delayedRetransmitRunnable);
sendControllerInputContextInternal();
-
+ if (frame_layout != null && PreferenceConfiguration.readPreferences(context).enableKeyboardVibrate) {
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+ VibrationEffect effect;
+ if (vibrationDuration == 0) {
+ effect = defaultVibrationEffect;
+ } else {
+ effect = VibrationEffect.createOneShot(vibrationDuration, vibrationAmplitude);
+ }
+ vibrator.vibrate(effect);
+ } else {
+ if (vibrationDuration == 0) {
+ vibrationDuration = 10;
+ }
+ vibrator.vibrate(vibrationDuration);
+ }
+ }
// HACK: GFE sometimes discards gamepad packets when they are received
// very shortly after another. This can be critical if an axis zeroing packet
// is lost and causes an analog stick to get stuck. To avoid this, we retransmit
@@ -212,4 +270,8 @@ void sendControllerInputContext() {
handler.postDelayed(delayedRetransmitRunnable, 50);
handler.postDelayed(delayedRetransmitRunnable, 75);
}
+
+ public void sendControllerInputContext() {
+ sendControllerInputContext(0, 0);
+ }
}
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerConfigurationLoader.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerConfigurationLoader.java
old mode 100644
new mode 100755
index d66ca8362e..e543ed1f0f
--- a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerConfigurationLoader.java
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerConfigurationLoader.java
@@ -9,6 +9,7 @@
import android.content.SharedPreferences;
import android.util.DisplayMetrics;
+import com.limelight.R;
import com.limelight.nvstream.input.ControllerPacket;
import com.limelight.preferences.PreferenceConfiguration;
@@ -65,7 +66,7 @@ public void onDirectionChange(int direction) {
inputContext.inputMap &= ~ControllerPacket.DOWN_FLAG;
}
- controller.sendControllerInputContext();
+ controller.sendControllerInputContext(10, 0x22);
}
});
@@ -79,12 +80,13 @@ private static DigitalButton createDigitalButton(
final int layer,
final String text,
final int icon,
+ final int iconPress,
final VirtualController controller,
final Context context) {
DigitalButton button = new DigitalButton(controller, elementId, layer, context);
button.setText(text);
button.setIcon(icon);
-
+ button.setIconPress(iconPress);
button.addDigitalButtonListener(new DigitalButton.DigitalButtonListener() {
@Override
public void onClick() {
@@ -122,11 +124,13 @@ private static DigitalButton createLeftTrigger(
final int layer,
final String text,
final int icon,
+ final int iconPress,
final VirtualController controller,
final Context context) {
LeftTrigger button = new LeftTrigger(controller, layer, context);
button.setText(text);
button.setIcon(icon);
+ button.setIconPress(iconPress);
return button;
}
@@ -134,11 +138,13 @@ private static DigitalButton createRightTrigger(
final int layer,
final String text,
final int icon,
+ final int iconPress,
final VirtualController controller,
final Context context) {
RightTrigger button = new RightTrigger(controller, layer, context);
button.setText(text);
button.setIcon(icon);
+ button.setIconPress(iconPress);
return button;
}
@@ -154,6 +160,18 @@ private static AnalogStick createRightStick(
return new RightAnalogStick(controller, context);
}
+ private static AnalogStickFree createLeftStick2(
+ final VirtualController controller,
+ final Context context) {
+ return new LeftAnalogStickFree(controller, context);
+ }
+
+ private static AnalogStickFree createRightStick2(
+ final VirtualController controller,
+ final Context context) {
+ return new RightAnalogStickFree(controller, context);
+ }
+
private static final int TRIGGER_L_BASE_X = 1;
private static final int TRIGGER_R_BASE_X = 92;
@@ -214,7 +232,7 @@ public static void createDefaultLayout(final VirtualController controller, final
controller.addElement(createDigitalButton(
VirtualControllerElement.EID_A,
!config.flipFaceButtons ? ControllerPacket.A_FLAG : ControllerPacket.B_FLAG, 0, 1,
- !config.flipFaceButtons ? "A" : "B", -1, controller, context),
+ !config.flipFaceButtons ? "A" : "B", R.drawable.facebutton_a,R.drawable.facebutton_a_press, controller, context),
screenScale(BUTTON_BASE_X, height) + rightDisplacement,
screenScale(BUTTON_BASE_Y + 2 * BUTTON_SIZE, height),
screenScale(BUTTON_SIZE, height),
@@ -224,7 +242,7 @@ public static void createDefaultLayout(final VirtualController controller, final
controller.addElement(createDigitalButton(
VirtualControllerElement.EID_B,
config.flipFaceButtons ? ControllerPacket.A_FLAG : ControllerPacket.B_FLAG, 0, 1,
- config.flipFaceButtons ? "A" : "B", -1, controller, context),
+ config.flipFaceButtons ? "A" : "B", R.drawable.facebutton_b,R.drawable.facebutton_b_press, controller, context),
screenScale(BUTTON_BASE_X + BUTTON_SIZE, height) + rightDisplacement,
screenScale(BUTTON_BASE_Y + BUTTON_SIZE, height),
screenScale(BUTTON_SIZE, height),
@@ -234,7 +252,7 @@ public static void createDefaultLayout(final VirtualController controller, final
controller.addElement(createDigitalButton(
VirtualControllerElement.EID_X,
!config.flipFaceButtons ? ControllerPacket.X_FLAG : ControllerPacket.Y_FLAG, 0, 1,
- !config.flipFaceButtons ? "X" : "Y", -1, controller, context),
+ !config.flipFaceButtons ? "X" : "Y", R.drawable.facebutton_x,R.drawable.facebutton_x_press, controller, context),
screenScale(BUTTON_BASE_X - BUTTON_SIZE, height) + rightDisplacement,
screenScale(BUTTON_BASE_Y + BUTTON_SIZE, height),
screenScale(BUTTON_SIZE, height),
@@ -244,7 +262,7 @@ public static void createDefaultLayout(final VirtualController controller, final
controller.addElement(createDigitalButton(
VirtualControllerElement.EID_Y,
config.flipFaceButtons ? ControllerPacket.X_FLAG : ControllerPacket.Y_FLAG, 0, 1,
- config.flipFaceButtons ? "X" : "Y", -1, controller, context),
+ config.flipFaceButtons ? "X" : "Y", R.drawable.facebutton_y,R.drawable.facebutton_y_press, controller, context),
screenScale(BUTTON_BASE_X, height) + rightDisplacement,
screenScale(BUTTON_BASE_Y, height),
screenScale(BUTTON_SIZE, height),
@@ -252,7 +270,7 @@ public static void createDefaultLayout(final VirtualController controller, final
);
controller.addElement(createLeftTrigger(
- 1, "LT", -1, controller, context),
+ 1, "LT", R.drawable.facebutton_zl,R.drawable.facebutton_zl_press, controller, context),
screenScale(TRIGGER_L_BASE_X, height),
screenScale(TRIGGER_BASE_Y, height),
screenScale(TRIGGER_WIDTH, height),
@@ -260,7 +278,7 @@ public static void createDefaultLayout(final VirtualController controller, final
);
controller.addElement(createRightTrigger(
- 1, "RT", -1, controller, context),
+ 1, "RT", R.drawable.facebutton_zr,R.drawable.facebutton_zr_press, controller, context),
screenScale(TRIGGER_R_BASE_X + TRIGGER_DISTANCE, height) + rightDisplacement,
screenScale(TRIGGER_BASE_Y, height),
screenScale(TRIGGER_WIDTH, height),
@@ -269,7 +287,7 @@ public static void createDefaultLayout(final VirtualController controller, final
controller.addElement(createDigitalButton(
VirtualControllerElement.EID_LB,
- ControllerPacket.LB_FLAG, 0, 1, "LB", -1, controller, context),
+ ControllerPacket.LB_FLAG, 0, 1, "LB", R.drawable.facebutton_l,R.drawable.facebutton_l_press, controller, context),
screenScale(TRIGGER_L_BASE_X + TRIGGER_DISTANCE, height),
screenScale(TRIGGER_BASE_Y, height),
screenScale(TRIGGER_WIDTH, height),
@@ -278,30 +296,45 @@ public static void createDefaultLayout(final VirtualController controller, final
controller.addElement(createDigitalButton(
VirtualControllerElement.EID_RB,
- ControllerPacket.RB_FLAG, 0, 1, "RB", -1, controller, context),
+ ControllerPacket.RB_FLAG, 0, 1, "RB", R.drawable.facebutton_r,R.drawable.facebutton_r_press, controller, context),
screenScale(TRIGGER_R_BASE_X, height) + rightDisplacement,
screenScale(TRIGGER_BASE_Y, height),
screenScale(TRIGGER_WIDTH, height),
screenScale(TRIGGER_HEIGHT, height)
);
- controller.addElement(createLeftStick(controller, context),
- screenScale(ANALOG_L_BASE_X, height),
- screenScale(ANALOG_L_BASE_Y, height),
- screenScale(ANALOG_SIZE, height),
- screenScale(ANALOG_SIZE, height)
- );
-
- controller.addElement(createRightStick(controller, context),
- screenScale(ANALOG_R_BASE_X, height) + rightDisplacement,
- screenScale(ANALOG_R_BASE_Y, height),
- screenScale(ANALOG_SIZE, height),
- screenScale(ANALOG_SIZE, height)
- );
-
+ if(config.enableNewAnalogStick){
+ controller.addElement(createLeftStick2(controller, context),
+ screenScale(ANALOG_L_BASE_X, height),
+ screenScale(ANALOG_L_BASE_Y, height),
+ screenScale(ANALOG_SIZE, height),
+ screenScale(ANALOG_SIZE, height)
+ );
+
+ controller.addElement(createRightStick2(controller, context),
+ screenScale(ANALOG_R_BASE_X, height) + rightDisplacement,
+ screenScale(ANALOG_R_BASE_Y, height),
+ screenScale(ANALOG_SIZE, height),
+ screenScale(ANALOG_SIZE, height)
+ );
+ }else{
+ controller.addElement(createLeftStick(controller, context),
+ screenScale(ANALOG_L_BASE_X, height),
+ screenScale(ANALOG_L_BASE_Y, height),
+ screenScale(ANALOG_SIZE, height),
+ screenScale(ANALOG_SIZE, height)
+ );
+
+ controller.addElement(createRightStick(controller, context),
+ screenScale(ANALOG_R_BASE_X, height) + rightDisplacement,
+ screenScale(ANALOG_R_BASE_Y, height),
+ screenScale(ANALOG_SIZE, height),
+ screenScale(ANALOG_SIZE, height)
+ );
+ }
controller.addElement(createDigitalButton(
VirtualControllerElement.EID_BACK,
- ControllerPacket.BACK_FLAG, 0, 2, "BACK", -1, controller, context),
+ ControllerPacket.BACK_FLAG, 0, 2, "BACK", R.drawable.facebutton_minus,R.drawable.facebutton_minus_press, controller, context),
screenScale(BACK_X, height),
screenScale(START_BACK_Y, height),
screenScale(START_BACK_WIDTH, height),
@@ -310,17 +343,44 @@ public static void createDefaultLayout(final VirtualController controller, final
controller.addElement(createDigitalButton(
VirtualControllerElement.EID_START,
- ControllerPacket.PLAY_FLAG, 0, 3, "START", -1, controller, context),
+ ControllerPacket.PLAY_FLAG, 0, 3, "START", R.drawable.facebutton_plus,R.drawable.facebutton_plus_press, controller, context),
screenScale(START_X, height) + rightDisplacement,
screenScale(START_BACK_Y, height),
screenScale(START_BACK_WIDTH, height),
screenScale(START_BACK_HEIGHT, height)
);
+
+ controller.addElement(createDigitalButton(
+ VirtualControllerElement.EID_LSB,
+ ControllerPacket.LS_CLK_FLAG, 0, 1, "L3", R.drawable.facebutton_l3,R.drawable.facebutton_l3_press, controller, context),
+ screenScale(TRIGGER_L_BASE_X, height),
+ screenScale(L3_R3_BASE_Y, height),
+ screenScale(TRIGGER_WIDTH, height),
+ screenScale(TRIGGER_HEIGHT, height)
+ );
+
+ controller.addElement(createDigitalButton(
+ VirtualControllerElement.EID_RSB,
+ ControllerPacket.RS_CLK_FLAG, 0, 1, "R3", R.drawable.facebutton_r3,R.drawable.facebutton_r3_press, controller, context),
+ screenScale(TRIGGER_R_BASE_X + TRIGGER_DISTANCE, height) + rightDisplacement,
+ screenScale(L3_R3_BASE_Y, height),
+ screenScale(TRIGGER_WIDTH, height),
+ screenScale(TRIGGER_HEIGHT, height)
+ );
+
+ controller.addElement(createDigitalButton(
+ VirtualControllerElement.EID_TOUCHPAD,
+ ControllerPacket.TOUCHPAD_FLAG, 0, 1, "Trackpad", R.drawable.facebutton_touchpad_press,R.drawable.facebutton_touchpad, controller, context),
+ screenScale(50, height),
+ screenScale(50, height),
+ screenScale(20, height),
+ screenScale(12, height)
+ );
}
else {
controller.addElement(createDigitalButton(
VirtualControllerElement.EID_LSB,
- ControllerPacket.LS_CLK_FLAG, 0, 1, "L3", -1, controller, context),
+ ControllerPacket.LS_CLK_FLAG, 0, 1, "L3", -1, -1,controller, context),
screenScale(TRIGGER_L_BASE_X, height),
screenScale(L3_R3_BASE_Y, height),
screenScale(TRIGGER_WIDTH, height),
@@ -329,7 +389,7 @@ public static void createDefaultLayout(final VirtualController controller, final
controller.addElement(createDigitalButton(
VirtualControllerElement.EID_RSB,
- ControllerPacket.RS_CLK_FLAG, 0, 1, "R3", -1, controller, context),
+ ControllerPacket.RS_CLK_FLAG, 0, 1, "R3", -1,-1, controller, context),
screenScale(TRIGGER_R_BASE_X + TRIGGER_DISTANCE, height) + rightDisplacement,
screenScale(L3_R3_BASE_Y, height),
screenScale(TRIGGER_WIDTH, height),
@@ -337,9 +397,10 @@ public static void createDefaultLayout(final VirtualController controller, final
);
}
+
if(config.showGuideButton){
controller.addElement(createDigitalButton(VirtualControllerElement.EID_GDB,
- ControllerPacket.SPECIAL_BUTTON_FLAG, 0, 1, "GUIDE", -1, controller, context),
+ ControllerPacket.SPECIAL_BUTTON_FLAG, 0, 1, "GUIDE", -1, -1, controller, context),
screenScale(GUIDE_X, height)+ rightDisplacement,
screenScale(GUIDE_Y, height),
screenScale(START_BACK_WIDTH, height),
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerElement.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerElement.java
old mode 100644
new mode 100755
index e45e9ddc75..7a01d638d6
--- a/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerElement.java
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/VirtualControllerElement.java
@@ -1,347 +1,360 @@
-/**
- * Created by Karim Mreisi.
- */
-
-package com.limelight.binding.input.virtual_controller;
-
-import android.app.AlertDialog;
-import android.content.Context;
-import android.content.DialogInterface;
-import android.graphics.Canvas;
-import android.graphics.Paint;
-import android.util.DisplayMetrics;
-import android.view.MotionEvent;
-import android.view.View;
-import android.widget.FrameLayout;
-
-import org.json.JSONException;
-import org.json.JSONObject;
-
-public abstract class VirtualControllerElement extends View {
- protected static boolean _PRINT_DEBUG_INFORMATION = false;
-
- public static final int EID_DPAD = 1;
- public static final int EID_LT = 2;
- public static final int EID_RT = 3;
- public static final int EID_LB = 4;
- public static final int EID_RB = 5;
- public static final int EID_A = 6;
- public static final int EID_B = 7;
- public static final int EID_X = 8;
- public static final int EID_Y = 9;
- public static final int EID_BACK = 10;
- public static final int EID_START = 11;
- public static final int EID_LS = 12;
- public static final int EID_RS = 13;
- public static final int EID_LSB = 14;
- public static final int EID_RSB = 15;
- public static final int EID_GDB = 16;
-
- protected VirtualController virtualController;
- protected final int elementId;
-
- private final Paint paint = new Paint();
-
- private int normalColor = 0xF0888888;
- protected int pressedColor = 0xF00000FF;
- private int configMoveColor = 0xF0FF0000;
- private int configResizeColor = 0xF0FF00FF;
- private int configSelectedColor = 0xF000FF00;
-
- protected int startSize_x;
- protected int startSize_y;
-
- float position_pressed_x = 0;
- float position_pressed_y = 0;
-
- private enum Mode {
- Normal,
- Resize,
- Move
- }
-
- private Mode currentMode = Mode.Normal;
-
- protected VirtualControllerElement(VirtualController controller, Context context, int elementId) {
- super(context);
-
- this.virtualController = controller;
- this.elementId = elementId;
- }
-
- protected void moveElement(int pressed_x, int pressed_y, int x, int y) {
- int newPos_x = (int) getX() + x - pressed_x;
- int newPos_y = (int) getY() + y - pressed_y;
-
- FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams();
-
- layoutParams.leftMargin = newPos_x > 0 ? newPos_x : 0;
- layoutParams.topMargin = newPos_y > 0 ? newPos_y : 0;
- layoutParams.rightMargin = 0;
- layoutParams.bottomMargin = 0;
-
- requestLayout();
- }
-
- protected void resizeElement(int pressed_x, int pressed_y, int width, int height) {
- FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams();
-
- int newHeight = height + (startSize_y - pressed_y);
- int newWidth = width + (startSize_x - pressed_x);
-
- layoutParams.height = newHeight > 20 ? newHeight : 20;
- layoutParams.width = newWidth > 20 ? newWidth : 20;
-
- requestLayout();
- }
-
- @Override
- protected void onDraw(Canvas canvas) {
- onElementDraw(canvas);
-
- if (currentMode != Mode.Normal) {
- paint.setColor(configSelectedColor);
- paint.setStrokeWidth(getDefaultStrokeWidth());
- paint.setStyle(Paint.Style.STROKE);
-
- canvas.drawRect(paint.getStrokeWidth(), paint.getStrokeWidth(),
- getWidth()-paint.getStrokeWidth(), getHeight()-paint.getStrokeWidth(),
- paint);
- }
-
- super.onDraw(canvas);
- }
-
- /*
- protected void actionShowNormalColorChooser() {
- AmbilWarnaDialog colorDialog = new AmbilWarnaDialog(getContext(), normalColor, true, new AmbilWarnaDialog.OnAmbilWarnaListener() {
- @Override
- public void onCancel(AmbilWarnaDialog dialog)
- {}
-
- @Override
- public void onOk(AmbilWarnaDialog dialog, int color) {
- normalColor = color;
- invalidate();
- }
- });
- colorDialog.show();
- }
-
- protected void actionShowPressedColorChooser() {
- AmbilWarnaDialog colorDialog = new AmbilWarnaDialog(getContext(), normalColor, true, new AmbilWarnaDialog.OnAmbilWarnaListener() {
- @Override
- public void onCancel(AmbilWarnaDialog dialog) {
- }
-
- @Override
- public void onOk(AmbilWarnaDialog dialog, int color) {
- pressedColor = color;
- invalidate();
- }
- });
- colorDialog.show();
- }
- */
-
- protected void actionEnableMove() {
- currentMode = Mode.Move;
- }
-
- protected void actionEnableResize() {
- currentMode = Mode.Resize;
- }
-
- protected void actionCancel() {
- currentMode = Mode.Normal;
- invalidate();
- }
-
- protected int getDefaultColor() {
- if (virtualController.getControllerMode() == VirtualController.ControllerMode.MoveButtons)
- return configMoveColor;
- else if (virtualController.getControllerMode() == VirtualController.ControllerMode.ResizeButtons)
- return configResizeColor;
- else
- return normalColor;
- }
-
- protected int getDefaultStrokeWidth() {
- DisplayMetrics screen = getResources().getDisplayMetrics();
- return (int)(screen.heightPixels*0.004f);
- }
-
- protected void showConfigurationDialog() {
- AlertDialog.Builder alertBuilder = new AlertDialog.Builder(getContext());
-
- alertBuilder.setTitle("Configuration");
-
- CharSequence functions[] = new CharSequence[]{
- "Move",
- "Resize",
- /*election
- "Set n
- Disable color sormal color",
- "Set pressed color",
- */
- "Cancel"
- };
-
- alertBuilder.setItems(functions, new DialogInterface.OnClickListener() {
-
- @Override
- public void onClick(DialogInterface dialog, int which) {
- switch (which) {
- case 0: { // move
- actionEnableMove();
- break;
- }
- case 1: { // resize
- actionEnableResize();
- break;
- }
- /*
- case 2: { // set default color
- actionShowNormalColorChooser();
- break;
- }
- case 3: { // set pressed color
- actionShowPressedColorChooser();
- break;
- }
- */
- default: { // cancel
- actionCancel();
- break;
- }
- }
- }
- });
- AlertDialog alert = alertBuilder.create();
- // show menu
- alert.show();
- }
-
- @Override
- public boolean onTouchEvent(MotionEvent event) {
- // Ignore secondary touches on controls
- //
- // NB: We can get an additional pointer down if the user touches a non-StreamView area
- // while also touching an OSC control, even if that pointer down doesn't correspond to
- // an area of the OSC control.
- if (event.getActionIndex() != 0) {
- return true;
- }
-
- if (virtualController.getControllerMode() == VirtualController.ControllerMode.Active) {
- return onElementTouchEvent(event);
- }
-
- switch (event.getActionMasked()) {
- case MotionEvent.ACTION_DOWN: {
- position_pressed_x = event.getX();
- position_pressed_y = event.getY();
- startSize_x = getWidth();
- startSize_y = getHeight();
-
- if (virtualController.getControllerMode() == VirtualController.ControllerMode.MoveButtons)
- actionEnableMove();
- else if (virtualController.getControllerMode() == VirtualController.ControllerMode.ResizeButtons)
- actionEnableResize();
-
- return true;
- }
- case MotionEvent.ACTION_MOVE: {
- switch (currentMode) {
- case Move: {
- moveElement(
- (int) position_pressed_x,
- (int) position_pressed_y,
- (int) event.getX(),
- (int) event.getY());
- break;
- }
- case Resize: {
- resizeElement(
- (int) position_pressed_x,
- (int) position_pressed_y,
- (int) event.getX(),
- (int) event.getY());
- break;
- }
- case Normal: {
- break;
- }
- }
- return true;
- }
- case MotionEvent.ACTION_CANCEL:
- case MotionEvent.ACTION_UP: {
- actionCancel();
- return true;
- }
- default: {
- }
- }
- return true;
- }
-
- abstract protected void onElementDraw(Canvas canvas);
-
- abstract public boolean onElementTouchEvent(MotionEvent event);
-
- protected static final void _DBG(String text) {
- if (_PRINT_DEBUG_INFORMATION) {
- System.out.println(text);
- }
- }
-
- public void setColors(int normalColor, int pressedColor) {
- this.normalColor = normalColor;
- this.pressedColor = pressedColor;
-
- invalidate();
- }
-
-
- public void setOpacity(int opacity) {
- int hexOpacity = opacity * 255 / 100;
- this.normalColor = (hexOpacity << 24) | (normalColor & 0x00FFFFFF);
- this.pressedColor = (hexOpacity << 24) | (pressedColor & 0x00FFFFFF);
-
- invalidate();
- }
-
- protected final float getPercent(float value, float percent) {
- return value / 100 * percent;
- }
-
- protected final int getCorrectWidth() {
- return getWidth() > getHeight() ? getHeight() : getWidth();
- }
-
-
- public JSONObject getConfiguration() throws JSONException {
- JSONObject configuration = new JSONObject();
-
- FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams();
-
- configuration.put("LEFT", layoutParams.leftMargin);
- configuration.put("TOP", layoutParams.topMargin);
- configuration.put("WIDTH", layoutParams.width);
- configuration.put("HEIGHT", layoutParams.height);
-
- return configuration;
- }
-
- public void loadConfiguration(JSONObject configuration) throws JSONException {
- FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams();
-
- layoutParams.leftMargin = configuration.getInt("LEFT");
- layoutParams.topMargin = configuration.getInt("TOP");
- layoutParams.width = configuration.getInt("WIDTH");
- layoutParams.height = configuration.getInt("HEIGHT");
-
- requestLayout();
- }
-}
+/**
+ * Created by Karim Mreisi.
+ */
+
+package com.limelight.binding.input.virtual_controller;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.util.DisplayMetrics;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.FrameLayout;
+
+import com.limelight.Game;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+public abstract class VirtualControllerElement extends View {
+ protected static boolean _PRINT_DEBUG_INFORMATION = false;
+
+ public static final int EID_DPAD = 1;
+ public static final int EID_LT = 2;
+ public static final int EID_RT = 3;
+ public static final int EID_LB = 4;
+ public static final int EID_RB = 5;
+ public static final int EID_A = 6;
+ public static final int EID_B = 7;
+ public static final int EID_X = 8;
+ public static final int EID_Y = 9;
+ public static final int EID_BACK = 10;
+ public static final int EID_START = 11;
+ public static final int EID_LS = 12;
+ public static final int EID_RS = 13;
+ public static final int EID_LSB = 14;
+ public static final int EID_RSB = 15;
+ public static final int EID_GDB = 16;
+ public static final int EID_TOUCHPAD = 65;
+
+ protected VirtualController virtualController;
+ protected final int elementId;
+
+ private final Paint paint = new Paint();
+
+ protected int normalColor = 0xF0888888;
+ protected int pressedColor = 0xF07272ED;
+ private int configMoveColor = 0xF0FF0000;
+ private int configResizeColor = 0xF0FF00FF;
+ private int configSelectedColor = 0xF000FF00;
+
+ private int configDisabledColor = 0xF0AAAAAA;
+ protected int startSize_x;
+ protected int startSize_y;
+
+ float position_pressed_x = 0;
+ float position_pressed_y = 0;
+
+ public boolean enabled = true;
+
+ private enum Mode {
+ Normal,
+ Resize,
+ Move
+ }
+
+ private Mode currentMode = Mode.Normal;
+
+ protected VirtualControllerElement(VirtualController controller, Context context, int elementId) {
+ super(context);
+
+ this.virtualController = controller;
+ this.elementId = elementId;
+ }
+
+ protected void moveElement(int pressed_x, int pressed_y, int x, int y) {
+ int newPos_x = (int) getX() + x - pressed_x;
+ int newPos_y = (int) getY() + y - pressed_y;
+
+ FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams();
+
+ layoutParams.leftMargin = newPos_x > 0 ? newPos_x : 0;
+ layoutParams.topMargin = newPos_y > 0 ? newPos_y : 0;
+ layoutParams.rightMargin = 0;
+ layoutParams.bottomMargin = 0;
+
+ requestLayout();
+ }
+
+ protected void resizeElement(int pressed_x, int pressed_y, int width, int height) {
+ FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams();
+
+ int newHeight = height + (startSize_y - pressed_y);
+ int newWidth = width + (startSize_x - pressed_x);
+
+ layoutParams.height = newHeight > 20 ? newHeight : 20;
+ layoutParams.width = newWidth > 20 ? newWidth : 20;
+
+ requestLayout();
+ }
+
+ protected void actionDisableEnableButton(){
+ enabled = !enabled;
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ onElementDraw(canvas);
+
+ if (currentMode != Mode.Normal) {
+ paint.setColor(configSelectedColor);
+ paint.setStrokeWidth(getDefaultStrokeWidth());
+ paint.setStyle(Paint.Style.STROKE);
+
+ canvas.drawRect(paint.getStrokeWidth(), paint.getStrokeWidth(),
+ getWidth()-paint.getStrokeWidth(), getHeight()-paint.getStrokeWidth(),
+ paint);
+ }
+
+ super.onDraw(canvas);
+ }
+
+ /*
+ protected void actionShowNormalColorChooser() {
+ AmbilWarnaDialog colorDialog = new AmbilWarnaDialog(getContext(), normalColor, true, new AmbilWarnaDialog.OnAmbilWarnaListener() {
+ @Override
+ public void onCancel(AmbilWarnaDialog dialog)
+ {}
+
+ @Override
+ public void onOk(AmbilWarnaDialog dialog, int color) {
+ normalColor = color;
+ invalidate();
+ }
+ });
+ colorDialog.show();
+ }
+
+ protected void actionShowPressedColorChooser() {
+ AmbilWarnaDialog colorDialog = new AmbilWarnaDialog(getContext(), normalColor, true, new AmbilWarnaDialog.OnAmbilWarnaListener() {
+ @Override
+ public void onCancel(AmbilWarnaDialog dialog) {
+ }
+
+ @Override
+ public void onOk(AmbilWarnaDialog dialog, int color) {
+ pressedColor = color;
+ invalidate();
+ }
+ });
+ colorDialog.show();
+ }
+ */
+
+ protected void actionEnableMove() {
+ currentMode = Mode.Move;
+ }
+
+ protected void actionEnableResize() {
+ currentMode = Mode.Resize;
+ }
+
+ protected void actionCancel() {
+ currentMode = Mode.Normal;
+ invalidate();
+ }
+
+ protected int getDefaultColor() {
+ if (virtualController.getControllerMode() == VirtualController.ControllerMode.MoveButtons)
+ return configMoveColor;
+ else if (virtualController.getControllerMode() == VirtualController.ControllerMode.ResizeButtons)
+ return configResizeColor;
+ else if (virtualController.getControllerMode() == VirtualController.ControllerMode.DisableEnableButtons)
+ return enabled ? configSelectedColor: configDisabledColor;
+ else return normalColor;
+ }
+
+ protected int getDefaultStrokeWidth() {
+ DisplayMetrics screen = getResources().getDisplayMetrics();
+ return (int)(screen.heightPixels*0.004f);
+ }
+
+ protected void showConfigurationDialog() {
+ AlertDialog.Builder alertBuilder = new AlertDialog.Builder(getContext());
+
+ alertBuilder.setTitle("Configuration");
+
+ CharSequence functions[] = new CharSequence[]{
+ "Move",
+ "Resize",
+ /*election
+ "Set n
+ Disable color sormal color",
+ "Set pressed color",
+ */
+ "Cancel"
+ };
+
+ alertBuilder.setItems(functions, new DialogInterface.OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ switch (which) {
+ case 0: { // move
+ actionEnableMove();
+ break;
+ }
+ case 1: { // resize
+ actionEnableResize();
+ break;
+ }
+ /*
+ case 2: { // set default color
+ actionShowNormalColorChooser();
+ break;
+ }
+ case 3: { // set pressed color
+ actionShowPressedColorChooser();
+ break;
+ }
+ */
+ default: { // cancel
+ actionCancel();
+ break;
+ }
+ }
+ }
+ });
+ AlertDialog alert = alertBuilder.create();
+ // show menu
+ alert.show();
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ // Ignore secondary touches on controls
+ //
+ // NB: We can get an additional pointer down if the user touches a non-StreamView area
+ // while also touching an OSC control, even if that pointer down doesn't correspond to
+ // an area of the OSC control.
+ if (event.getActionIndex() != 0) {
+ return true;
+ }
+
+ if (virtualController.getControllerMode() == VirtualController.ControllerMode.Active) {
+ return onElementTouchEvent(event);
+ }
+
+ switch (event.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN: {
+ position_pressed_x = event.getX();
+ position_pressed_y = event.getY();
+ startSize_x = getWidth();
+ startSize_y = getHeight();
+
+ if (virtualController.getControllerMode() == VirtualController.ControllerMode.MoveButtons)
+ actionEnableMove();
+ else if (virtualController.getControllerMode() == VirtualController.ControllerMode.ResizeButtons)
+ actionEnableResize();
+ else if (virtualController.getControllerMode() == VirtualController.ControllerMode.DisableEnableButtons)
+ actionDisableEnableButton();
+ return true;
+ }
+ case MotionEvent.ACTION_MOVE: {
+ switch (currentMode) {
+ case Move: {
+ moveElement(
+ (int) position_pressed_x,
+ (int) position_pressed_y,
+ (int) event.getX(),
+ (int) event.getY());
+ break;
+ }
+ case Resize: {
+ resizeElement(
+ (int) position_pressed_x,
+ (int) position_pressed_y,
+ (int) event.getX(),
+ (int) event.getY());
+ break;
+ }
+ case Normal: {
+ break;
+ }
+ }
+ return true;
+ }
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP: {
+ actionCancel();
+ return true;
+ }
+ default: {
+ }
+ }
+ return true;
+ }
+
+ abstract protected void onElementDraw(Canvas canvas);
+
+ abstract public boolean onElementTouchEvent(MotionEvent event);
+
+ protected static final void _DBG(String text) {
+ if (_PRINT_DEBUG_INFORMATION) {
+// System.out.println(text);
+ }
+ }
+
+ public void setColors(int normalColor, int pressedColor) {
+ this.normalColor = normalColor;
+ this.pressedColor = pressedColor;
+
+ invalidate();
+ }
+
+
+ public void setOpacity(int opacity) {
+ int hexOpacity = opacity * 255 / 100;
+ this.normalColor = (hexOpacity << 24) | (normalColor & 0x00FFFFFF);
+ this.pressedColor = (hexOpacity << 24) | (pressedColor & 0x00FFFFFF);
+
+ invalidate();
+ }
+
+ protected final float getPercent(float value, float percent) {
+ return value / 100 * percent;
+ }
+
+ protected final int getCorrectWidth() {
+ return getWidth() > getHeight() ? getHeight() : getWidth();
+ }
+
+
+ public JSONObject getConfiguration() throws JSONException {
+ JSONObject configuration = new JSONObject();
+
+ FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams();
+
+ configuration.put("LEFT", layoutParams.leftMargin);
+ configuration.put("TOP", layoutParams.topMargin);
+ configuration.put("WIDTH", layoutParams.width);
+ configuration.put("HEIGHT", layoutParams.height);
+ configuration.put("ENABLED", enabled);
+ return configuration;
+ }
+
+ public void loadConfiguration(JSONObject configuration) throws JSONException {
+ FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams();
+
+ layoutParams.leftMargin = configuration.getInt("LEFT");
+ layoutParams.topMargin = configuration.getInt("TOP");
+ layoutParams.width = configuration.getInt("WIDTH");
+ layoutParams.height = configuration.getInt("HEIGHT");
+ enabled = configuration.getBoolean("ENABLED");
+ setVisibility(enabled ? VISIBLE: GONE);
+ requestLayout();
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyAnalogStick.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyAnalogStick.java
new file mode 100755
index 0000000000..f8ba12947a
--- /dev/null
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyAnalogStick.java
@@ -0,0 +1,351 @@
+/**
+ * Created by Karim Mreisi.
+ */
+
+package com.limelight.binding.input.virtual_controller.keyboard;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.view.MotionEvent;
+
+import com.limelight.binding.input.virtual_controller.VirtualController;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * This is a analog stick on screen element. It is used to get 2-Axis user input.
+ */
+public class KeyAnalogStick extends keyBoardVirtualControllerElement {
+
+ /**
+ * outer radius size in percent of the ui element
+ */
+ public static final int SIZE_RADIUS_COMPLETE = 90;
+ /**
+ * analog stick size in percent of the ui element
+ */
+ public static final int SIZE_RADIUS_ANALOG_STICK = 90;
+ /**
+ * dead zone size in percent of the ui element
+ */
+ public static final int SIZE_RADIUS_DEADZONE = 90;
+ /**
+ * time frame for a double click
+ */
+ public final static long timeoutDoubleClick = 350;
+
+ /**
+ * touch down time until the deadzone is lifted to allow precise movements with the analog sticks
+ */
+ public final static long timeoutDeadzone = 150;
+
+ /**
+ * Listener interface to update registered observers.
+ */
+ public interface AnalogStickListener {
+
+ /**
+ * onMovement event will be fired on real analog stick movement (outside of the deadzone).
+ *
+ * @param x horizontal position, value from -1.0 ... 0 .. 1.0
+ * @param y vertical position, value from -1.0 ... 0 .. 1.0
+ */
+ void onMovement(float x, float y);
+
+ /**
+ * onClick event will be fired on click on the analog stick
+ */
+ void onClick();
+
+ /**
+ * onDoubleClick event will be fired on a double click in a short time frame on the analog
+ * stick.
+ */
+ void onDoubleClick();
+
+ /**
+ * onRevoke event will be fired on unpress of the analog stick.
+ */
+ void onRevoke();
+ }
+
+ /**
+ * Movement states of the analog sick.
+ */
+ private enum STICK_STATE {
+ NO_MOVEMENT,
+ MOVED_IN_DEAD_ZONE,
+ MOVED_ACTIVE
+ }
+
+ /**
+ * Click type states.
+ */
+ private enum CLICK_STATE {
+ SINGLE,
+ DOUBLE
+ }
+
+ /**
+ * configuration if the analog stick should be displayed as circle or square
+ */
+ private boolean circle_stick = true; // TODO: implement square sick for simulations
+
+ /**
+ * outer radius, this size will be automatically updated on resize
+ */
+ private float radius_complete = 0;
+ /**
+ * analog stick radius, this size will be automatically updated on resize
+ */
+ private float radius_analog_stick = 0;
+ /**
+ * dead zone radius, this size will be automatically updated on resize
+ */
+ private float radius_dead_zone = 0;
+
+ /**
+ * horizontal position in relation to the center of the element
+ */
+ private float relative_x = 0;
+ /**
+ * vertical position in relation to the center of the element
+ */
+ private float relative_y = 0;
+
+
+ private double movement_radius = 0;
+ private double movement_angle = 0;
+
+ private float position_stick_x = 0;
+ private float position_stick_y = 0;
+
+ private final Paint paint = new Paint();
+
+ private STICK_STATE stick_state = STICK_STATE.NO_MOVEMENT;
+ private CLICK_STATE click_state = CLICK_STATE.SINGLE;
+
+ private List listeners = new ArrayList<>();
+ private long timeLastClick = 0;
+
+ private static double getMovementRadius(float x, float y) {
+ return Math.sqrt(x * x + y * y);
+ }
+
+ private static double getAngle(float way_x, float way_y) {
+ // prevent divisions by zero for corner cases
+ if (way_x == 0) {
+ return way_y < 0 ? Math.PI : 0;
+ } else if (way_y == 0) {
+ if (way_x > 0) {
+ return Math.PI * 3 / 2;
+ } else if (way_x < 0) {
+ return Math.PI * 1 / 2;
+ }
+ }
+ // return correct calculated angle for each quadrant
+ if (way_x > 0) {
+ if (way_y < 0) {
+ // first quadrant
+ return 3 * Math.PI / 2 + Math.atan((double) (-way_y / way_x));
+ } else {
+ // second quadrant
+ return Math.PI + Math.atan((double) (way_x / way_y));
+ }
+ } else {
+ if (way_y > 0) {
+ // third quadrant
+ return Math.PI / 2 + Math.atan((double) (way_y / -way_x));
+ } else {
+ // fourth quadrant
+ return 0 + Math.atan((double) (-way_x / -way_y));
+ }
+ }
+ }
+
+ public KeyAnalogStick(KeyBoardController controller, Context context, String elementId) {
+ super(controller, context, elementId);
+ // reset stick position
+ position_stick_x = getWidth() / 2;
+ position_stick_y = getHeight() / 2;
+ }
+
+ public void addAnalogStickListener(AnalogStickListener listener) {
+ listeners.add(listener);
+ }
+
+ private void notifyOnMovement(float x, float y) {
+ _DBG("movement x: " + x + " movement y: " + y);
+ // notify listeners
+ for (AnalogStickListener listener : listeners) {
+ listener.onMovement(x, y);
+ }
+ }
+
+ private void notifyOnClick() {
+ _DBG("click");
+ // notify listeners
+ for (AnalogStickListener listener : listeners) {
+ listener.onClick();
+ }
+ }
+
+ private void notifyOnDoubleClick() {
+ _DBG("double click");
+ // notify listeners
+ for (AnalogStickListener listener : listeners) {
+ listener.onDoubleClick();
+ }
+ }
+
+ private void notifyOnRevoke() {
+ _DBG("revoke");
+ // notify listeners
+ for (AnalogStickListener listener : listeners) {
+ listener.onRevoke();
+ }
+ }
+
+ @Override
+ protected void onSizeChanged(int w, int h, int oldw, int oldh) {
+ // calculate new radius sizes depending
+ radius_complete = getPercent(getCorrectWidth() / 2, 100) - 2 * getDefaultStrokeWidth();
+ radius_dead_zone = getPercent(getCorrectWidth() / 2, 30);
+ radius_analog_stick = getPercent(getCorrectWidth() / 2, 20);
+
+ super.onSizeChanged(w, h, oldw, oldh);
+ }
+
+ @Override
+ protected void onElementDraw(Canvas canvas) {
+ // set transparent background
+ canvas.drawColor(Color.TRANSPARENT);
+
+ paint.setStyle(Paint.Style.STROKE);
+ paint.setStrokeWidth(getDefaultStrokeWidth());
+
+ // draw outer circle
+ if (!isPressed() || click_state == CLICK_STATE.SINGLE) {
+ paint.setColor(getDefaultColor());
+ } else {
+ paint.setColor(pressedColor);
+ }
+ canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_complete, paint);
+
+ paint.setColor(getDefaultColor());
+ // draw dead zone
+ canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_dead_zone, paint);
+
+ // draw stick depending on state
+ switch (stick_state) {
+ case NO_MOVEMENT: {
+ paint.setColor(getDefaultColor());
+ canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_analog_stick, paint);
+ break;
+ }
+ case MOVED_IN_DEAD_ZONE:
+ case MOVED_ACTIVE: {
+ paint.setColor(pressedColor);
+ canvas.drawCircle(position_stick_x, position_stick_y, radius_analog_stick, paint);
+ break;
+ }
+ }
+ }
+
+ private void updatePosition(long eventTime) {
+ // get 100% way
+ float complete = radius_complete - radius_analog_stick;
+
+ // calculate relative way
+ float correlated_y = (float) (Math.sin(Math.PI / 2 - movement_angle) * (movement_radius));
+ float correlated_x = (float) (Math.cos(Math.PI / 2 - movement_angle) * (movement_radius));
+
+ // update positions
+ position_stick_x = getWidth() / 2 - correlated_x;
+ position_stick_y = getHeight() / 2 - correlated_y;
+
+ // Stay active even if we're back in the deadzone because we know the user is actively
+ // giving analog stick input and we don't want to snap back into the deadzone.
+ // We also release the deadzone if the user keeps the stick pressed for a bit to allow
+ // them to make precise movements.
+ stick_state = (stick_state == STICK_STATE.MOVED_ACTIVE ||
+ eventTime - timeLastClick > timeoutDeadzone ||
+ movement_radius > radius_dead_zone) ?
+ STICK_STATE.MOVED_ACTIVE : STICK_STATE.MOVED_IN_DEAD_ZONE;
+
+ // trigger move event if state active
+ if (stick_state == STICK_STATE.MOVED_ACTIVE) {
+ notifyOnMovement(-correlated_x / complete, correlated_y / complete);
+ }
+ }
+
+ @Override
+ public boolean onElementTouchEvent(MotionEvent event) {
+ // save last click state
+ CLICK_STATE lastClickState = click_state;
+
+ // get absolute way for each axis
+ relative_x = -(getWidth() / 2 - event.getX());
+ relative_y = -(getHeight() / 2 - event.getY());
+
+ // get radius and angel of movement from center
+ movement_radius = getMovementRadius(relative_x, relative_y);
+ movement_angle = getAngle(relative_x, relative_y);
+
+ // pass touch event to parent if out of outer circle
+ if (movement_radius > radius_complete && !isPressed())
+ return false;
+
+ // chop radius if out of outer circle or near the edge
+ if (movement_radius > (radius_complete - radius_analog_stick)) {
+ movement_radius = radius_complete - radius_analog_stick;
+ }
+
+ // handle event depending on action
+ switch (event.getActionMasked()) {
+ // down event (touch event)
+ case MotionEvent.ACTION_DOWN: {
+ // set to dead zoned, will be corrected in update position if necessary
+ stick_state = STICK_STATE.MOVED_IN_DEAD_ZONE;
+ // check for double click
+ if (lastClickState == CLICK_STATE.SINGLE &&
+ event.getEventTime() - timeLastClick <= timeoutDoubleClick) {
+ click_state = CLICK_STATE.DOUBLE;
+ notifyOnDoubleClick();
+ } else {
+ click_state = CLICK_STATE.SINGLE;
+ notifyOnClick();
+ }
+ // reset last click timestamp
+ timeLastClick = event.getEventTime();
+ // set item pressed and update
+ setPressed(true);
+ break;
+ }
+ // up event (revoke touch)
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP: {
+ setPressed(false);
+ break;
+ }
+ }
+
+ if (isPressed()) {
+ // when is pressed calculate new positions (will trigger movement if necessary)
+ updatePosition(event.getEventTime());
+ } else {
+ stick_state = STICK_STATE.NO_MOVEMENT;
+ notifyOnRevoke();
+
+ // not longer pressed reset analog stick
+ notifyOnMovement(0, 0);
+ }
+ // refresh view
+ invalidate();
+ // accept the touch event
+ return true;
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardAnalogStickButton.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardAnalogStickButton.java
new file mode 100755
index 0000000000..6c19173e0a
--- /dev/null
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardAnalogStickButton.java
@@ -0,0 +1,154 @@
+package com.limelight.binding.input.virtual_controller.keyboard;
+
+import android.content.Context;
+
+
+public class KeyBoardAnalogStickButton extends KeyAnalogStick {
+
+ private final int MIN_CIRCLE_R = 10000; //当摇杆移动的非常小时,不产生操作,摇杆范围-32765 0) {
+ stickIndex[0] = y;
+ stickIndex[1] = -1;
+ } else if (y < 0) {
+ stickIndex[0] = -1;
+ stickIndex[1] = -y;
+ } else {
+ stickIndex[0] = 0;
+ stickIndex[1] = -1;
+ }
+
+ if (x > 0) {
+ stickIndex[2] = -1;
+ stickIndex[3] = x;
+ } else if (x < 0) {
+ stickIndex[2] = -x;
+ stickIndex[3] = -1;
+ } else {
+ stickIndex[2] = 0;
+ stickIndex[3] = -1;
+ }
+
+
+ boolean b = x * x + y * y > MIN_CIRCLE_R * MIN_CIRCLE_R;
+ if (y >= EIGHTH_THREE_PI * x && y >= NEGATIVE_EIGHTH_THREE_PI * x && b){
+ //UP
+ stickBool[0] = true;
+ stickBool[1] = false;
+ stickBool[2] = false;
+ stickBool[3] = false;
+ } else if (y < EIGHTH_THREE_PI * x && y < NEGATIVE_EIGHTH_THREE_PI * x && b){
+ //DOWN
+ stickBool[0] = false;
+ stickBool[1] = true;
+ stickBool[2] = false;
+ stickBool[3] = false;
+ } else if (y >= EIGHTH_PI * x && y < NEGATIVE_EIGHTH_PI * x && b){
+ //LEFT
+ stickBool[0] = false;
+ stickBool[1] = false;
+ stickBool[2] = true;
+ stickBool[3] = false;
+ } else if (y < EIGHTH_PI * x && y >= NEGATIVE_EIGHTH_PI * x && b){
+ //RIGHT
+ stickBool[0] = false;
+ stickBool[1] = false;
+ stickBool[2] = false;
+ stickBool[3] = true;
+ } else if (y < NEGATIVE_EIGHTH_THREE_PI * x && y >= NEGATIVE_EIGHTH_PI * x && b){
+ //UP & LEFT
+ stickBool[0] = true;
+ stickBool[1] = false;
+ stickBool[2] = true;
+ stickBool[3] = false;
+ } else if (y >= NEGATIVE_EIGHTH_THREE_PI * x && y < NEGATIVE_EIGHTH_PI * x && b){
+ //DOWN & RIGHT
+ stickBool[0] = false;
+ stickBool[1] = true;
+ stickBool[2] = false;
+ stickBool[3] = true;
+ } else if (y >= EIGHTH_PI * x && y < EIGHTH_THREE_PI * x && b){
+ //UP & RIGHT
+ stickBool[0] = true;
+ stickBool[1] = false;
+ stickBool[2] = false;
+ stickBool[3] = true;
+ } else if (y < EIGHTH_PI * x && y >= EIGHTH_THREE_PI * x && b){
+ //DOWN & LEFT
+ stickBool[0] = false;
+ stickBool[1] = true;
+ stickBool[2] = true;
+ stickBool[3] = false;
+ } else {
+ stickBool[0] = false;
+ stickBool[1] = false;
+ stickBool[2] = false;
+ stickBool[3] = false;
+ }
+
+ for (int i = 0; i < 4; i++) {
+ listener.onkeyEvent(stickSender[i],stickBool[i]);
+ }
+ }
+
+ @Override
+ public void onClick() {
+
+ }
+
+ @Override
+ public void onDoubleClick() {
+ listener.onkeyEvent(stickSender[4],true);
+ }
+
+ @Override
+ public void onRevoke() {
+ stickIndex[0] = 0;
+ stickIndex[1] = -1;
+ stickIndex[2] = 0;
+ stickIndex[3] = -1;
+ stickBool[0] = false;
+ stickBool[1] = false;
+ stickBool[2] = false;
+ stickBool[3] = false;
+ for (int i = 0; i < 4; i++) {
+ listener.onkeyEvent(stickSender[i],stickBool[i]);
+ }
+ listener.onkeyEvent(stickSender[4],false);
+ }
+ });
+ }
+
+ public interface KeyBoardAnalogStickListener {
+ void onkeyEvent(int code,boolean isPress);
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardAnalogStickButtonFree.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardAnalogStickButtonFree.java
new file mode 100755
index 0000000000..2c1e1d20cd
--- /dev/null
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardAnalogStickButtonFree.java
@@ -0,0 +1,154 @@
+package com.limelight.binding.input.virtual_controller.keyboard;
+
+import android.content.Context;
+
+
+public class KeyBoardAnalogStickButtonFree extends keyAnalogStickFree {
+
+ private final int MIN_CIRCLE_R = 10000; //当摇杆移动的非常小时,不产生操作,摇杆范围-32765 0) {
+ stickIndex[0] = y;
+ stickIndex[1] = -1;
+ } else if (y < 0) {
+ stickIndex[0] = -1;
+ stickIndex[1] = -y;
+ } else {
+ stickIndex[0] = 0;
+ stickIndex[1] = -1;
+ }
+
+ if (x > 0) {
+ stickIndex[2] = -1;
+ stickIndex[3] = x;
+ } else if (x < 0) {
+ stickIndex[2] = -x;
+ stickIndex[3] = -1;
+ } else {
+ stickIndex[2] = 0;
+ stickIndex[3] = -1;
+ }
+
+
+ boolean b = x * x + y * y > MIN_CIRCLE_R * MIN_CIRCLE_R;
+ if (y >= EIGHTH_THREE_PI * x && y >= NEGATIVE_EIGHTH_THREE_PI * x && b){
+ //UP
+ stickBool[0] = true;
+ stickBool[1] = false;
+ stickBool[2] = false;
+ stickBool[3] = false;
+ } else if (y < EIGHTH_THREE_PI * x && y < NEGATIVE_EIGHTH_THREE_PI * x && b){
+ //DOWN
+ stickBool[0] = false;
+ stickBool[1] = true;
+ stickBool[2] = false;
+ stickBool[3] = false;
+ } else if (y >= EIGHTH_PI * x && y < NEGATIVE_EIGHTH_PI * x && b){
+ //LEFT
+ stickBool[0] = false;
+ stickBool[1] = false;
+ stickBool[2] = true;
+ stickBool[3] = false;
+ } else if (y < EIGHTH_PI * x && y >= NEGATIVE_EIGHTH_PI * x && b){
+ //RIGHT
+ stickBool[0] = false;
+ stickBool[1] = false;
+ stickBool[2] = false;
+ stickBool[3] = true;
+ } else if (y < NEGATIVE_EIGHTH_THREE_PI * x && y >= NEGATIVE_EIGHTH_PI * x && b){
+ //UP & LEFT
+ stickBool[0] = true;
+ stickBool[1] = false;
+ stickBool[2] = true;
+ stickBool[3] = false;
+ } else if (y >= NEGATIVE_EIGHTH_THREE_PI * x && y < NEGATIVE_EIGHTH_PI * x && b){
+ //DOWN & RIGHT
+ stickBool[0] = false;
+ stickBool[1] = true;
+ stickBool[2] = false;
+ stickBool[3] = true;
+ } else if (y >= EIGHTH_PI * x && y < EIGHTH_THREE_PI * x && b){
+ //UP & RIGHT
+ stickBool[0] = true;
+ stickBool[1] = false;
+ stickBool[2] = false;
+ stickBool[3] = true;
+ } else if (y < EIGHTH_PI * x && y >= EIGHTH_THREE_PI * x && b){
+ //DOWN & LEFT
+ stickBool[0] = false;
+ stickBool[1] = true;
+ stickBool[2] = true;
+ stickBool[3] = false;
+ } else {
+ stickBool[0] = false;
+ stickBool[1] = false;
+ stickBool[2] = false;
+ stickBool[3] = false;
+ }
+
+ for (int i = 0; i < 4; i++) {
+ listener.onkeyEvent(stickSender[i],stickBool[i]);
+ }
+ }
+
+ @Override
+ public void onClick() {
+
+ }
+
+ @Override
+ public void onDoubleClick() {
+ listener.onkeyEvent(stickSender[4],true);
+ }
+
+ @Override
+ public void onRevoke() {
+ stickIndex[0] = 0;
+ stickIndex[1] = -1;
+ stickIndex[2] = 0;
+ stickIndex[3] = -1;
+ stickBool[0] = false;
+ stickBool[1] = false;
+ stickBool[2] = false;
+ stickBool[3] = false;
+ for (int i = 0; i < 4; i++) {
+ listener.onkeyEvent(stickSender[i],stickBool[i]);
+ }
+ listener.onkeyEvent(stickSender[4],false);
+ }
+ });
+ }
+
+ public interface KeyBoardAnalogStickListener {
+ void onkeyEvent(int code,boolean isPress);
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardController.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardController.java
new file mode 100755
index 0000000000..3f85857e70
--- /dev/null
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardController.java
@@ -0,0 +1,248 @@
+/**
+ * Created by Karim Mreisi.
+ */
+
+package com.limelight.binding.input.virtual_controller.keyboard;
+
+import android.content.Context;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Vibrator;
+import android.util.DisplayMetrics;
+import android.view.HapticFeedbackConstants;
+import android.view.KeyEvent;
+import android.view.View;
+import android.widget.Button;
+import android.widget.FrameLayout;
+import android.widget.Toast;
+
+import com.limelight.Game;
+import com.limelight.LimeLog;
+import com.limelight.R;
+import com.limelight.binding.input.ControllerHandler;
+import com.limelight.nvstream.NvConnection;
+import com.limelight.preferences.PreferenceConfiguration;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class KeyBoardController {
+
+ public enum ControllerMode {
+ Active,
+ MoveButtons,
+ ResizeButtons,
+ DisableEnableButtons
+ }
+
+ public boolean shown = false;
+
+ private static final boolean _PRINT_DEBUG_INFORMATION = false;
+
+ private final NvConnection conn;
+ private final Context context;
+ private final Handler handler;
+
+ private FrameLayout frame_layout = null;
+
+ ControllerMode currentMode = ControllerMode.Active;
+
+ private Map keyEventRunnableMap = new HashMap<>();
+
+ private Button buttonConfigure = null;
+
+ private Vibrator vibrator;
+ private List elements = new ArrayList<>();
+
+ public KeyBoardController(final NvConnection conn, FrameLayout layout, final Context context) {
+ this.conn = conn;
+ this.frame_layout = layout;
+ this.context = context;
+ this.handler = new Handler(Looper.getMainLooper());
+
+ this.vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE);
+
+ buttonConfigure = new Button(context);
+ buttonConfigure.setAlpha(0.5f);
+ buttonConfigure.setFocusable(false);
+ buttonConfigure.setBackgroundResource(R.drawable.ic_keyboard_setting);
+ buttonConfigure.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ String message;
+
+ if (currentMode == ControllerMode.Active) {
+ currentMode = ControllerMode.DisableEnableButtons;
+ showElements();
+ message = context.getString(R.string.configuration_mode_disable_enable_buttons);
+ } else if (currentMode == ControllerMode.DisableEnableButtons) {
+ currentMode = ControllerMode.MoveButtons;
+ showEnabledElements();
+ message = context.getString(R.string.configuration_mode_move_buttons);
+ } else if (currentMode == ControllerMode.MoveButtons) {
+ currentMode = ControllerMode.ResizeButtons;
+ message = context.getString(R.string.configuration_mode_resize_buttons);
+ } else {
+ currentMode = ControllerMode.Active;
+ KeyBoardControllerConfigurationLoader.saveProfile(KeyBoardController.this, context);
+ message = context.getString(R.string.configuration_mode_exiting);
+ }
+
+ Toast.makeText(context, message, Toast.LENGTH_SHORT).show();
+
+ buttonConfigure.invalidate();
+
+ for (keyBoardVirtualControllerElement element : elements) {
+ element.invalidate();
+ }
+ }
+ });
+
+ }
+
+ Handler getHandler() {
+ return handler;
+ }
+
+ public void hide(boolean temporary) {
+ for (keyBoardVirtualControllerElement element : elements) {
+ element.setVisibility(View.GONE);
+ }
+
+ buttonConfigure.setVisibility(View.GONE);
+ if (!temporary) {
+ shown = false;
+ };
+ }
+
+ public void hide() {
+ hide(false);
+ }
+
+ public void show() {
+ showEnabledElements();
+ buttonConfigure.setVisibility(View.VISIBLE);
+ shown = true;
+ }
+
+ public void showElements() {
+ for (keyBoardVirtualControllerElement element : elements) {
+ element.setVisibility(View.VISIBLE);
+ }
+ }
+
+ public void showEnabledElements() {
+ for (keyBoardVirtualControllerElement element : elements) {
+ element.setVisibility(element.enabled ? View.VISIBLE : View.GONE);
+ }
+ }
+
+ public void toggleVisibility() {
+ if (buttonConfigure.getVisibility() == View.VISIBLE) {
+ hide();
+ } else {
+ show();
+ }
+ }
+
+ public void removeElements() {
+ for (keyBoardVirtualControllerElement element : elements) {
+ frame_layout.removeView(element);
+ }
+ elements.clear();
+
+ frame_layout.removeView(buttonConfigure);
+ }
+
+ public void setOpacity(int opacity) {
+ for (keyBoardVirtualControllerElement element : elements) {
+ element.setOpacity(opacity);
+ }
+ }
+
+
+ public void addElement(keyBoardVirtualControllerElement element, int x, int y, int width, int height) {
+ elements.add(element);
+ FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(width, height);
+ layoutParams.setMargins(x, y, 0, 0);
+
+ frame_layout.addView(element, layoutParams);
+ }
+
+ public List getElements() {
+ return elements;
+ }
+
+ private static final void _DBG(String text) {
+ if (_PRINT_DEBUG_INFORMATION) {
+ LimeLog.info("VirtualController: " + text);
+ }
+ }
+
+ public void refreshLayout() {
+ removeElements();
+
+ DisplayMetrics screen = context.getResources().getDisplayMetrics();
+
+ int buttonSize = (int) (screen.heightPixels * 0.06f);
+ FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(buttonSize, buttonSize);
+ params.leftMargin = 20 + buttonSize;
+ params.topMargin = 15;
+ frame_layout.addView(buttonConfigure, params);
+
+ // Start with the default layout
+ KeyBoardControllerConfigurationLoader.createDefaultLayout(this, context, conn);
+
+ // Apply user preferences onto the default layout
+ KeyBoardControllerConfigurationLoader.loadFromPreferences(this, context);
+ }
+
+ public ControllerMode getControllerMode() {
+ return currentMode;
+ }
+
+ public void sendKeyEvent(KeyEvent keyEvent) {
+ if (Game.instance == null || !Game.instance.connected) {
+ return;
+ }
+ //1-鼠标 0-按键 2-摇杆 3-十字键
+ if (keyEvent.getSource() == 1) {
+ Game.instance.mouseButtonEvent(keyEvent.getKeyCode(), KeyEvent.ACTION_DOWN == keyEvent.getAction());
+ } else {
+ Game.instance.onKey(null, keyEvent.getKeyCode(), keyEvent);
+ }
+
+ if (keyEvent.getSource() != 2) {
+ vibrate(keyEvent.getAction());
+ }
+ }
+
+ public void sendMouseMove(int x,int y){
+ if (Game.instance == null || !Game.instance.connected) {
+ return;
+ }
+ Game.instance.mouseMove(x,y);
+ }
+
+ public void vibrate(int action) {
+ if (PreferenceConfiguration.readPreferences(context).enableKeyboardVibrate && vibrator.hasVibrator()) {
+ switch (action) {
+ case KeyEvent.ACTION_DOWN:
+ frame_layout.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
+ break;
+ case KeyEvent.ACTION_UP:
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
+ frame_layout.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY_RELEASE);
+ } else {
+ frame_layout.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
+ }
+ break;
+ default:
+ frame_layout.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardControllerConfigurationLoader.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardControllerConfigurationLoader.java
new file mode 100755
index 0000000000..f6ff83248c
--- /dev/null
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardControllerConfigurationLoader.java
@@ -0,0 +1,569 @@
+/**
+ * Created by Karim Mreisi.
+ */
+
+package com.limelight.binding.input.virtual_controller.keyboard;
+
+import static com.limelight.GameMenu.getModifier;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.text.TextUtils;
+import android.util.DisplayMetrics;
+import android.view.KeyEvent;
+import android.widget.Toast;
+import androidx.preference.PreferenceManager;
+
+import com.limelight.GameMenu;
+import com.limelight.LimeLog;
+import com.limelight.R;
+import com.limelight.nvstream.NvConnection;
+import com.limelight.nvstream.input.KeyboardPacket;
+import com.limelight.preferences.PreferenceConfiguration;
+import com.limelight.utils.KeyMapper;
+
+import org.jcodec.common.ArrayUtil;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.util.BitSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+public class KeyBoardControllerConfigurationLoader {
+ public static final String OSC_PREFERENCE = "keyboard_axi_list";
+ public static final String OSC_PREFERENCE_VALUE = "OSC_Keyboard";
+
+ private static final Set MODIFIER_KEY_CODES = new HashSet<>();
+ static {
+ MODIFIER_KEY_CODES.add(KeyEvent.KEYCODE_ALT_LEFT);
+ MODIFIER_KEY_CODES.add(KeyEvent.KEYCODE_ALT_RIGHT);
+ MODIFIER_KEY_CODES.add(KeyEvent.KEYCODE_CTRL_LEFT);
+ MODIFIER_KEY_CODES.add(KeyEvent.KEYCODE_CTRL_RIGHT);
+ MODIFIER_KEY_CODES.add(KeyEvent.KEYCODE_SHIFT_LEFT);
+ MODIFIER_KEY_CODES.add(KeyEvent.KEYCODE_SHIFT_RIGHT);
+ MODIFIER_KEY_CODES.add(KeyEvent.KEYCODE_META_LEFT);
+ MODIFIER_KEY_CODES.add(KeyEvent.KEYCODE_META_RIGHT);
+ }
+
+ public static boolean isModifierKey(int keyCode) {
+ return MODIFIER_KEY_CODES.contains(keyCode);
+ }
+
+ // The default controls are specified using a grid of 128*72 cells at 16:9
+ private static int screenScale(int units, int height) {
+ return (int) (((float) height / (float) 72) * (float) units);
+ }
+
+ private static int screenScaleSwicth(int result, int height) {
+ return result * 72 / height;
+ }
+
+ private static KeyboardDigitalPadButton createDiaitalPadButton(String elementId, int keyCodeLeft, int keyCodeRight, int keyCodeUp, int keyCodeDown, final KeyBoardController controller, final Context context) {
+ KeyboardDigitalPadButton button = new KeyboardDigitalPadButton(controller, context, elementId);
+ button.addDigitalPadListener(new KeyboardDigitalPadButton.DigitalPadListener() {
+ @Override
+ public void onDirectionChange(int direction) {
+ if ((direction & KeyboardDigitalPadButton.DIGITAL_PAD_DIRECTION_LEFT) != 0) {
+ KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, keyCodeLeft);
+ event.setSource(3);
+ controller.sendKeyEvent(event);
+ } else {
+ KeyEvent event = new KeyEvent(KeyEvent.ACTION_UP, keyCodeLeft);
+ event.setSource(3);
+ controller.sendKeyEvent(event);
+ }
+ if ((direction & KeyboardDigitalPadButton.DIGITAL_PAD_DIRECTION_RIGHT) != 0) {
+ KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, keyCodeRight);
+ event.setSource(3);
+ controller.sendKeyEvent(event);
+ } else {
+ KeyEvent event = new KeyEvent(KeyEvent.ACTION_UP, keyCodeRight);
+ event.setSource(3);
+ controller.sendKeyEvent(event);
+ }
+ if ((direction & KeyboardDigitalPadButton.DIGITAL_PAD_DIRECTION_UP) != 0) {
+ KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, keyCodeUp);
+ event.setSource(3);
+ controller.sendKeyEvent(event);
+ } else {
+ KeyEvent event = new KeyEvent(KeyEvent.ACTION_UP, keyCodeUp);
+ event.setSource(3);
+ controller.sendKeyEvent(event);
+ }
+ if ((direction & KeyboardDigitalPadButton.DIGITAL_PAD_DIRECTION_DOWN) != 0) {
+ KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, keyCodeDown);
+ event.setSource(3);
+ controller.sendKeyEvent(event);
+ } else {
+ KeyEvent event = new KeyEvent(KeyEvent.ACTION_UP, keyCodeDown);
+ event.setSource(3);
+ controller.sendKeyEvent(event);
+ }
+ }
+ });
+ return button;
+ }
+
+
+ private static KeyBoardAnalogStickButton createKeyBoardAnalogStickButton(final KeyBoardController controller, String elementId, final Context context, int[] keylist) {
+
+ KeyBoardAnalogStickButton analogStick = new KeyBoardAnalogStickButton(controller, elementId, context, keylist);
+ analogStick.setListener(new KeyBoardAnalogStickButton.KeyBoardAnalogStickListener() {
+ @Override
+ public void onkeyEvent(int code, boolean isPress) {
+ KeyEvent keyEvent = new KeyEvent(isPress ? KeyEvent.ACTION_DOWN : KeyEvent.ACTION_UP, code);
+ keyEvent.setSource(2);
+ controller.sendKeyEvent(keyEvent);
+ }
+ });
+
+ return analogStick;
+
+ }
+
+ private static KeyBoardAnalogStickButtonFree createKeyBoardAnalogStickButton2(final KeyBoardController controller, String elementId, final Context context, int[] keylist) {
+
+ KeyBoardAnalogStickButtonFree analogStick = new KeyBoardAnalogStickButtonFree(controller, elementId, context, keylist);
+ analogStick.setListener(new KeyBoardAnalogStickButtonFree.KeyBoardAnalogStickListener() {
+ @Override
+ public void onkeyEvent(int code, boolean isPress) {
+ KeyEvent keyEvent = new KeyEvent(isPress ? KeyEvent.ACTION_DOWN : KeyEvent.ACTION_UP, code);
+ keyEvent.setSource(2);
+ controller.sendKeyEvent(keyEvent);
+ }
+ });
+
+ return analogStick;
+
+ }
+
+ private static KeyBoardDigitalButton createDigitalButton(
+ final String elementId,
+ final int keyShort,
+ final int type,
+ final int layer,
+ final String text,
+ final int icon,
+ final boolean sticky,
+ final KeyBoardController controller,
+ final Context context) {
+ KeyBoardDigitalButton button = new KeyBoardDigitalButton(controller, elementId, layer, context);
+ button.setText(text);
+ button.setIcon(icon);
+
+ if(elementId.startsWith("m_s_")||elementId.startsWith("key_s_")){
+ button.setEnableSwitchDown(true);
+ }
+
+ if (sticky) {
+ button.addDigitalButtonListener(new KeyBoardDigitalButton.DigitalButtonListener() {
+ @Override
+ public void onClick() {
+ if (button.isSticky()) {
+ button.setSticky(false);
+ return;
+ }
+ KeyEvent keyEvent = new KeyEvent(KeyEvent.ACTION_DOWN, keyShort);
+ keyEvent.setSource(type);
+ controller.sendKeyEvent(keyEvent);
+ }
+
+ @Override
+ public void onLongClick() {
+ button.setSticky(true);
+ controller.vibrate(-1);
+ }
+
+ @Override
+ public void onRelease() {
+ if (button.isSticky()) {
+ return;
+ }
+ KeyEvent keyEvent = new KeyEvent(KeyEvent.ACTION_UP, keyShort);
+ keyEvent.setSource(type);
+ controller.sendKeyEvent(keyEvent);
+ }
+ });
+ } else {
+ button.addDigitalButtonListener(new KeyBoardDigitalButton.DigitalButtonListener() {
+ @Override
+ public void onClick() {
+ KeyEvent keyEvent = new KeyEvent(KeyEvent.ACTION_DOWN, keyShort);
+ keyEvent.setSource(type);
+ controller.sendKeyEvent(keyEvent);
+ }
+
+ @Override
+ public void onLongClick() {
+ }
+
+ @Override
+ public void onRelease() {
+ KeyEvent keyEvent = new KeyEvent(KeyEvent.ACTION_UP, keyShort);
+ keyEvent.setSource(type);
+ controller.sendKeyEvent(keyEvent);
+ }
+ });
+ }
+
+ return button;
+ }
+
+ private static KeyBoardDigitalButton createCustomButton(
+ String elementId,
+ final short[] keys,
+ final int layer,
+ final String text,
+ final int icon,
+ final boolean sticky,
+ final KeyBoardController controller,
+ final NvConnection conn,
+ final Context context
+ ) {
+ KeyBoardDigitalButton button = new KeyBoardDigitalButton(controller, elementId, layer, context);
+ button.setText(text);
+ button.setIcon(icon);
+
+ final byte[] modifier = {(byte) 0};
+
+ if (sticky) {
+ button.addDigitalButtonListener(new KeyBoardDigitalButton.DigitalButtonListener() {
+ @Override
+ public void onClick() {
+ if (button.isSticky()) {
+ button.setSticky(false);
+ return;
+ }
+ controller.vibrate(KeyEvent.ACTION_DOWN);
+ for (short key : keys) {
+ conn.sendKeyboardInput(key, KeyboardPacket.KEY_DOWN, modifier[0], (byte) 0);
+ modifier[0] |= getModifier(key);
+ }
+ }
+
+ @Override
+ public void onLongClick() {
+ button.setSticky(true);
+ controller.vibrate(-1);
+ }
+
+ @Override
+ public void onRelease() {
+ if (button.isSticky()) {
+ return;
+ }
+ controller.vibrate(KeyEvent.ACTION_UP);
+ for (int pos = keys.length - 1; pos >= 0; pos--) {
+ short key = keys[pos];
+ modifier[0] &= (byte) ~getModifier(key);
+ conn.sendKeyboardInput(key, KeyboardPacket.KEY_UP, modifier[0], (byte) 0);
+ }
+ }
+ });
+ } else {
+ button.addDigitalButtonListener(new KeyBoardDigitalButton.DigitalButtonListener() {
+ @Override
+ public void onClick() {
+ controller.vibrate(KeyEvent.ACTION_DOWN);
+ for (short key : keys) {
+ conn.sendKeyboardInput(key, KeyboardPacket.KEY_DOWN, modifier[0], (byte) 0);
+ modifier[0] |= getModifier(key);
+ }
+ }
+
+ @Override
+ public void onLongClick() {
+ }
+
+ @Override
+ public void onRelease() {
+ controller.vibrate(KeyEvent.ACTION_UP);
+ for (int pos = keys.length - 1; pos >= 0; pos--) {
+ short key = keys[pos];
+ modifier[0] &= (byte) ~getModifier(key);
+ conn.sendKeyboardInput(key, KeyboardPacket.KEY_UP, modifier[0], (byte) 0);
+ }
+ }
+ });
+ }
+
+ return button;
+ }
+
+
+ private static KeyBoardTouchPadButton createDigitalTouchButton(
+ final String elementId,
+ final int keyShort,
+ final int type,
+ final int layer,
+ final String text,
+ final int icon,
+ final KeyBoardController controller,
+ final Context context) {
+ KeyBoardTouchPadButton button = new KeyBoardTouchPadButton(controller, elementId, layer, context);
+ button.setText(text);
+ button.setIcon(icon);
+ button.addDigitalButtonListener(new KeyBoardTouchPadButton.DigitalButtonListener() {
+ @Override
+ public void onClick() {
+ int code=keyShort==9?3:1;
+ KeyEvent keyEvent = new KeyEvent(KeyEvent.ACTION_DOWN, code);
+ keyEvent.setSource(type);
+ controller.sendKeyEvent(keyEvent);
+ }
+
+ @Override
+ public void onLongClick() {
+ }
+
+ @Override
+ public void onMove(int x, int y) {
+ controller.sendMouseMove(x,y);
+ }
+
+ @Override
+ public void onRelease() {
+ int code=keyShort==9?3:1;
+ KeyEvent keyEvent = new KeyEvent(KeyEvent.ACTION_UP, code);
+ keyEvent.setSource(type);
+ controller.sendKeyEvent(keyEvent);
+
+ }
+ });
+
+ return button;
+ }
+
+ public static void createDefaultLayout(final KeyBoardController controller, final Context context, final NvConnection conn) {
+
+ DisplayMetrics screen = context.getResources().getDisplayMetrics();
+
+ PreferenceConfiguration config = PreferenceConfiguration.readPreferences(context);
+
+ int height = screen.heightPixels;
+
+ int rightDisplacement = screen.widthPixels - screen.heightPixels * 16 / 9;
+
+ int BUTTON_SIZE = 10;
+
+ int w = screenScale(BUTTON_SIZE, height);
+
+ int maxW = screen.widthPixels / 18;
+
+ if (w > maxW) {
+ BUTTON_SIZE = screenScaleSwicth(maxW, height);
+ w = screenScale(BUTTON_SIZE, height);
+ }
+
+ String result = "";
+ try {
+ InputStream is = context.getAssets().open("config/keyboard.json");
+ int lenght = is.available();
+ byte[] buffer = new byte[lenght];
+ is.read(buffer);
+ result = new String(buffer, "utf8");
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ if (TextUtils.isEmpty(result)) {
+ return;
+ }
+ try {
+ JSONObject jsonObject = new JSONObject(result);
+ JSONObject jsonObject1 = jsonObject.getJSONObject("data");
+
+ JSONArray keystrokeList = jsonObject1.getJSONArray("keystroke");
+ JSONArray dpadList = jsonObject1.getJSONArray("dpad");
+ JSONArray rockerList = jsonObject1.getJSONArray("rocker");
+ JSONArray mouseList = jsonObject1.getJSONArray("mouse");
+
+ //十字键
+ for (int i = 0; i < dpadList.length(); i++) {
+ JSONObject obj = dpadList.getJSONObject(i);
+ String code = obj.optString("elementId");
+ int keyCodeLeft = obj.optInt("leftCode");
+ int keyCodeRight = obj.optInt("rightCode");
+ int keyCodeUp = obj.optInt("upCode");
+ int keyCodeDown = obj.optInt("downCode");
+ controller.addElement(createDiaitalPadButton(code, keyCodeLeft, keyCodeRight, keyCodeUp, keyCodeDown, controller, context),
+ screenScale(92, height) + rightDisplacement,
+ screenScale(41, height),
+ (int) (w * 2.5), (int) (w * 2.5)
+ );
+ }
+ //摇杆
+ for (int i = 0; i < rockerList.length(); i++) {
+ JSONObject obj = rockerList.getJSONObject(i);
+ String code = obj.optString("elementId");
+ int keyCodeLeft = obj.optInt("leftCode");
+ int keyCodeRight = obj.optInt("rightCode");
+ int keyCodeUp = obj.optInt("upCode");
+ int keyCodeDown = obj.optInt("downCode");
+ int keyCodeMiddle = obj.optInt("middleCode");
+ int[] keys = new int[]{keyCodeUp, keyCodeDown, keyCodeLeft, keyCodeRight, keyCodeMiddle};
+
+ if(config.enableNewAnalogStick){
+ controller.addElement(createKeyBoardAnalogStickButton2(controller, code, context, keys),
+ screenScale(4, height),
+ screenScale(41, height),
+ (int) (w * 2.5), (int) (w * 2.5)
+ );
+ }else{
+ controller.addElement(createKeyBoardAnalogStickButton(controller, code, context, keys),
+ screenScale(4, height),
+ screenScale(41, height),
+ (int) (w * 2.5), (int) (w * 2.5)
+ );
+ }
+ }
+
+ //鼠标按键
+ for (int i = 0; i < mouseList.length(); i++) {
+ JSONObject obj = mouseList.getJSONObject(i);
+ obj.put("type", 1);
+ keystrokeList.put(obj);
+ }
+
+ double buttonSum = 14.0;
+
+ int i;
+
+ //普通按键
+ for (i = 0; i < keystrokeList.length(); i++) {
+ JSONObject obj = keystrokeList.getJSONObject(i);
+
+ String name = obj.optString("name");
+
+ int type = obj.optInt("type");
+
+ int code = obj.optInt("code");
+
+ int switchButton = obj.optInt("switchButton");
+
+ String elementId = type == 0 ? "key_" + code : "m_" + code;
+
+ if(switchButton == 1){
+ elementId = type == 0 ? "key_s_" + code : "m_s_" + code;
+ }
+
+ int lastIndex = (int) (i / buttonSum);
+
+ int x = screenScale(1 + (int) (i % buttonSum) * BUTTON_SIZE, height);
+
+ int y = screenScale(BUTTON_SIZE + lastIndex * BUTTON_SIZE, height);
+
+ if(TextUtils.equals("m_9", elementId)||TextUtils.equals("m_10", elementId)||TextUtils.equals("m_11", elementId)){
+ controller.addElement(createDigitalTouchButton(elementId, code, type, 1, name, -1, controller, context),
+ x, y,
+ w, w
+ );
+ }else{
+ controller.addElement(createDigitalButton(elementId, code, type, 1, name, -1, config.stickyModifierKey && isModifierKey(code), controller, context),
+ x, y,
+ w, w
+ );
+ }
+ LimeLog.info("x:" + x + ",y:" + y + ",W&H:" + w + "," + screenScale(BUTTON_SIZE, height));
+ }
+
+ // Custom keys
+ SharedPreferences preferences = context.getSharedPreferences(GameMenu.PREF_NAME, Activity.MODE_PRIVATE);
+ String value = preferences.getString(GameMenu.KEY_NAME,"");
+
+ if(!TextUtils.isEmpty(value)){
+ try {
+ JSONObject object = new JSONObject(value);
+ JSONArray keyMapArr = object.optJSONArray("data");
+ if(keyMapArr != null&&keyMapArr.length()>0){
+ for (int idx = 0; idx < keyMapArr.length(); idx++) {
+ JSONObject keyMap = keyMapArr.getJSONObject(idx);
+ String id = keyMap.optString("id", Integer.toString(idx));
+ String name = keyMap.optString("name");
+ JSONArray keySequence = keyMap.getJSONArray("keys");
+ short[] vkKeyCodes = new short[keySequence.length()];
+ for (int j = 0; j < keySequence.length(); j++) {
+ String code = keySequence.getString(j);
+ int keycode;
+ if (code.startsWith("0x")) {
+ keycode = Integer.parseInt(code.substring(2), 16);
+ } else if (code.startsWith("VK_")) {
+ Field vkCodeField = KeyMapper.class.getDeclaredField(code);
+ keycode = vkCodeField.getInt(null);
+ } else {
+ throw new Exception("Unknown key code: " + code);
+ }
+ vkKeyCodes[j] = (short) keycode;
+ }
+
+ boolean sticky = keyMap.optBoolean("sticky", false);
+
+ int lastIndex = (int) ((idx + i) / buttonSum);
+
+ int x = screenScale(1 + (int) ((idx + i) % buttonSum) * BUTTON_SIZE, height);
+ int y = screenScale(BUTTON_SIZE + lastIndex * BUTTON_SIZE, height);
+
+ controller.addElement(createCustomButton("custom_" + id, vkKeyCodes, 1, name, -1, sticky, controller, conn, context),
+ x, y,
+ w, w
+ );
+ }
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ Toast.makeText(context,context.getString(R.string.wrong_import_format),Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ } catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+
+ controller.setOpacity(config.oscOpacity);
+ }
+
+ public static void saveProfile(final KeyBoardController controller,
+ final Context context) {
+ String name = PreferenceManager.getDefaultSharedPreferences(context).getString(OSC_PREFERENCE, OSC_PREFERENCE_VALUE);
+
+ SharedPreferences.Editor prefEditor = context.getSharedPreferences(name, Activity.MODE_PRIVATE).edit();
+
+ for (keyBoardVirtualControllerElement element : controller.getElements()) {
+ String prefKey = "" + element.elementId;
+ try {
+ prefEditor.putString(prefKey, element.getConfiguration().toString());
+ } catch (JSONException e) {
+ e.printStackTrace();
+ }
+ }
+ prefEditor.apply();
+ }
+
+ public static void loadFromPreferences(final KeyBoardController controller, final Context context) {
+ String name = PreferenceManager.getDefaultSharedPreferences(context).getString(OSC_PREFERENCE, OSC_PREFERENCE_VALUE);
+
+ SharedPreferences pref = context.getSharedPreferences(name, Activity.MODE_PRIVATE);
+
+ for (keyBoardVirtualControllerElement element : controller.getElements()) {
+ String prefKey = "" + element.elementId;
+
+ String jsonConfig = pref.getString(prefKey, null);
+ if (jsonConfig != null) {
+ try {
+ element.loadConfiguration(new JSONObject(jsonConfig));
+ } catch (JSONException e) {
+ e.printStackTrace();
+
+ // Remove the corrupt element from the preferences
+ pref.edit().remove(prefKey).apply();
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardDigitalButton.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardDigitalButton.java
new file mode 100755
index 0000000000..9b6ec434ed
--- /dev/null
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardDigitalButton.java
@@ -0,0 +1,266 @@
+/**
+ * Created by Karim Mreisi.
+ */
+
+package com.limelight.binding.input.virtual_controller.keyboard;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.RectF;
+import android.graphics.drawable.Drawable;
+import android.view.MotionEvent;
+
+import com.limelight.binding.input.virtual_controller.VirtualController;
+import com.limelight.binding.input.virtual_controller.VirtualControllerElement;
+import com.limelight.preferences.PreferenceConfiguration;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * This is a digital button on screen element. It is used to get click and double click user input.
+ */
+public class KeyBoardDigitalButton extends keyBoardVirtualControllerElement {
+
+ /**
+ * Listener interface to update registered observers.
+ */
+ public interface DigitalButtonListener {
+
+ /**
+ * onClick event will be fired on button click.
+ */
+ void onClick();
+
+ /**
+ * onLongClick event will be fired on button long click.
+ */
+ void onLongClick();
+
+ /**
+ * onRelease event will be fired on button unpress.
+ */
+ void onRelease();
+ }
+
+ private List listeners = new ArrayList<>();
+ private String text = "";
+ private int icon = -1;
+ private long timerLongClickTimeout = 300;
+ private final Runnable longClickRunnable = new Runnable() {
+ @Override
+ public void run() {
+ onLongClickCallback();
+ }
+ };
+
+ private final Paint paint = new Paint();
+ private final RectF rect = new RectF();
+
+ private int layer;
+ private KeyBoardDigitalButton movingButton = null;
+ private boolean sticky = false;
+
+ boolean inRange(float x, float y) {
+ return (this.getX() < x && this.getX() + this.getWidth() > x) &&
+ (this.getY() < y && this.getY() + this.getHeight() > y);
+ }
+
+ public boolean checkMovement(float x, float y, KeyBoardDigitalButton movingButton) {
+ // check if the movement happened in the same layer
+ if (movingButton.layer != this.layer) {
+ return false;
+ }
+
+ // save current pressed state
+ boolean wasPressed = isPressed();
+
+ // check if the movement directly happened on the button
+ if ((this.movingButton == null || movingButton == this.movingButton)
+ && this.inRange(x, y)) {
+ // set button pressed state depending on moving button pressed state
+ if (this.isPressed() != movingButton.isPressed()) {
+ this.setPressed(movingButton.isPressed());
+ }
+ }
+ // check if the movement is outside of the range and the movement button
+ // is the saved moving button
+ else if (movingButton == this.movingButton) {
+ this.setPressed(false);
+ }
+
+ // check if a change occurred
+ if (wasPressed != isPressed()) {
+ if (isPressed()) {
+ // is pressed set moving button and emit click event
+ this.movingButton = movingButton;
+ onClickCallback();
+ } else {
+ // no longer pressed reset moving button and emit release event
+ this.movingButton = null;
+ onReleaseCallback();
+ }
+
+ invalidate();
+
+ return true;
+ }
+
+ return false;
+ }
+
+ private void checkMovementForAllButtons(float x, float y) {
+ for (keyBoardVirtualControllerElement element : virtualController.getElements()) {
+ if (element != this && element instanceof KeyBoardDigitalButton) {
+ ((KeyBoardDigitalButton) element).checkMovement(x, y, this);
+ }
+ }
+ }
+
+ public KeyBoardDigitalButton(KeyBoardController controller, String elementId, int layer, Context context) {
+ super(controller, context, elementId);
+ this.layer = layer;
+ }
+
+ public void addDigitalButtonListener(DigitalButtonListener listener) {
+ listeners.add(listener);
+ }
+
+ public void setText(String text) {
+ this.text = text;
+ invalidate();
+ }
+
+ public void setIcon(int id) {
+ this.icon = id;
+ invalidate();
+ }
+
+ public void setSticky(boolean sticky) {
+ this.sticky = sticky;
+ }
+
+ public boolean isSticky() {
+ return this.sticky;
+ }
+
+ @Override
+ protected void onElementDraw(Canvas canvas) {
+ // set transparent background
+ canvas.drawColor(Color.TRANSPARENT);
+
+ paint.setTextSize(getPercent(getWidth(), 25));
+ paint.setTextAlign(Paint.Align.CENTER);
+ paint.setStrokeWidth(getDefaultStrokeWidth());
+
+ boolean shouldSetPressed = isPressed() || isSticky();
+
+ paint.setColor(shouldSetPressed ? pressedColor : getDefaultColor());
+
+ paint.setStyle(shouldSetPressed ? Paint.Style.FILL_AND_STROKE: Paint.Style.STROKE);
+
+ rect.left = rect.top = paint.getStrokeWidth();
+ rect.right = getWidth() - rect.left;
+ rect.bottom = getHeight() - rect.top;
+
+ if(PreferenceConfiguration.readPreferences(getContext()).enableKeyboardSquare){
+ canvas.drawRect(rect,paint);
+ }else{
+ canvas.drawOval(rect, paint);
+ }
+
+ if (icon != -1) {
+ Drawable d = getResources().getDrawable(icon);
+ d.setBounds(5, 5, getWidth() - 5, getHeight() - 5);
+ d.draw(canvas);
+ } else {
+ paint.setStyle(Paint.Style.FILL_AND_STROKE);
+ paint.setStrokeWidth(getDefaultStrokeWidth()/2);
+ canvas.drawText(text, getPercent(getWidth(), 50), getPercent(getHeight(), 63), paint);
+ }
+ }
+
+ private void onClickCallback() {
+ _DBG("clicked");
+ // notify listeners
+ for (DigitalButtonListener listener : listeners) {
+ listener.onClick();
+ }
+
+ virtualController.getHandler().removeCallbacks(longClickRunnable);
+ virtualController.getHandler().postDelayed(longClickRunnable, timerLongClickTimeout);
+ }
+
+ private void onLongClickCallback() {
+ _DBG("long click");
+ // notify listeners
+ for (DigitalButtonListener listener : listeners) {
+ listener.onLongClick();
+ }
+ }
+
+ private void onReleaseCallback() {
+ _DBG("released");
+ // notify listeners
+ for (DigitalButtonListener listener : listeners) {
+ listener.onRelease();
+ }
+
+ // We may be called for a release without a prior click
+ virtualController.getHandler().removeCallbacks(longClickRunnable);
+ }
+
+ private boolean switchDown;
+
+ private boolean enableSwitchDown;
+
+ public void setEnableSwitchDown(boolean enableSwitchDown) {
+ this.enableSwitchDown = enableSwitchDown;
+ }
+
+ @Override
+ public boolean onElementTouchEvent(MotionEvent event) {
+ // get masked (not specific to a pointer) action
+ float x = getX() + event.getX();
+ float y = getY() + event.getY();
+ int action = event.getActionMasked();
+
+ switch (action) {
+ case MotionEvent.ACTION_DOWN: {
+ movingButton = null;
+ setPressed(true);
+ onClickCallback();
+
+ invalidate();
+ if(enableSwitchDown){
+ switchDown=!switchDown;
+ }
+ return true;
+ }
+ case MotionEvent.ACTION_MOVE: {
+ checkMovementForAllButtons(x, y);
+
+ return true;
+ }
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP: {
+ if(enableSwitchDown&&switchDown){
+ return true;
+ }
+ setPressed(false);
+ onReleaseCallback();
+
+ checkMovementForAllButtons(x, y);
+
+ invalidate();
+
+ return true;
+ }
+ default: {
+ }
+ }
+ return true;
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardLayoutController.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardLayoutController.java
new file mode 100755
index 0000000000..3707f720be
--- /dev/null
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardLayoutController.java
@@ -0,0 +1,349 @@
+/**
+ * Created by Karim Mreisi.
+ */
+
+package com.limelight.binding.input.virtual_controller.keyboard;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.graphics.Color;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.text.TextUtils;
+import android.view.Gravity;
+import android.view.HapticFeedbackConstants;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.PopupWindow;
+import android.widget.TextView;
+
+import com.limelight.Game;
+import com.limelight.R;
+import com.limelight.preferences.PreferenceConfiguration;
+
+import java.util.BitSet;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Set;
+
+public class KeyBoardLayoutController {
+ private static final Set MODIFIER_KEY_CODES = new HashSet<>();
+ private static final Set SPECIAL_KEY_CODES = new HashSet<>();
+ private static final long POPUP_DURATION_MS = 75;
+
+ private final long timerLongClickTimeout = 300;
+ private final Context context;
+ private final PreferenceConfiguration prefConfig;
+ private FrameLayout frame_layout = null;
+ private final Handler handler;
+ public boolean shown = false;
+ private final LinearLayout keyboardView;
+ private PopupWindow keyPopup;
+ private TextView keyPopupText;
+ private Runnable hidePopupRunnable;
+
+ static {
+ MODIFIER_KEY_CODES.add(KeyEvent.KEYCODE_ALT_LEFT);
+ MODIFIER_KEY_CODES.add(KeyEvent.KEYCODE_ALT_RIGHT);
+ MODIFIER_KEY_CODES.add(KeyEvent.KEYCODE_CTRL_LEFT);
+ MODIFIER_KEY_CODES.add(KeyEvent.KEYCODE_CTRL_RIGHT);
+ MODIFIER_KEY_CODES.add(KeyEvent.KEYCODE_SHIFT_LEFT);
+ MODIFIER_KEY_CODES.add(KeyEvent.KEYCODE_SHIFT_RIGHT);
+ MODIFIER_KEY_CODES.add(KeyEvent.KEYCODE_META_LEFT);
+ MODIFIER_KEY_CODES.add(KeyEvent.KEYCODE_META_RIGHT);
+
+ SPECIAL_KEY_CODES.add(KeyEvent.KEYCODE_TAB);
+ SPECIAL_KEY_CODES.add(KeyEvent.KEYCODE_ENTER);
+ SPECIAL_KEY_CODES.add(KeyEvent.KEYCODE_SPACE);
+ SPECIAL_KEY_CODES.add(KeyEvent.KEYCODE_DEL);
+ SPECIAL_KEY_CODES.add(KeyEvent.KEYCODE_FORWARD_DEL);
+ SPECIAL_KEY_CODES.add(KeyEvent.KEYCODE_ESCAPE);
+ SPECIAL_KEY_CODES.add(KeyEvent.KEYCODE_CAPS_LOCK);
+ SPECIAL_KEY_CODES.add(KeyEvent.KEYCODE_INSERT);
+ SPECIAL_KEY_CODES.add(KeyEvent.KEYCODE_DPAD_UP);
+ SPECIAL_KEY_CODES.add(KeyEvent.KEYCODE_DPAD_DOWN);
+ SPECIAL_KEY_CODES.add(KeyEvent.KEYCODE_DPAD_LEFT);
+ SPECIAL_KEY_CODES.add(KeyEvent.KEYCODE_DPAD_RIGHT);
+ SPECIAL_KEY_CODES.add(KeyEvent.KEYCODE_PAGE_UP);
+ SPECIAL_KEY_CODES.add(KeyEvent.KEYCODE_PAGE_DOWN);
+ SPECIAL_KEY_CODES.add(KeyEvent.KEYCODE_MOVE_HOME);
+ SPECIAL_KEY_CODES.add(KeyEvent.KEYCODE_MOVE_END);
+ }
+
+ private static final HashMap longClickRunnables = new HashMap<>();
+
+ private final BitSet modifierKeyStates = new BitSet();
+
+ public boolean isModifierKeyPressed(int keyCode) {
+ return modifierKeyStates.get(keyCode);
+ }
+
+ private boolean isModifierKey(int keyCode) {
+ if (prefConfig.stickyModifierKey) {
+ return MODIFIER_KEY_CODES.contains(keyCode);
+ }
+
+ return false;
+ }
+
+ private boolean isSpecialKey(int keyCode) {
+ return SPECIAL_KEY_CODES.contains(keyCode) || MODIFIER_KEY_CODES.contains(keyCode);
+ }
+
+ public KeyBoardLayoutController(FrameLayout layout, final Context context, PreferenceConfiguration prefConfig) {
+ this.frame_layout = layout;
+ this.context = context;
+ this.prefConfig = prefConfig;
+ this.keyboardView = (LinearLayout) LayoutInflater.from(context).inflate(R.layout.layout_axixi_keyboard, null);
+ this.handler = new Handler(Looper.getMainLooper());
+ initKeyPopup();
+ initKeyboard();
+ }
+
+ public Handler getHandler() {
+ return handler;
+ }
+
+ private void initKeyboard() {
+ @SuppressLint("ClickableViewAccessibility")
+ View.OnTouchListener touchListener = (View v, MotionEvent event) -> {
+ int eventAction = event.getAction();
+ String tag = (String) v.getTag();
+ if (TextUtils.equals("hide", tag)) {
+ if (eventAction == MotionEvent.ACTION_UP || eventAction == MotionEvent.ACTION_CANCEL) {
+ hide();
+ }
+ return true;
+ }
+
+ int keyCode = Integer.parseInt(tag);
+ int keyAction;
+ boolean _isModifierKey = isModifierKey(keyCode);
+ boolean _isSpecialKey = isSpecialKey(keyCode);
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ if (_isModifierKey && isModifierKeyPressed(keyCode)) {
+ modifierKeyStates.clear(keyCode);
+ return true;
+ }
+
+ // Key popup
+ if (!TextUtils.equals("hide", tag) && !_isSpecialKey) {
+ String popupText;
+ KeyEvent tempEvent = new KeyEvent(KeyEvent.ACTION_DOWN, keyCode);
+ int unicodeChar = tempEvent.getUnicodeChar(0);
+
+ if (unicodeChar != 0) {
+ popupText = String.valueOf((char) unicodeChar);
+ } else {
+ popupText = KeyEvent.keyCodeToString(keyCode).replace("KEYCODE_", "");
+ }
+
+ keyPopupText.setText(popupText);
+
+ // Force layout measurement
+ keyPopupText.measure(
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
+ );
+
+ int popupWidth = keyPopupText.getMeasuredWidth();
+
+ // Calculate position using the measured width
+ int[] location = new int[2];
+ v.getLocationInWindow(location);
+
+ // Center the popup over the key
+ int x = location[0] + (v.getWidth() - popupWidth) / 2;
+
+ // Show the popup above the key
+ int y = (int) (location[1] - v.getHeight() * 1.5);
+
+ keyPopup.update(x, y, popupWidth, ViewGroup.LayoutParams.WRAP_CONTENT);
+
+ if (keyPopup.isShowing()) {
+ keyPopup.update(x, y, popupWidth, ViewGroup.LayoutParams.WRAP_CONTENT);
+ } else {
+ keyPopup.showAtLocation(v, Gravity.NO_GRAVITY, x, y);
+ }
+ }
+
+ keyAction = KeyEvent.ACTION_DOWN;
+ break;
+
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_CANCEL:
+ if (_isModifierKey && isModifierKeyPressed(keyCode)) {
+ return true;
+ }
+
+ // Remove any pending hide operations
+ handler.removeCallbacks(hidePopupRunnable);
+ // Schedule a new hide operation
+ handler.postDelayed(hidePopupRunnable, POPUP_DURATION_MS);
+
+ keyAction = KeyEvent.ACTION_UP;
+ break;
+ default:
+ return false;
+ }
+
+ KeyEvent keyEvent = new KeyEvent(keyAction, keyCode);
+ keyEvent.setSource(0);
+ sendKeyEvent(keyEvent);
+
+ if (_isModifierKey) {
+ Runnable longClickRunnable = longClickRunnables.get(keyCode);
+ if (longClickRunnable != null) {
+ getHandler().removeCallbacks(longClickRunnable);
+ if (keyAction == KeyEvent.ACTION_DOWN) {
+ getHandler().postDelayed(longClickRunnable, timerLongClickTimeout);
+ }
+ }
+ }
+
+ if (keyAction == KeyEvent.ACTION_DOWN) {
+ if (prefConfig.enableKeyboardVibrate) {
+ keyboardView.performHapticFeedback(
+ HapticFeedbackConstants.VIRTUAL_KEY,
+ HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING |
+ HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
+ );
+ }
+ v.setBackgroundResource(R.drawable.bg_ax_keyboard_button_confirm);
+ } else {
+ if (prefConfig.enableKeyboardVibrate) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
+ keyboardView.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY_RELEASE);
+ } else {
+ keyboardView.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
+ }
+ }
+ v.setBackgroundResource(R.drawable.bg_ax_keyboard_button);
+ }
+ return true;
+ };
+ for (int i = 0; i < keyboardView.getChildCount(); i++) {
+ LinearLayout keyboardRow = (LinearLayout) keyboardView.getChildAt(i);
+ for (int j = 0; j < keyboardRow.getChildCount(); j++) {
+ View child = keyboardRow.getChildAt(j);
+ keyboardRow.getChildAt(j).setOnTouchListener(touchListener);
+ String keyTag = (String) child.getTag();
+ if (keyTag.equals("hide")) {
+ continue;
+ }
+ int keycode = Integer.parseInt((String) child.getTag());
+ if (isModifierKey(keycode)) {
+ longClickRunnables.put(keycode, () -> {
+ modifierKeyStates.set(keycode);
+ if (prefConfig.enableKeyboardVibrate) {
+ child.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
+ }
+ });
+ }
+ }
+ }
+ }
+
+ private void initKeyPopup() {
+ // Create the popup window
+ keyPopupText = new TextView(context);
+ keyPopupText.setBackgroundResource(R.drawable.key_popup_background);
+ keyPopupText.setTextColor(Color.WHITE);
+ keyPopupText.setTextSize(32);
+ keyPopupText.setGravity(Gravity.CENTER);
+ keyPopupText.setPadding(24, 16, 24, 16);
+
+ keyPopup = new PopupWindow(
+ keyPopupText,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ );
+
+ hidePopupRunnable = () -> keyPopup.dismiss();
+ }
+
+ public void hide(boolean temporary) {
+ if (prefConfig.enableKeyboardVibrate) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ keyboardView.performHapticFeedback(HapticFeedbackConstants.REJECT);
+ } else {
+ keyboardView.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
+ }
+ }
+ keyboardView.setVisibility(View.GONE);
+ if (!temporary) {
+ shown = false;
+ }
+ }
+
+ public void hide() {
+ hide(false);
+ }
+
+ public void show() {
+ keyboardView.setVisibility(View.VISIBLE);
+ shown = true;
+ }
+
+ public void toggleVisibility() {
+ if (keyboardView.getVisibility() == View.VISIBLE) {
+ hide();
+ } else {
+ show();
+ }
+ }
+
+ public void refreshLayout() {
+ frame_layout.removeView(keyboardView);
+ // DisplayMetrics screen = context.getResources().getDisplayMetrics();
+ // (int)(screen.heightPixels/0.4)/
+ int height = prefConfig.onscreenKeyboardHeight;
+ int widthPreference = prefConfig.onscreenKeyboardWidth;
+ int width = widthPreference == 1000 ? ViewGroup.LayoutParams.MATCH_PARENT : dip2px(context, widthPreference);
+ FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(width, dip2px(context, height));
+ params.gravity = Gravity.BOTTOM;
+ switch (prefConfig.onscreenKeyboardAlignMode) {
+ case "left": {
+ params.gravity |= Gravity.START;
+ break;
+ }
+ case "right": {
+ params.gravity |= Gravity.END;
+ break;
+ }
+ case "center":
+ default: {
+ params.gravity |= Gravity.CENTER_HORIZONTAL;
+ }
+ }
+
+ // params.leftMargin = 20 + buttonSize;
+ // params.topMargin = 15;
+ keyboardView.setAlpha(prefConfig.oscKeyboardOpacity / 100f);
+ frame_layout.addView(keyboardView, params);
+ }
+
+ public int dip2px(Context context, float dpValue) {
+ final float scale = context.getResources().getDisplayMetrics().density;
+ return (int) (dpValue * scale + 0.5f);
+ }
+
+ public void sendKeyEvent(KeyEvent keyEvent) {
+ if (Game.instance == null || !Game.instance.connected) {
+ return;
+ }
+ // 1-Mouse 0-Buttons 2-Stick 3-DPad
+ if (keyEvent.getSource() == 1) {
+ Game.instance.mouseButtonEvent(keyEvent.getKeyCode(), KeyEvent.ACTION_DOWN == keyEvent.getAction());
+ } else {
+ Game.instance.onKey(null, keyEvent.getKeyCode(), keyEvent);
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardTouchPadButton.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardTouchPadButton.java
new file mode 100755
index 0000000000..4604e15d71
--- /dev/null
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardTouchPadButton.java
@@ -0,0 +1,281 @@
+/**
+ * Created by Karim Mreisi.
+ */
+
+package com.limelight.binding.input.virtual_controller.keyboard;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.RectF;
+import android.graphics.drawable.Drawable;
+import android.text.TextUtils;
+import android.view.MotionEvent;
+
+import com.limelight.LimeLog;
+import com.limelight.preferences.PreferenceConfiguration;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * This is a digital button on screen element. It is used to get click and double click user input.
+ */
+public class KeyBoardTouchPadButton extends keyBoardVirtualControllerElement {
+
+ /**
+ * Listener interface to update registered observers.
+ */
+ public interface DigitalButtonListener {
+
+ /**
+ * onClick event will be fired on button click.
+ */
+ void onClick();
+
+ /**
+ * onLongClick event will be fired on button long click.
+ */
+ void onLongClick();
+
+ void onMove(int x, int y);
+
+ /**
+ * onRelease event will be fired on button unpress.
+ */
+ void onRelease();
+ }
+
+ private List listeners = new ArrayList<>();
+ private String text = "";
+ private int icon = -1;
+ private long timerLongClickTimeout = 3000;
+ private final Runnable longClickRunnable = new Runnable() {
+ @Override
+ public void run() {
+ onLongClickCallback();
+ }
+ };
+
+ private final Paint paint = new Paint();
+ private final RectF rect = new RectF();
+
+ private int layer;
+ private KeyBoardTouchPadButton movingButton = null;
+
+ boolean inRange(float x, float y) {
+ return (this.getX() < x && this.getX() + this.getWidth() > x) &&
+ (this.getY() < y && this.getY() + this.getHeight() > y);
+ }
+
+ public boolean checkMovement(float x, float y, KeyBoardTouchPadButton movingButton) {
+ // check if the movement happened in the same layer
+ if (movingButton.layer != this.layer) {
+ return false;
+ }
+
+ // save current pressed state
+ boolean wasPressed = isPressed();
+
+ // check if the movement directly happened on the button
+ if ((this.movingButton == null || movingButton == this.movingButton)
+ && this.inRange(x, y)) {
+ // set button pressed state depending on moving button pressed state
+ if (this.isPressed() != movingButton.isPressed()) {
+ this.setPressed(movingButton.isPressed());
+ }
+ }
+ // check if the movement is outside of the range and the movement button
+ // is the saved moving button
+ else if (movingButton == this.movingButton) {
+ this.setPressed(false);
+ }
+
+ // check if a change occurred
+ if (wasPressed != isPressed()) {
+ if (isPressed()) {
+ // is pressed set moving button and emit click event
+ this.movingButton = movingButton;
+ onClickCallback();
+ } else {
+ // no longer pressed reset moving button and emit release event
+ this.movingButton = null;
+ onReleaseCallback();
+ }
+
+ invalidate();
+
+ return true;
+ }
+
+ return false;
+ }
+
+ private void checkMovementForAllButtons(float x, float y) {
+ for (keyBoardVirtualControllerElement element : virtualController.getElements()) {
+ if (element != this && element instanceof KeyBoardTouchPadButton) {
+ ((KeyBoardTouchPadButton) element).checkMovement(x, y, this);
+ }
+ }
+ }
+
+ public KeyBoardTouchPadButton(KeyBoardController controller, String elementId, int layer, Context context) {
+ super(controller, context, elementId);
+ this.layer = layer;
+ preferenceConfiguration=PreferenceConfiguration.readPreferences(context);
+ }
+
+ public void addDigitalButtonListener(DigitalButtonListener listener) {
+ listeners.add(listener);
+ }
+
+ public void setText(String text) {
+ this.text = text;
+ invalidate();
+ }
+
+ public void setIcon(int id) {
+ this.icon = id;
+ invalidate();
+ }
+
+ int pressedColor = 0x2BF5F5F9;
+
+ PreferenceConfiguration preferenceConfiguration;
+
+ @Override
+ protected void onElementDraw(Canvas canvas) {
+ // set transparent background
+ canvas.drawColor(Color.TRANSPARENT);
+
+ paint.setTextSize(getPercent(getWidth(), 25));
+ paint.setTextAlign(Paint.Align.CENTER);
+ paint.setStrokeWidth(getDefaultStrokeWidth());
+
+ paint.setColor(isPressed() ? pressedColor : getDefaultColor());
+
+ paint.setStyle(isPressed() ? Paint.Style.FILL_AND_STROKE : Paint.Style.STROKE);
+
+ rect.left = rect.top = paint.getStrokeWidth();
+ rect.right = getWidth() - rect.left;
+ rect.bottom = getHeight() - rect.top;
+
+ canvas.drawRect(rect, paint);
+
+ if (icon != -1) {
+ Drawable d = getResources().getDrawable(icon);
+ d.setBounds(5, 5, getWidth() - 5, getHeight() - 5);
+ d.draw(canvas);
+ } else {
+ paint.setStyle(Paint.Style.FILL_AND_STROKE);
+ paint.setStrokeWidth(getDefaultStrokeWidth() / 2);
+ canvas.drawText(text, getPercent(getWidth(), 50), getPercent(getHeight(), 63), paint);
+ }
+ }
+
+ private void onClickCallback() {
+ _DBG("clicked");
+ // notify listeners
+ for (DigitalButtonListener listener : listeners) {
+ listener.onClick();
+ }
+
+ virtualController.getHandler().removeCallbacks(longClickRunnable);
+ virtualController.getHandler().postDelayed(longClickRunnable, timerLongClickTimeout);
+ }
+
+ private void onLongClickCallback() {
+ _DBG("long click");
+ // notify listeners
+ for (DigitalButtonListener listener : listeners) {
+ listener.onLongClick();
+ }
+ }
+
+ private void onMoveCallback(int x, int y) {
+ _DBG("long click");
+ // notify listeners
+ for (DigitalButtonListener listener : listeners) {
+ listener.onMove(x, y);
+ }
+ }
+
+ private void onReleaseCallback() {
+ _DBG("released");
+ // notify listeners
+ for (DigitalButtonListener listener : listeners) {
+ listener.onRelease();
+ }
+
+ // We may be called for a release without a prior click
+ virtualController.getHandler().removeCallbacks(longClickRunnable);
+ }
+
+ private long originalTouchTime = 0;
+ private int lastTouchX = 0;
+ private int lastTouchY = 0;
+
+ private double xFactor, yFactor;
+
+ @Override
+ public boolean onElementTouchEvent(MotionEvent event) {
+ // get masked (not specific to a pointer) action
+ int action = event.getActionMasked();
+ switch (action) {
+ case MotionEvent.ACTION_DOWN: {
+ xFactor = 1280 / (double) getWidth();
+ yFactor = 720 / (double) getHeight();
+ lastTouchX = (int) event.getX();
+ lastTouchY = (int) event.getY();
+ movingButton = null;
+ originalTouchTime = event.getEventTime();
+ invalidate();
+ return true;
+ }
+ case MotionEvent.ACTION_MOVE: {
+ int deltaX = (int) (event.getX() - lastTouchX);
+ int deltaY = (int) (event.getY() - lastTouchY);
+ deltaX = (int) Math.round((double) Math.abs(deltaX) * xFactor);
+ deltaY = (int) Math.round((double) Math.abs(deltaY) * yFactor);
+ // Fix up the signs
+ if (event.getX() < lastTouchX) {
+ deltaX = -deltaX;
+ }
+ if (event.getY() < lastTouchY) {
+ deltaY = -deltaY;
+ }
+ if (event.getEventTime() - originalTouchTime > 100 && !isPressed()) {
+ setPressed(true);
+ if(TextUtils.equals(elementId,"m_9")||TextUtils.equals(elementId,"m_11")){
+ onClickCallback();
+ }
+ }
+// LimeLog.info("touchPadSensitivity"+preferenceConfiguration.touchPadSensitivity);
+// LimeLog.info("onElementTouchEvent:" + deltaX + "," + deltaY);
+ onMoveCallback((int) (deltaX*0.01f*preferenceConfiguration.touchPadSensitivity), (int) (deltaY*0.01f*preferenceConfiguration.touchPadYSensitity));
+ if (deltaX != 0) {
+ lastTouchX = (int) event.getX();
+ }
+ if (deltaY != 0) {
+ lastTouchY = (int) event.getY();
+ }
+ invalidate();
+ return true;
+ }
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP: {
+ setPressed(false);
+ if (event.getEventTime() - originalTouchTime <= 200) {
+ onClickCallback();
+ }
+ onReleaseCallback();
+ invalidate();
+ return true;
+ }
+ default: {
+ }
+ }
+ return true;
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyboardDigitalPadButton.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyboardDigitalPadButton.java
new file mode 100755
index 0000000000..e4fb7fd56b
--- /dev/null
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyboardDigitalPadButton.java
@@ -0,0 +1,202 @@
+package com.limelight.binding.input.virtual_controller.keyboard;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.view.MotionEvent;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class KeyboardDigitalPadButton extends keyBoardVirtualControllerElement{
+
+ private String value;
+
+ public final static int DIGITAL_PAD_DIRECTION_NO_DIRECTION = 0;
+ int direction = DIGITAL_PAD_DIRECTION_NO_DIRECTION;
+ public final static int DIGITAL_PAD_DIRECTION_LEFT = 1;
+ public final static int DIGITAL_PAD_DIRECTION_UP = 2;
+ public final static int DIGITAL_PAD_DIRECTION_RIGHT = 4;
+ public final static int DIGITAL_PAD_DIRECTION_DOWN = 8;
+ List listeners = new ArrayList<>();
+
+ private static final int DPAD_MARGIN = 5;
+
+ private final Paint paint = new Paint();
+
+ protected KeyboardDigitalPadButton(KeyBoardController controller, Context context, String elementId) {
+ super(controller, context, elementId);
+ }
+
+ public void addDigitalPadListener(DigitalPadListener listener) {
+ listeners.add(listener);
+ }
+
+ @Override
+ protected void onElementDraw(Canvas canvas) {
+ // set transparent background
+ canvas.drawColor(Color.TRANSPARENT);
+
+ paint.setTextSize(getPercent(getCorrectWidth(), 20));
+ paint.setTextAlign(Paint.Align.CENTER);
+ paint.setStrokeWidth(getDefaultStrokeWidth());
+
+ if (direction == DIGITAL_PAD_DIRECTION_NO_DIRECTION) {
+ // draw no direction rect
+ paint.setStyle(Paint.Style.STROKE);
+ paint.setColor(getDefaultColor());
+ canvas.drawRect(
+ getPercent(getWidth(), 36), getPercent(getHeight(), 36),
+ getPercent(getWidth(), 63), getPercent(getHeight(), 63),
+ paint
+ );
+ }
+
+ // draw left rect
+ paint.setColor(
+ (direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 ? pressedColor : getDefaultColor());
+ paint.setStyle(Paint.Style.STROKE);
+ canvas.drawRect(
+ paint.getStrokeWidth()+DPAD_MARGIN, getPercent(getHeight(), 33),
+ getPercent(getWidth(), 33), getPercent(getHeight(), 66),
+ paint
+ );
+
+
+ // draw up rect
+ paint.setColor(
+ (direction & DIGITAL_PAD_DIRECTION_UP) > 0 ? pressedColor : getDefaultColor());
+ paint.setStyle(Paint.Style.STROKE);
+ canvas.drawRect(
+ getPercent(getWidth(), 33), paint.getStrokeWidth()+DPAD_MARGIN,
+ getPercent(getWidth(), 66), getPercent(getHeight(), 33),
+ paint
+ );
+
+ // draw right rect
+ paint.setColor(
+ (direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 ? pressedColor : getDefaultColor());
+ paint.setStyle(Paint.Style.STROKE);
+ canvas.drawRect(
+ getPercent(getWidth(), 66), getPercent(getHeight(), 33),
+ getWidth() - (paint.getStrokeWidth()+DPAD_MARGIN), getPercent(getHeight(), 66),
+ paint
+ );
+
+ // draw down rect
+ paint.setColor(
+ (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0 ? pressedColor : getDefaultColor());
+ paint.setStyle(Paint.Style.STROKE);
+ canvas.drawRect(
+ getPercent(getWidth(), 33), getPercent(getHeight(), 66),
+ getPercent(getWidth(), 66), getHeight() - (paint.getStrokeWidth()+DPAD_MARGIN),
+ paint
+ );
+
+ // draw left up line
+ paint.setColor((
+ (direction & DIGITAL_PAD_DIRECTION_LEFT) > 0 &&
+ (direction & DIGITAL_PAD_DIRECTION_UP) > 0
+ ) ? pressedColor : getDefaultColor()
+ );
+ paint.setStyle(Paint.Style.STROKE);
+ canvas.drawLine(
+ paint.getStrokeWidth()+DPAD_MARGIN, getPercent(getHeight(), 33),
+ getPercent(getWidth(), 33), paint.getStrokeWidth()+DPAD_MARGIN,
+ paint
+ );
+
+ // draw up right line
+ paint.setColor((
+ (direction & DIGITAL_PAD_DIRECTION_UP) > 0 &&
+ (direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0
+ ) ? pressedColor : getDefaultColor()
+ );
+ paint.setStyle(Paint.Style.STROKE);
+ canvas.drawLine(
+ getPercent(getWidth(), 66), paint.getStrokeWidth()+DPAD_MARGIN,
+ getWidth() - (paint.getStrokeWidth()+DPAD_MARGIN), getPercent(getHeight(), 33),
+ paint
+ );
+
+ // draw right down line
+ paint.setColor((
+ (direction & DIGITAL_PAD_DIRECTION_RIGHT) > 0 &&
+ (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0
+ ) ? pressedColor : getDefaultColor()
+ );
+ paint.setStyle(Paint.Style.STROKE);
+ canvas.drawLine(
+ getWidth()-paint.getStrokeWidth(), getPercent(getHeight(), 66),
+ getPercent(getWidth(), 66), getHeight()-(paint.getStrokeWidth()+DPAD_MARGIN),
+ paint
+ );
+
+ // draw down left line
+ paint.setColor((
+ (direction & DIGITAL_PAD_DIRECTION_DOWN) > 0 &&
+ (direction & DIGITAL_PAD_DIRECTION_LEFT) > 0
+ ) ? pressedColor : getDefaultColor()
+ );
+ paint.setStyle(Paint.Style.STROKE);
+ canvas.drawLine(
+ getPercent(getWidth(), 33), getHeight()-(paint.getStrokeWidth()+DPAD_MARGIN),
+ paint.getStrokeWidth()+DPAD_MARGIN, getPercent(getHeight(), 66),
+ paint
+ );
+ }
+
+ private void newDirectionCallback(int direction) {
+ _DBG("direction: " + direction);
+
+ // notify listeners
+ for (DigitalPadListener listener : listeners) {
+ listener.onDirectionChange(direction);
+ }
+ }
+
+ @Override
+ public boolean onElementTouchEvent(MotionEvent event) {
+ // get masked (not specific to a pointer) action
+ switch (event.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_MOVE: {
+ direction = 0;
+
+ if (event.getX() < getPercent(getWidth(), 33)) {
+ direction |= DIGITAL_PAD_DIRECTION_LEFT;
+ }
+ if (event.getX() > getPercent(getWidth(), 66)) {
+ direction |= DIGITAL_PAD_DIRECTION_RIGHT;
+ }
+ if (event.getY() > getPercent(getHeight(), 66)) {
+ direction |= DIGITAL_PAD_DIRECTION_DOWN;
+ }
+ if (event.getY() < getPercent(getHeight(), 33)) {
+ direction |= DIGITAL_PAD_DIRECTION_UP;
+ }
+ newDirectionCallback(direction);
+ invalidate();
+
+ return true;
+ }
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP: {
+ direction = 0;
+ newDirectionCallback(direction);
+ invalidate();
+
+ return true;
+ }
+ default: {
+ }
+ }
+
+ return true;
+ }
+
+ public interface DigitalPadListener {
+ void onDirectionChange(int direction);
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/keyAnalogStickFree.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/keyAnalogStickFree.java
new file mode 100755
index 0000000000..49b981b666
--- /dev/null
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/keyAnalogStickFree.java
@@ -0,0 +1,419 @@
+/**
+ * Created by Karim Mreisi.
+ */
+
+package com.limelight.binding.input.virtual_controller.keyboard;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.view.MotionEvent;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * This is a analog stick on screen element. It is used to get 2-Axis user input.
+ */
+public class keyAnalogStickFree extends keyBoardVirtualControllerElement {
+
+ /**
+ * outer radius size in percent of the ui element
+ */
+ public static final int SIZE_RADIUS_COMPLETE = 90;
+ /**
+ * analog stick size in percent of the ui element
+ */
+ public static final int SIZE_RADIUS_ANALOG_STICK = 90;
+ /**
+ * dead zone size in percent of the ui element
+ */
+ public static final int SIZE_RADIUS_DEADZONE = 90;
+ /**
+ * time frame for a double click
+ */
+ public final static long timeoutDoubleClick = 350;
+
+ /**
+ * touch down time until the deadzone is lifted to allow precise movements with the analog sticks
+ */
+ public final static long timeoutDeadzone = 150;
+
+ /**
+ * Listener interface to update registered observers.
+ */
+ public interface AnalogStickListener {
+
+ /**
+ * onMovement event will be fired on real analog stick movement (outside of the deadzone).
+ *
+ * @param x horizontal position, value from -1.0 ... 0 .. 1.0
+ * @param y vertical position, value from -1.0 ... 0 .. 1.0
+ */
+ void onMovement(float x, float y);
+
+ /**
+ * onClick event will be fired on click on the analog stick
+ */
+ void onClick();
+
+ /**
+ * onDoubleClick event will be fired on a double click in a short time frame on the analog
+ * stick.
+ */
+ void onDoubleClick();
+
+ /**
+ * onRevoke event will be fired on unpress of the analog stick.
+ */
+ void onRevoke();
+ }
+
+ /**
+ * Movement states of the analog sick.
+ */
+ private enum STICK_STATE {
+ NO_MOVEMENT,
+ MOVED_IN_DEAD_ZONE,
+ MOVED_ACTIVE
+ }
+
+ /**
+ * Click type states.
+ */
+ private enum CLICK_STATE {
+ SINGLE,
+ DOUBLE
+ }
+
+ /**
+ * configuration if the analog stick should be displayed as circle or square
+ */
+ private boolean circle_stick = true; // TODO: implement square sick for simulations
+
+ /**
+ * outer radius, this size will be automatically updated on resize
+ */
+ private float radius_complete = 0;
+ /**
+ * analog stick radius, this size will be automatically updated on resize
+ */
+ private float radius_analog_stick = 0;
+ /**
+ * dead zone radius, this size will be automatically updated on resize
+ */
+ private float radius_dead_zone = 0;
+
+ /**
+ * horizontal position in relation to the center of the element
+ */
+ private float relative_x = 0;
+ /**
+ * vertical position in relation to the center of the element
+ */
+ private float relative_y = 0;
+
+ private boolean bIsFingerOnScreen = false;
+
+ private double movement_radius = 0;
+ private double movement_angle = 0;
+
+ private float position_stick_x = 0;
+ private float position_stick_y = 0;
+
+ private final Paint paint = new Paint();
+
+ private STICK_STATE stick_state = STICK_STATE.NO_MOVEMENT;
+ private CLICK_STATE click_state = CLICK_STATE.SINGLE;
+
+ private List listeners = new ArrayList<>();
+ private long timeLastClick = 0;
+
+ private int touchID;
+ private float touchStartX;
+ private float touchStartY;
+ private float touchX;
+ private float touchY;
+
+ private float touchMaxDistance = 120;
+ private float touchDeadZone = 20;
+ private float fDeadzoneSave = 0.01f;
+
+ protected String strStickSide = "L";
+
+ private static double getMovementRadius(float x, float y) {
+ return Math.sqrt(x * x + y * y);
+ }
+
+ private static double getAngle(float way_x, float way_y) {
+ // prevent divisions by zero for corner cases
+ if (way_x == 0) {
+ return way_y < 0 ? Math.PI : 0;
+ } else if (way_y == 0) {
+ if (way_x > 0) {
+ return Math.PI * 3 / 2;
+ } else if (way_x < 0) {
+ return Math.PI * 1 / 2;
+ }
+ }
+ // return correct calculated angle for each quadrant
+ if (way_x > 0) {
+ if (way_y < 0) {
+ // first quadrant
+ return 3 * Math.PI / 2 + Math.atan((double) (-way_y / way_x));
+ } else {
+ // second quadrant
+ return Math.PI + Math.atan((double) (way_x / way_y));
+ }
+ } else {
+ if (way_y > 0) {
+ // third quadrant
+ return Math.PI / 2 + Math.atan((double) (way_y / -way_x));
+ } else {
+ // fourth quadrant
+ return 0 + Math.atan((double) (-way_x / -way_y));
+ }
+ }
+ }
+
+ public keyAnalogStickFree(KeyBoardController controller, Context context, String elementId) {
+ super(controller, context, elementId);
+ // reset stick position
+ position_stick_x = getWidth() / 2;
+ position_stick_y = getHeight() / 2;
+ }
+
+ public void addAnalogStickListener(AnalogStickListener listener) {
+ listeners.add(listener);
+ }
+
+ private void notifyOnMovement(float x, float y) {
+ _DBG("movement x: " + x + " movement y: " + y);
+ // notify listeners
+ for (AnalogStickListener listener : listeners) {
+ listener.onMovement(x, y);
+ }
+ }
+
+ private void notifyOnClick() {
+ _DBG("click");
+ // notify listeners
+ for (AnalogStickListener listener : listeners) {
+ listener.onClick();
+ }
+ }
+
+ private void notifyOnDoubleClick() {
+ _DBG("double click");
+ // notify listeners
+ for (AnalogStickListener listener : listeners) {
+ listener.onDoubleClick();
+ }
+ }
+
+ private void notifyOnRevoke() {
+ _DBG("revoke");
+ // notify listeners
+ for (AnalogStickListener listener : listeners) {
+ listener.onRevoke();
+ }
+ }
+
+ @Override
+ protected void onSizeChanged(int w, int h, int oldw, int oldh) {
+ // calculate new radius sizes depending
+ radius_complete = getPercent(getCorrectWidth() / 2, 100) - 2 * getDefaultStrokeWidth();
+ radius_dead_zone = getPercent(getCorrectWidth() / 2, 30);
+ radius_analog_stick = getPercent(getCorrectWidth() / 2, 20);
+
+ super.onSizeChanged(w, h, oldw, oldh);
+ }
+
+
+ @Override
+ protected void onElementDraw(Canvas canvas) {
+ boolean bIsMoving = virtualController.getControllerMode() == KeyBoardController.ControllerMode.MoveButtons;
+ boolean bIsResizing = virtualController.getControllerMode() == KeyBoardController.ControllerMode.ResizeButtons;
+ boolean bIsEnable = virtualController.getControllerMode() == KeyBoardController.ControllerMode.DisableEnableButtons;
+
+ if (bIsMoving || bIsResizing || bIsEnable) {
+ canvas.drawColor(getDefaultColor());
+ paint.setColor(Color.WHITE);
+ int nWidth = getWidth();
+ int nHeight = getHeight();
+
+ paint.setStyle(Paint.Style.FILL);
+ paint.setTextSize(Math.min(nWidth, nHeight) / 2);
+ canvas.drawText(strStickSide, nWidth / 2, nHeight / 2, paint);
+ }
+
+ paint.setStyle(Paint.Style.STROKE);
+ paint.setStrokeWidth(getDefaultStrokeWidth());
+
+ if (bIsFingerOnScreen) {
+ // set transparent background
+ canvas.drawColor(Color.TRANSPARENT);
+ //canvas.drawCircle(touchX, touchY, 50, paint);
+
+ // draw outer circle
+// if (!isPressed() || click_state == CLICK_STATE.SINGLE) {
+// //paint.setColor(getDefaultColor());
+// } else {
+// //paint.setColor(pressedColor);
+// }
+// //canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_complete, paint);
+//
+// //paint.setColor(getDefaultColor());
+// // draw dead zone
+// //canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_dead_zone, paint);
+ // draw stick depending on state
+ switch (stick_state) {
+ case NO_MOVEMENT: {
+ paint.setColor(Color.MAGENTA);
+ canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius_analog_stick, paint);
+ break;
+ }
+
+ case MOVED_IN_DEAD_ZONE:
+ case MOVED_ACTIVE: {
+ paint.setColor(pressedColor);
+ // draw start touch point circle
+ canvas.drawCircle(touchStartX, touchStartY,
+ radius_analog_stick / 2.0f, paint);
+ //paint.setColor(Color.RED);
+ // line from start point to current touch point
+// canvas.drawLine(touchStartX, touchStartY, position_stick_x, position_stick_y, paint);
+
+ //paint.setColor(pressedColor);
+ canvas.drawCircle(position_stick_x, position_stick_y, radius_analog_stick, paint);
+
+// float distance = (float) Math.sqrt(Math.pow(touchStartY - position_stick_y, 2) + Math.pow(touchStartX - position_stick_x, 2));
+
+// canvas.drawCircle(touchStartX, touchStartY, touchMaxDistance, paint);
+ break;
+ }
+ }
+ }
+ }
+
+
+ private void updatePosition(long eventTime) {
+ float complete = radius_complete - radius_analog_stick;
+
+ // calculate relative way
+ float correlated_y = (float) (Math.sin(Math.PI / 2 - movement_angle) * (movement_radius));
+ float correlated_x = (float) (Math.cos(Math.PI / 2 - movement_angle) * (movement_radius));
+
+ // update positions
+ position_stick_x = touchStartX - correlated_x;
+ position_stick_y = touchStartY - correlated_y;
+
+ // Stay active even if we're back in the deadzone because we know the user is actively
+ // giving analog stick input and we don't want to snap back into the deadzone.
+ // We also release the deadzone if the user keeps the stick pressed for a bit to allow
+ // them to make precise movements.
+ stick_state = (stick_state == keyAnalogStickFree.STICK_STATE.MOVED_ACTIVE ||
+ eventTime - timeLastClick > timeoutDeadzone ||
+ movement_radius > radius_dead_zone) ?
+ keyAnalogStickFree.STICK_STATE.MOVED_ACTIVE : keyAnalogStickFree.STICK_STATE.MOVED_IN_DEAD_ZONE;
+
+ // trigger move event if state active
+ if (stick_state == keyAnalogStickFree.STICK_STATE.MOVED_ACTIVE) {
+ notifyOnMovement(-correlated_x / complete, correlated_y / complete);
+ }
+ }
+
+ @Override
+ public boolean onElementTouchEvent(MotionEvent event) {
+ // save last click state
+ CLICK_STATE lastClickState = click_state;
+ relative_x = -(touchStartX - event.getX());
+ relative_y = -(touchStartY - event.getY());
+
+ // get radius and angel of movement from center
+ movement_radius = getMovementRadius(relative_x, relative_y);
+ movement_angle = getAngle(relative_x, relative_y);
+
+ // pass touch event to parent if out of outer circle
+// if (movement_radius > radius_complete && !isPressed())
+// return false;
+
+ // chop radius if out of outer circle or near the edge
+ if (movement_radius > (radius_complete - radius_analog_stick)) {
+ movement_radius = radius_complete - radius_analog_stick;
+ }
+ // handle event depending on action
+ switch (event.getActionMasked()) {
+ // down event (touch event)
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_POINTER_DOWN: {
+ if (!bIsFingerOnScreen) {
+ touchID = event.getPointerId(event.getActionIndex());
+ touchStartX = event.getX();
+ touchStartY = event.getY();
+ bIsFingerOnScreen = true;
+ }
+
+ if (touchID == event.getPointerId(event.getActionIndex())) {
+ touchX = event.getX();
+ touchY = event.getY();
+
+ // set to dead zoned, will be corrected in update position if necessary
+ stick_state = STICK_STATE.MOVED_IN_DEAD_ZONE;
+ // check for double click
+ if (lastClickState == CLICK_STATE.SINGLE &&
+ timeLastClick + timeoutDoubleClick > System.currentTimeMillis()) {
+ click_state = CLICK_STATE.DOUBLE;
+ notifyOnDoubleClick();
+ } else {
+ click_state = CLICK_STATE.SINGLE;
+ notifyOnClick();
+ }
+ // reset last click timestamp
+ timeLastClick = System.currentTimeMillis();
+ // set item pressed and update
+ setPressed(true);
+
+ updatePosition(event.getEventTime());
+ }
+ break;
+ }
+ case MotionEvent.ACTION_MOVE: {
+ for (int i = 0; i < event.getPointerCount(); i++) {
+ if (touchID == event.getPointerId(i)) {
+ touchX = event.getX();
+ touchY = event.getY();
+
+ updatePosition(event.getEventTime());
+ }
+ }
+ break;
+ }
+ // up event (revoke touch)
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_POINTER_UP: {
+ if (touchID == event.getPointerId(event.getActionIndex())) {
+ setPressed(false);
+ bIsFingerOnScreen = false;
+ }
+ break;
+ }
+ }
+
+ if (isPressed()) {
+ updatePosition(event.getEventTime());
+ // when is pressed calculate new positions (will trigger movement if necessary)
+ } else {
+ stick_state = STICK_STATE.NO_MOVEMENT;
+ notifyOnRevoke();
+
+ // not longer pressed reset analog stick
+ notifyOnMovement(0, 0);
+ }
+ // refresh view
+ invalidate();
+ // accept the touch event
+ return true;
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/keyBoardVirtualControllerElement.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/keyBoardVirtualControllerElement.java
new file mode 100755
index 0000000000..0019adf4ec
--- /dev/null
+++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/keyBoardVirtualControllerElement.java
@@ -0,0 +1,363 @@
+/**
+ * Created by Karim Mreisi.
+ */
+
+package com.limelight.binding.input.virtual_controller.keyboard;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.util.DisplayMetrics;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.FrameLayout;
+
+import com.limelight.Game;
+import com.limelight.binding.input.virtual_controller.VirtualController;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+public abstract class keyBoardVirtualControllerElement extends View {
+ protected static boolean _PRINT_DEBUG_INFORMATION = false;
+
+ public static final int EID_DPAD = 1;
+ public static final int EID_LT = 2;
+ public static final int EID_RT = 3;
+ public static final int EID_LB = 4;
+ public static final int EID_RB = 5;
+ public static final int EID_A = 6;
+ public static final int EID_B = 7;
+ public static final int EID_X = 8;
+ public static final int EID_Y = 9;
+ public static final int EID_BACK = 10;
+ public static final int EID_START = 11;
+ public static final int EID_LS = 12;
+ public static final int EID_RS = 13;
+ public static final int EID_LSB = 14;
+ public static final int EID_RSB = 15;
+
+ protected KeyBoardController virtualController;
+ protected final String elementId;
+
+ private final Paint paint = new Paint();
+
+ private int normalColor = 0xF0888888;
+ protected int pressedColor = 0xA3DCDCDE;
+ private int configMoveColor = 0xF0FF0000;
+ private int configResizeColor = 0xF0FF00FF;
+ private int configSelectedColor = 0xF000FF00;
+
+ private int configDisabledColor = 0xF0AAAAAA;
+
+ protected int startSize_x;
+ protected int startSize_y;
+
+ float position_pressed_x = 0;
+ float position_pressed_y = 0;
+
+ public boolean enabled = true;
+ private enum Mode {
+ Normal,
+ Resize,
+ Move
+ }
+
+ private Mode currentMode = Mode.Normal;
+
+ protected keyBoardVirtualControllerElement(KeyBoardController controller, Context context, String elementId) {
+ super(context);
+
+ this.virtualController = controller;
+ this.elementId = elementId;
+ }
+
+ protected void moveElement(int pressed_x, int pressed_y, int x, int y) {
+ int newPos_x = (int) getX() + x - pressed_x;
+ int newPos_y = (int) getY() + y - pressed_y;
+
+ FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams();
+
+ layoutParams.leftMargin = newPos_x > 0 ? newPos_x : 0;
+ layoutParams.topMargin = newPos_y > 0 ? newPos_y : 0;
+ layoutParams.rightMargin = 0;
+ layoutParams.bottomMargin = 0;
+
+ requestLayout();
+ }
+
+ protected void resizeElement(int pressed_x, int pressed_y, int width, int height) {
+ FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams();
+
+ int newHeight = height + (startSize_y - pressed_y);
+ int newWidth = width + (startSize_x - pressed_x);
+
+ layoutParams.height = newHeight > 20 ? newHeight : 20;
+ layoutParams.width = newWidth > 20 ? newWidth : 20;
+
+ requestLayout();
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ onElementDraw(canvas);
+
+ if (currentMode != Mode.Normal) {
+ paint.setColor(configSelectedColor);
+ paint.setStrokeWidth(getDefaultStrokeWidth());
+ paint.setStyle(Paint.Style.STROKE);
+
+ canvas.drawRect(paint.getStrokeWidth(), paint.getStrokeWidth(),
+ getWidth()-paint.getStrokeWidth(), getHeight()-paint.getStrokeWidth(),
+ paint);
+ }
+
+ super.onDraw(canvas);
+ }
+
+ /*
+ protected void actionShowNormalColorChooser() {
+ AmbilWarnaDialog colorDialog = new AmbilWarnaDialog(getContext(), normalColor, true, new AmbilWarnaDialog.OnAmbilWarnaListener() {
+ @Override
+ public void onCancel(AmbilWarnaDialog dialog)
+ {}
+
+ @Override
+ public void onOk(AmbilWarnaDialog dialog, int color) {
+ normalColor = color;
+ invalidate();
+ }
+ });
+ colorDialog.show();
+ }
+
+ protected void actionShowPressedColorChooser() {
+ AmbilWarnaDialog colorDialog = new AmbilWarnaDialog(getContext(), normalColor, true, new AmbilWarnaDialog.OnAmbilWarnaListener() {
+ @Override
+ public void onCancel(AmbilWarnaDialog dialog) {
+ }
+
+ @Override
+ public void onOk(AmbilWarnaDialog dialog, int color) {
+ pressedColor = color;
+ invalidate();
+ }
+ });
+ colorDialog.show();
+ }
+ */
+
+ protected void actionEnableMove() {
+ currentMode = Mode.Move;
+ }
+
+ protected void actionEnableResize() {
+ currentMode = Mode.Resize;
+ }
+
+ protected void actionCancel() {
+ currentMode = Mode.Normal;
+ invalidate();
+ }
+
+ protected int getDefaultColor() {
+ if (virtualController.getControllerMode() == KeyBoardController.ControllerMode.MoveButtons)
+ return configMoveColor;
+ else if (virtualController.getControllerMode() == KeyBoardController.ControllerMode.ResizeButtons)
+ return configResizeColor;
+ else if (virtualController.getControllerMode() == KeyBoardController.ControllerMode.DisableEnableButtons)
+ return enabled ? configSelectedColor: configDisabledColor;
+ else
+ return normalColor;
+ }
+
+ protected int getDefaultStrokeWidth() {
+ DisplayMetrics screen = getResources().getDisplayMetrics();
+ return (int)(screen.heightPixels*0.004f);
+ }
+
+ protected void showConfigurationDialog() {
+ AlertDialog.Builder alertBuilder = new AlertDialog.Builder(getContext());
+
+ alertBuilder.setTitle("Configuration");
+
+ CharSequence functions[] = new CharSequence[]{
+ "Move",
+ "Resize",
+ /*election
+ "Set n
+ Disable color sormal color",
+ "Set pressed color",
+ */
+ "Cancel"
+ };
+
+ alertBuilder.setItems(functions, new DialogInterface.OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ switch (which) {
+ case 0: { // move
+ actionEnableMove();
+ break;
+ }
+ case 1: { // resize
+ actionEnableResize();
+ break;
+ }
+ /*
+ case 2: { // set default color
+ actionShowNormalColorChooser();
+ break;
+ }
+ case 3: { // set pressed color
+ actionShowPressedColorChooser();
+ break;
+ }
+ */
+ default: { // cancel
+ actionCancel();
+ break;
+ }
+ }
+ }
+ });
+ AlertDialog alert = alertBuilder.create();
+ // show menu
+ alert.show();
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ // Ignore secondary touches on controls
+ //
+ // NB: We can get an additional pointer down if the user touches a non-StreamView area
+ // while also touching an OSC control, even if that pointer down doesn't correspond to
+ // an area of the OSC control.
+ if (event.getActionIndex() != 0) {
+ return true;
+ }
+
+ if (virtualController.getControllerMode() == KeyBoardController.ControllerMode.Active) {
+ return onElementTouchEvent(event);
+ }
+
+ switch (event.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN: {
+ position_pressed_x = event.getX();
+ position_pressed_y = event.getY();
+ startSize_x = getWidth();
+ startSize_y = getHeight();
+
+ if (virtualController.getControllerMode() == KeyBoardController.ControllerMode.MoveButtons)
+ actionEnableMove();
+ else if (virtualController.getControllerMode() == KeyBoardController.ControllerMode.ResizeButtons)
+ actionEnableResize();
+ else if (virtualController.getControllerMode() == KeyBoardController.ControllerMode.DisableEnableButtons)
+ actionDisableEnableButton();
+ return true;
+ }
+ case MotionEvent.ACTION_MOVE: {
+ switch (currentMode) {
+ case Move: {
+ moveElement(
+ (int) position_pressed_x,
+ (int) position_pressed_y,
+ (int) event.getX(),
+ (int) event.getY());
+ break;
+ }
+ case Resize: {
+ resizeElement(
+ (int) position_pressed_x,
+ (int) position_pressed_y,
+ (int) event.getX(),
+ (int) event.getY());
+ break;
+ }
+ case Normal: {
+ break;
+ }
+ }
+ return true;
+ }
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP: {
+ actionCancel();
+ return true;
+ }
+ default: {
+ }
+ }
+ return true;
+ }
+
+ abstract protected void onElementDraw(Canvas canvas);
+
+ abstract public boolean onElementTouchEvent(MotionEvent event);
+
+ protected static final void _DBG(String text) {
+ if (_PRINT_DEBUG_INFORMATION) {
+// System.out.println(text);
+ }
+ }
+
+ public void setColors(int normalColor, int pressedColor) {
+ this.normalColor = normalColor;
+ this.pressedColor = pressedColor;
+
+ invalidate();
+ }
+
+
+ public void setOpacity(int opacity) {
+ int hexOpacity = opacity * 255 / 100;
+ this.normalColor = (hexOpacity << 24) | (normalColor & 0x00FFFFFF);
+ this.pressedColor = (hexOpacity << 24) | (pressedColor & 0x00FFFFFF);
+
+ invalidate();
+ }
+
+ protected final float getPercent(float value, float percent) {
+ return value / 100 * percent;
+ }
+
+ protected final int getCorrectWidth() {
+ return getWidth() > getHeight() ? getHeight() : getWidth();
+ }
+
+
+ public JSONObject getConfiguration() throws JSONException {
+ JSONObject configuration = new JSONObject();
+
+ FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams();
+
+ configuration.put("LEFT", layoutParams.leftMargin);
+ configuration.put("TOP", layoutParams.topMargin);
+ configuration.put("WIDTH", layoutParams.width);
+ configuration.put("HEIGHT", layoutParams.height);
+ configuration.put("ENABLED", enabled);
+ return configuration;
+ }
+
+ public void loadConfiguration(JSONObject configuration) throws JSONException {
+ FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams();
+
+ layoutParams.leftMargin = configuration.getInt("LEFT");
+ layoutParams.topMargin = configuration.getInt("TOP");
+ layoutParams.width = configuration.getInt("WIDTH");
+ layoutParams.height = configuration.getInt("HEIGHT");
+
+ enabled = configuration.getBoolean("ENABLED");
+
+ setVisibility(enabled ? VISIBLE: GONE);
+ requestLayout();
+ }
+
+ protected void actionDisableEnableButton(){
+ enabled = !enabled;
+ }
+
+}
diff --git a/app/src/main/java/com/limelight/binding/video/CrashListener.java b/app/src/main/java/com/limelight/binding/video/CrashListener.java
old mode 100644
new mode 100755
index 5023da5e39..eba22ff26d
--- a/app/src/main/java/com/limelight/binding/video/CrashListener.java
+++ b/app/src/main/java/com/limelight/binding/video/CrashListener.java
@@ -1,5 +1,5 @@
-package com.limelight.binding.video;
-
-public interface CrashListener {
- void notifyCrash(Exception e);
-}
+package com.limelight.binding.video;
+
+public interface CrashListener {
+ void notifyCrash(Exception e);
+}
diff --git a/app/src/main/java/com/limelight/binding/video/MediaCodecDecoderRenderer.java b/app/src/main/java/com/limelight/binding/video/MediaCodecDecoderRenderer.java
old mode 100644
new mode 100755
index d24ec0dc72..c61a92341e
--- a/app/src/main/java/com/limelight/binding/video/MediaCodecDecoderRenderer.java
+++ b/app/src/main/java/com/limelight/binding/video/MediaCodecDecoderRenderer.java
@@ -1,1972 +1,2022 @@
-package com.limelight.binding.video;
-
-import java.io.IOException;
-import java.nio.ByteBuffer;
-import java.nio.ByteOrder;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.concurrent.LinkedBlockingQueue;
-import java.util.concurrent.atomic.AtomicInteger;
-
-import org.jcodec.codecs.h264.H264Utils;
-import org.jcodec.codecs.h264.io.model.SeqParameterSet;
-import org.jcodec.codecs.h264.io.model.VUIParameters;
-
-import com.limelight.BuildConfig;
-import com.limelight.LimeLog;
-import com.limelight.R;
-import com.limelight.nvstream.av.video.VideoDecoderRenderer;
-import com.limelight.nvstream.jni.MoonBridge;
-import com.limelight.preferences.PreferenceConfiguration;
-
-import android.annotation.TargetApi;
-import android.app.Activity;
-import android.content.Context;
-import android.media.MediaCodec;
-import android.media.MediaCodecInfo;
-import android.media.MediaFormat;
-import android.media.MediaCodec.BufferInfo;
-import android.media.MediaCodec.CodecException;
-import android.os.Build;
-import android.os.Handler;
-import android.os.HandlerThread;
-import android.os.Process;
-import android.os.SystemClock;
-import android.util.Range;
-import android.view.Choreographer;
-import android.view.SurfaceHolder;
-
-public class MediaCodecDecoderRenderer extends VideoDecoderRenderer implements Choreographer.FrameCallback {
-
- private static final boolean USE_FRAME_RENDER_TIME = false;
- private static final boolean FRAME_RENDER_TIME_ONLY = USE_FRAME_RENDER_TIME && false;
-
- // Used on versions < 5.0
- private ByteBuffer[] legacyInputBuffers;
-
- private MediaCodecInfo avcDecoder;
- private MediaCodecInfo hevcDecoder;
- private MediaCodecInfo av1Decoder;
-
- private final ArrayList vpsBuffers = new ArrayList<>();
- private final ArrayList spsBuffers = new ArrayList<>();
- private final ArrayList ppsBuffers = new ArrayList<>();
- private boolean submittedCsd;
- private byte[] currentHdrMetadata;
-
- private int nextInputBufferIndex = -1;
- private ByteBuffer nextInputBuffer;
-
- private Context context;
- private Activity activity;
- private MediaCodec videoDecoder;
- private Thread rendererThread;
- private boolean needsSpsBitstreamFixup, isExynos4;
- private boolean adaptivePlayback, directSubmit, fusedIdrFrame;
- private boolean constrainedHighProfile;
- private boolean refFrameInvalidationAvc, refFrameInvalidationHevc, refFrameInvalidationAv1;
- private byte optimalSlicesPerFrame;
- private boolean refFrameInvalidationActive;
- private int initialWidth, initialHeight;
- private int videoFormat;
- private SurfaceHolder renderTarget;
- private volatile boolean stopping;
- private CrashListener crashListener;
- private boolean reportedCrash;
- private int consecutiveCrashCount;
- private String glRenderer;
- private boolean foreground = true;
- private PerfOverlayListener perfListener;
-
- private static final int CR_MAX_TRIES = 10;
- private static final int CR_RECOVERY_TYPE_NONE = 0;
- private static final int CR_RECOVERY_TYPE_FLUSH = 1;
- private static final int CR_RECOVERY_TYPE_RESTART = 2;
- private static final int CR_RECOVERY_TYPE_RESET = 3;
- private AtomicInteger codecRecoveryType = new AtomicInteger(CR_RECOVERY_TYPE_NONE);
- private final Object codecRecoveryMonitor = new Object();
-
- // Each thread that touches the MediaCodec object or any associated buffers must have a flag
- // here and must call doCodecRecoveryIfRequired() on a regular basis.
- private static final int CR_FLAG_INPUT_THREAD = 0x1;
- private static final int CR_FLAG_RENDER_THREAD = 0x2;
- private static final int CR_FLAG_CHOREOGRAPHER = 0x4;
- private static final int CR_FLAG_ALL = CR_FLAG_INPUT_THREAD | CR_FLAG_RENDER_THREAD | CR_FLAG_CHOREOGRAPHER;
- private int codecRecoveryThreadQuiescedFlags = 0;
- private int codecRecoveryAttempts = 0;
-
- private MediaFormat inputFormat;
- private MediaFormat outputFormat;
- private MediaFormat configuredFormat;
-
- private boolean needsBaselineSpsHack;
- private SeqParameterSet savedSps;
-
- private RendererException initialException;
- private long initialExceptionTimestamp;
- private static final int EXCEPTION_REPORT_DELAY_MS = 3000;
-
- private VideoStats activeWindowVideoStats;
- private VideoStats lastWindowVideoStats;
- private VideoStats globalVideoStats;
-
- private long lastTimestampUs;
- private int lastFrameNumber;
- private int refreshRate;
- private PreferenceConfiguration prefs;
-
- private LinkedBlockingQueue outputBufferQueue = new LinkedBlockingQueue<>();
- private static final int OUTPUT_BUFFER_QUEUE_LIMIT = 2;
- private long lastRenderedFrameTimeNanos;
- private HandlerThread choreographerHandlerThread;
- private Handler choreographerHandler;
-
- private int numSpsIn;
- private int numPpsIn;
- private int numVpsIn;
- private int numFramesIn;
- private int numFramesOut;
-
- private MediaCodecInfo findAvcDecoder() {
- MediaCodecInfo decoder = MediaCodecHelper.findProbableSafeDecoder("video/avc", MediaCodecInfo.CodecProfileLevel.AVCProfileHigh);
- if (decoder == null) {
- decoder = MediaCodecHelper.findFirstDecoder("video/avc");
- }
- return decoder;
- }
-
- @TargetApi(Build.VERSION_CODES.LOLLIPOP)
- private boolean decoderCanMeetPerformancePoint(MediaCodecInfo.VideoCapabilities caps, PreferenceConfiguration prefs) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- MediaCodecInfo.VideoCapabilities.PerformancePoint targetPerfPoint = new MediaCodecInfo.VideoCapabilities.PerformancePoint(prefs.width, prefs.height, prefs.fps);
- List perfPoints = caps.getSupportedPerformancePoints();
- if (perfPoints != null) {
- for (MediaCodecInfo.VideoCapabilities.PerformancePoint perfPoint : perfPoints) {
- // If we find a performance point that covers our target, we're good to go
- if (perfPoint.covers(targetPerfPoint)) {
- return true;
- }
- }
-
- // We had performance point data but none met the specified streaming settings
- return false;
- }
-
- // Fall-through to try the Android M API if there's no performance point data
- }
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- try {
- // We'll ask the decoder what it can do for us at this resolution and see if our
- // requested frame rate falls below or inside the range of achievable frame rates.
- Range fpsRange = caps.getAchievableFrameRatesFor(prefs.width, prefs.height);
- if (fpsRange != null) {
- return prefs.fps <= fpsRange.getUpper();
- }
-
- // Fall-through to try the Android L API if there's no performance point data
- } catch (IllegalArgumentException e) {
- // Video size not supported at any frame rate
- return false;
- }
- }
-
- // As a last resort, we will use areSizeAndRateSupported() which is explicitly NOT a
- // performance metric, but it can work at least for the purpose of determining if
- // the codec is going to die when given a stream with the specified settings.
- return caps.areSizeAndRateSupported(prefs.width, prefs.height, prefs.fps);
- }
-
- private boolean decoderCanMeetPerformancePointWithHevcAndNotAvc(MediaCodecInfo hevcDecoderInfo, MediaCodecInfo avcDecoderInfo, PreferenceConfiguration prefs) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- MediaCodecInfo.VideoCapabilities avcCaps = avcDecoderInfo.getCapabilitiesForType("video/avc").getVideoCapabilities();
- MediaCodecInfo.VideoCapabilities hevcCaps = hevcDecoderInfo.getCapabilitiesForType("video/hevc").getVideoCapabilities();
-
- return !decoderCanMeetPerformancePoint(avcCaps, prefs) && decoderCanMeetPerformancePoint(hevcCaps, prefs);
- }
- else {
- // No performance data
- return false;
- }
- }
-
- private boolean decoderCanMeetPerformancePointWithAv1AndNotHevc(MediaCodecInfo av1DecoderInfo, MediaCodecInfo hevcDecoderInfo, PreferenceConfiguration prefs) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- MediaCodecInfo.VideoCapabilities av1Caps = av1DecoderInfo.getCapabilitiesForType("video/av01").getVideoCapabilities();
- MediaCodecInfo.VideoCapabilities hevcCaps = hevcDecoderInfo.getCapabilitiesForType("video/hevc").getVideoCapabilities();
-
- return !decoderCanMeetPerformancePoint(hevcCaps, prefs) && decoderCanMeetPerformancePoint(av1Caps, prefs);
- }
- else {
- // No performance data
- return false;
- }
- }
-
- private boolean decoderCanMeetPerformancePointWithAv1AndNotAvc(MediaCodecInfo av1DecoderInfo, MediaCodecInfo avcDecoderInfo, PreferenceConfiguration prefs) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- MediaCodecInfo.VideoCapabilities avcCaps = avcDecoderInfo.getCapabilitiesForType("video/avc").getVideoCapabilities();
- MediaCodecInfo.VideoCapabilities av1Caps = av1DecoderInfo.getCapabilitiesForType("video/av01").getVideoCapabilities();
-
- return !decoderCanMeetPerformancePoint(avcCaps, prefs) && decoderCanMeetPerformancePoint(av1Caps, prefs);
- }
- else {
- // No performance data
- return false;
- }
- }
-
- private MediaCodecInfo findHevcDecoder(PreferenceConfiguration prefs, boolean meteredNetwork, boolean requestedHdr) {
- // Don't return anything if H.264 is forced
- if (prefs.videoFormat == PreferenceConfiguration.FormatOption.FORCE_H264) {
- return null;
- }
-
- // We don't try the first HEVC decoder. We'd rather fall back to hardware accelerated AVC instead
- //
- // We need HEVC Main profile, so we could pass that constant to findProbableSafeDecoder, however
- // some decoders (at least Qualcomm's Snapdragon 805) don't properly report support
- // for even required levels of HEVC.
- MediaCodecInfo hevcDecoderInfo = MediaCodecHelper.findProbableSafeDecoder("video/hevc", -1);
- if (hevcDecoderInfo != null) {
- if (!MediaCodecHelper.decoderIsWhitelistedForHevc(hevcDecoderInfo)) {
- LimeLog.info("Found HEVC decoder, but it's not whitelisted - "+hevcDecoderInfo.getName());
-
- // Force HEVC enabled if the user asked for it
- if (prefs.videoFormat == PreferenceConfiguration.FormatOption.FORCE_HEVC) {
- LimeLog.info("Forcing HEVC enabled despite non-whitelisted decoder");
- }
- // HDR implies HEVC forced on, since HEVCMain10HDR10 is required for HDR.
- else if (requestedHdr) {
- LimeLog.info("Forcing HEVC enabled for HDR streaming");
- }
- // > 4K streaming also requires HEVC, so force it on there too.
- else if (prefs.width > 4096 || prefs.height > 4096) {
- LimeLog.info("Forcing HEVC enabled for over 4K streaming");
- }
- // Use HEVC if the H.264 decoder is unable to meet the performance point
- else if (avcDecoder != null && decoderCanMeetPerformancePointWithHevcAndNotAvc(hevcDecoderInfo, avcDecoder, prefs)) {
- LimeLog.info("Using non-whitelisted HEVC decoder to meet performance point");
- }
- else {
- return null;
- }
- }
- }
-
- return hevcDecoderInfo;
- }
-
- private MediaCodecInfo findAv1Decoder(PreferenceConfiguration prefs) {
- // For now, don't use AV1 unless explicitly requested
- if (prefs.videoFormat != PreferenceConfiguration.FormatOption.FORCE_AV1) {
- return null;
- }
-
- MediaCodecInfo decoderInfo = MediaCodecHelper.findProbableSafeDecoder("video/av01", -1);
- if (decoderInfo != null) {
- if (!MediaCodecHelper.isDecoderWhitelistedForAv1(decoderInfo)) {
- LimeLog.info("Found AV1 decoder, but it's not whitelisted - "+decoderInfo.getName());
-
- // Force HEVC enabled if the user asked for it
- if (prefs.videoFormat == PreferenceConfiguration.FormatOption.FORCE_AV1) {
- LimeLog.info("Forcing AV1 enabled despite non-whitelisted decoder");
- }
- // Use AV1 if the HEVC decoder is unable to meet the performance point
- else if (hevcDecoder != null && decoderCanMeetPerformancePointWithAv1AndNotHevc(decoderInfo, hevcDecoder, prefs)) {
- LimeLog.info("Using non-whitelisted AV1 decoder to meet performance point");
- }
- // Use AV1 if the H.264 decoder is unable to meet the performance point and we have no HEVC decoder
- else if (hevcDecoder == null && decoderCanMeetPerformancePointWithAv1AndNotAvc(decoderInfo, avcDecoder, prefs)) {
- LimeLog.info("Using non-whitelisted AV1 decoder to meet performance point");
- }
- else {
- return null;
- }
- }
- }
-
- return decoderInfo;
- }
-
- public void setRenderTarget(SurfaceHolder renderTarget) {
- this.renderTarget = renderTarget;
- }
-
- public MediaCodecDecoderRenderer(Activity activity, PreferenceConfiguration prefs,
- CrashListener crashListener, int consecutiveCrashCount,
- boolean meteredData, boolean requestedHdr,
- String glRenderer, PerfOverlayListener perfListener) {
- //dumpDecoders();
-
- this.context = activity;
- this.activity = activity;
- this.prefs = prefs;
- this.crashListener = crashListener;
- this.consecutiveCrashCount = consecutiveCrashCount;
- this.glRenderer = glRenderer;
- this.perfListener = perfListener;
-
- this.activeWindowVideoStats = new VideoStats();
- this.lastWindowVideoStats = new VideoStats();
- this.globalVideoStats = new VideoStats();
-
- avcDecoder = findAvcDecoder();
- if (avcDecoder != null) {
- LimeLog.info("Selected AVC decoder: "+avcDecoder.getName());
- }
- else {
- LimeLog.warning("No AVC decoder found");
- }
-
- hevcDecoder = findHevcDecoder(prefs, meteredData, requestedHdr);
- if (hevcDecoder != null) {
- LimeLog.info("Selected HEVC decoder: "+hevcDecoder.getName());
- }
- else {
- LimeLog.info("No HEVC decoder found");
- }
-
- av1Decoder = findAv1Decoder(prefs);
- if (av1Decoder != null) {
- LimeLog.info("Selected AV1 decoder: "+av1Decoder.getName());
- }
- else {
- LimeLog.info("No AV1 decoder found");
- }
-
- // Set attributes that are queried in getCapabilities(). This must be done here
- // because getCapabilities() may be called before setup() in current versions of the common
- // library. The limitation of this is that we don't know whether we're using HEVC or AVC.
- int avcOptimalSlicesPerFrame = 0;
- int hevcOptimalSlicesPerFrame = 0;
- if (avcDecoder != null) {
- directSubmit = MediaCodecHelper.decoderCanDirectSubmit(avcDecoder.getName());
- refFrameInvalidationAvc = MediaCodecHelper.decoderSupportsRefFrameInvalidationAvc(avcDecoder.getName(), prefs.height);
- avcOptimalSlicesPerFrame = MediaCodecHelper.getDecoderOptimalSlicesPerFrame(avcDecoder.getName());
-
- if (directSubmit) {
- LimeLog.info("Decoder "+avcDecoder.getName()+" will use direct submit");
- }
- if (refFrameInvalidationAvc) {
- LimeLog.info("Decoder "+avcDecoder.getName()+" will use reference frame invalidation for AVC");
- }
- LimeLog.info("Decoder "+avcDecoder.getName()+" wants "+avcOptimalSlicesPerFrame+" slices per frame");
- }
-
- if (hevcDecoder != null) {
- refFrameInvalidationHevc = MediaCodecHelper.decoderSupportsRefFrameInvalidationHevc(hevcDecoder);
- hevcOptimalSlicesPerFrame = MediaCodecHelper.getDecoderOptimalSlicesPerFrame(hevcDecoder.getName());
-
- if (refFrameInvalidationHevc) {
- LimeLog.info("Decoder "+hevcDecoder.getName()+" will use reference frame invalidation for HEVC");
- }
-
- LimeLog.info("Decoder "+hevcDecoder.getName()+" wants "+hevcOptimalSlicesPerFrame+" slices per frame");
- }
-
- if (av1Decoder != null) {
- refFrameInvalidationAv1 = MediaCodecHelper.decoderSupportsRefFrameInvalidationAv1(av1Decoder);
-
- if (refFrameInvalidationAv1) {
- LimeLog.info("Decoder "+av1Decoder.getName()+" will use reference frame invalidation for AV1");
- }
- }
-
- // Use the larger of the two slices per frame preferences
- optimalSlicesPerFrame = (byte)Math.max(avcOptimalSlicesPerFrame, hevcOptimalSlicesPerFrame);
- LimeLog.info("Requesting "+optimalSlicesPerFrame+" slices per frame");
-
- if (consecutiveCrashCount % 2 == 1) {
- refFrameInvalidationAvc = refFrameInvalidationHevc = false;
- LimeLog.warning("Disabling RFI due to previous crash");
- }
- }
-
- public boolean isHevcSupported() {
- return hevcDecoder != null;
- }
-
- public boolean isAvcSupported() {
- return avcDecoder != null;
- }
-
- public boolean isHevcMain10Hdr10Supported() {
- if (hevcDecoder == null) {
- return false;
- }
-
- for (MediaCodecInfo.CodecProfileLevel profileLevel : hevcDecoder.getCapabilitiesForType("video/hevc").profileLevels) {
- if (profileLevel.profile == MediaCodecInfo.CodecProfileLevel.HEVCProfileMain10HDR10) {
- LimeLog.info("HEVC decoder "+hevcDecoder.getName()+" supports HEVC Main10 HDR10");
- return true;
- }
- }
-
- return false;
- }
-
- public boolean isAv1Supported() {
- return av1Decoder != null;
- }
-
- public boolean isAv1Main10Supported() {
- if (av1Decoder == null) {
- return false;
- }
-
- for (MediaCodecInfo.CodecProfileLevel profileLevel : av1Decoder.getCapabilitiesForType("video/av01").profileLevels) {
- if (profileLevel.profile == MediaCodecInfo.CodecProfileLevel.AV1ProfileMain10HDR10) {
- LimeLog.info("AV1 decoder "+av1Decoder.getName()+" supports AV1 Main 10 HDR10");
- return true;
- }
- }
-
- return false;
- }
-
- public int getPreferredColorSpace() {
- // Default to Rec 709 which is probably better supported on modern devices.
- //
- // We are sticking to Rec 601 on older devices unless the device has an HEVC decoder
- // to avoid possible regressions (and they are < 5% of installed devices). If we have
- // an HEVC decoder, we will use Rec 709 (even for H.264) since we can't choose a
- // colorspace by codec (and it's probably safe to say a SoC with HEVC decoding is
- // plenty modern enough to handle H.264 VUI colorspace info).
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O || hevcDecoder != null || av1Decoder != null) {
- return MoonBridge.COLORSPACE_REC_709;
- }
- else {
- return MoonBridge.COLORSPACE_REC_601;
- }
- }
-
- public int getPreferredColorRange() {
- if (prefs.fullRange) {
- return MoonBridge.COLOR_RANGE_FULL;
- }
- else {
- return MoonBridge.COLOR_RANGE_LIMITED;
- }
- }
-
- public void notifyVideoForeground() {
- foreground = true;
- }
-
- public void notifyVideoBackground() {
- foreground = false;
- }
-
- public int getActiveVideoFormat() {
- return this.videoFormat;
- }
-
- private MediaFormat createBaseMediaFormat(String mimeType) {
- MediaFormat videoFormat = MediaFormat.createVideoFormat(mimeType, initialWidth, initialHeight);
-
- // Avoid setting KEY_FRAME_RATE on Lollipop and earlier to reduce compatibility risk
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- videoFormat.setInteger(MediaFormat.KEY_FRAME_RATE, refreshRate);
- }
-
- // Populate keys for adaptive playback
- if (adaptivePlayback) {
- videoFormat.setInteger(MediaFormat.KEY_MAX_WIDTH, initialWidth);
- videoFormat.setInteger(MediaFormat.KEY_MAX_HEIGHT, initialHeight);
- }
-
- // Android 7.0 adds color options to the MediaFormat
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- videoFormat.setInteger(MediaFormat.KEY_COLOR_RANGE,
- getPreferredColorRange() == MoonBridge.COLOR_RANGE_FULL ?
- MediaFormat.COLOR_RANGE_FULL : MediaFormat.COLOR_RANGE_LIMITED);
-
- // If the stream is HDR-capable, the decoder will detect transitions in color standards
- // rather than us hardcoding them into the MediaFormat.
- if ((getActiveVideoFormat() & MoonBridge.VIDEO_FORMAT_MASK_10BIT) == 0) {
- // Set color format keys when not in HDR mode, since we know they won't change
- videoFormat.setInteger(MediaFormat.KEY_COLOR_TRANSFER, MediaFormat.COLOR_TRANSFER_SDR_VIDEO);
- switch (getPreferredColorSpace()) {
- case MoonBridge.COLORSPACE_REC_601:
- videoFormat.setInteger(MediaFormat.KEY_COLOR_STANDARD, MediaFormat.COLOR_STANDARD_BT601_NTSC);
- break;
- case MoonBridge.COLORSPACE_REC_709:
- videoFormat.setInteger(MediaFormat.KEY_COLOR_STANDARD, MediaFormat.COLOR_STANDARD_BT709);
- break;
- case MoonBridge.COLORSPACE_REC_2020:
- videoFormat.setInteger(MediaFormat.KEY_COLOR_STANDARD, MediaFormat.COLOR_STANDARD_BT2020);
- break;
- }
- }
- }
-
- return videoFormat;
- }
-
- private void configureAndStartDecoder(MediaFormat format) {
- // Set HDR metadata if present
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- if (currentHdrMetadata != null) {
- ByteBuffer hdrStaticInfo = ByteBuffer.allocate(25).order(ByteOrder.LITTLE_ENDIAN);
- ByteBuffer hdrMetadata = ByteBuffer.wrap(currentHdrMetadata).order(ByteOrder.LITTLE_ENDIAN);
-
- // Create a HDMI Dynamic Range and Mastering InfoFrame as defined by CTA-861.3
- hdrStaticInfo.put((byte) 0); // Metadata type
- hdrStaticInfo.putShort(hdrMetadata.getShort()); // RX
- hdrStaticInfo.putShort(hdrMetadata.getShort()); // RY
- hdrStaticInfo.putShort(hdrMetadata.getShort()); // GX
- hdrStaticInfo.putShort(hdrMetadata.getShort()); // GY
- hdrStaticInfo.putShort(hdrMetadata.getShort()); // BX
- hdrStaticInfo.putShort(hdrMetadata.getShort()); // BY
- hdrStaticInfo.putShort(hdrMetadata.getShort()); // White X
- hdrStaticInfo.putShort(hdrMetadata.getShort()); // White Y
- hdrStaticInfo.putShort(hdrMetadata.getShort()); // Max mastering luminance
- hdrStaticInfo.putShort(hdrMetadata.getShort()); // Min mastering luminance
- hdrStaticInfo.putShort(hdrMetadata.getShort()); // Max content luminance
- hdrStaticInfo.putShort(hdrMetadata.getShort()); // Max frame average luminance
-
- hdrStaticInfo.rewind();
- format.setByteBuffer(MediaFormat.KEY_HDR_STATIC_INFO, hdrStaticInfo);
- }
- else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- format.removeKey(MediaFormat.KEY_HDR_STATIC_INFO);
- }
- }
-
- LimeLog.info("Configuring with format: "+format);
-
- videoDecoder.configure(format, renderTarget.getSurface(), null, 0);
-
- configuredFormat = format;
-
- // After reconfiguration, we must resubmit CSD buffers
- submittedCsd = false;
- vpsBuffers.clear();
- spsBuffers.clear();
- ppsBuffers.clear();
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- // This will contain the actual accepted input format attributes
- inputFormat = videoDecoder.getInputFormat();
- LimeLog.info("Input format: "+inputFormat);
- }
-
- videoDecoder.setVideoScalingMode(MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT);
-
- // Start the decoder
- videoDecoder.start();
-
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
- legacyInputBuffers = videoDecoder.getInputBuffers();
- }
- }
-
- private boolean tryConfigureDecoder(MediaCodecInfo selectedDecoderInfo, MediaFormat format, boolean throwOnCodecError) {
- boolean configured = false;
- try {
- videoDecoder = MediaCodec.createByCodecName(selectedDecoderInfo.getName());
- configureAndStartDecoder(format);
- LimeLog.info("Using codec " + selectedDecoderInfo.getName() + " for hardware decoding " + format.getString(MediaFormat.KEY_MIME));
- configured = true;
- } catch (IllegalArgumentException e) {
- e.printStackTrace();
- if (throwOnCodecError) {
- throw e;
- }
- } catch (IllegalStateException e) {
- e.printStackTrace();
- if (throwOnCodecError) {
- throw e;
- }
- } catch (IOException e) {
- e.printStackTrace();
- if (throwOnCodecError) {
- throw new RuntimeException(e);
- }
- } finally {
- if (!configured && videoDecoder != null) {
- videoDecoder.release();
- videoDecoder = null;
- }
- }
- return configured;
- }
-
- public int initializeDecoder(boolean throwOnCodecError) {
- String mimeType;
- MediaCodecInfo selectedDecoderInfo;
-
- if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H264) != 0) {
- mimeType = "video/avc";
- selectedDecoderInfo = avcDecoder;
-
- if (avcDecoder == null) {
- LimeLog.severe("No available AVC decoder!");
- return -1;
- }
-
- if (initialWidth > 4096 || initialHeight > 4096) {
- LimeLog.severe("> 4K streaming only supported on HEVC");
- return -1;
- }
-
- // These fixups only apply to H264 decoders
- needsSpsBitstreamFixup = MediaCodecHelper.decoderNeedsSpsBitstreamRestrictions(selectedDecoderInfo.getName());
- needsBaselineSpsHack = MediaCodecHelper.decoderNeedsBaselineSpsHack(selectedDecoderInfo.getName());
- constrainedHighProfile = MediaCodecHelper.decoderNeedsConstrainedHighProfile(selectedDecoderInfo.getName());
- isExynos4 = MediaCodecHelper.isExynos4Device();
- if (needsSpsBitstreamFixup) {
- LimeLog.info("Decoder "+selectedDecoderInfo.getName()+" needs SPS bitstream restrictions fixup");
- }
- if (needsBaselineSpsHack) {
- LimeLog.info("Decoder "+selectedDecoderInfo.getName()+" needs baseline SPS hack");
- }
- if (constrainedHighProfile) {
- LimeLog.info("Decoder "+selectedDecoderInfo.getName()+" needs constrained high profile");
- }
- if (isExynos4) {
- LimeLog.info("Decoder "+selectedDecoderInfo.getName()+" is on Exynos 4");
- }
-
- refFrameInvalidationActive = refFrameInvalidationAvc;
- }
- else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H265) != 0) {
- mimeType = "video/hevc";
- selectedDecoderInfo = hevcDecoder;
-
- if (hevcDecoder == null) {
- LimeLog.severe("No available HEVC decoder!");
- return -2;
- }
-
- refFrameInvalidationActive = refFrameInvalidationHevc;
- }
- else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_AV1) != 0) {
- mimeType = "video/av01";
- selectedDecoderInfo = av1Decoder;
-
- if (av1Decoder == null) {
- LimeLog.severe("No available AV1 decoder!");
- return -2;
- }
-
- refFrameInvalidationActive = refFrameInvalidationAv1;
- }
- else {
- // Unknown format
- LimeLog.severe("Unknown format");
- return -3;
- }
-
- adaptivePlayback = MediaCodecHelper.decoderSupportsAdaptivePlayback(selectedDecoderInfo, mimeType);
- fusedIdrFrame = MediaCodecHelper.decoderSupportsFusedIdrFrame(selectedDecoderInfo, mimeType);
-
- for (int tryNumber = 0;; tryNumber++) {
- LimeLog.info("Decoder configuration try: "+tryNumber);
-
- MediaFormat mediaFormat = createBaseMediaFormat(mimeType);
-
- // This will try low latency options until we find one that works (or we give up).
- boolean newFormat = MediaCodecHelper.setDecoderLowLatencyOptions(mediaFormat, selectedDecoderInfo, tryNumber);
-
- // Throw the underlying codec exception on the last attempt if the caller requested it
- if (tryConfigureDecoder(selectedDecoderInfo, mediaFormat, !newFormat && throwOnCodecError)) {
- // Success!
- break;
- }
-
- if (!newFormat) {
- // We couldn't even configure a decoder without any low latency options
- return -5;
- }
- }
-
- if (USE_FRAME_RENDER_TIME && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- videoDecoder.setOnFrameRenderedListener(new MediaCodec.OnFrameRenderedListener() {
- @Override
- public void onFrameRendered(MediaCodec mediaCodec, long presentationTimeUs, long renderTimeNanos) {
- long delta = (renderTimeNanos / 1000000L) - (presentationTimeUs / 1000);
- if (delta >= 0 && delta < 1000) {
- if (USE_FRAME_RENDER_TIME) {
- activeWindowVideoStats.totalTimeMs += delta;
- }
- }
- }
- }, null);
- }
-
- return 0;
- }
-
- @Override
- public int setup(int format, int width, int height, int redrawRate) {
- this.initialWidth = width;
- this.initialHeight = height;
- this.videoFormat = format;
- this.refreshRate = redrawRate;
-
- return initializeDecoder(false);
- }
-
- // All threads that interact with the MediaCodec instance must call this function regularly!
- private boolean doCodecRecoveryIfRequired(int quiescenceFlag) {
- // NB: We cannot check 'stopping' here because we could end up bailing in a partially
- // quiesced state that will cause the quiesced threads to never wake up.
- if (codecRecoveryType.get() == CR_RECOVERY_TYPE_NONE) {
- // Common case
- return false;
- }
-
- // We need some sort of recovery, so quiesce all threads before starting that
- synchronized (codecRecoveryMonitor) {
- if (choreographerHandlerThread == null) {
- // If we have no choreographer thread, we can just mark that as quiesced right now.
- codecRecoveryThreadQuiescedFlags |= CR_FLAG_CHOREOGRAPHER;
- }
-
- codecRecoveryThreadQuiescedFlags |= quiescenceFlag;
-
- // This is the final thread to quiesce, so let's perform the codec recovery now.
- if (codecRecoveryThreadQuiescedFlags == CR_FLAG_ALL) {
- // Input and output buffers are invalidated by stop() and reset().
- nextInputBuffer = null;
- nextInputBufferIndex = -1;
- outputBufferQueue.clear();
-
- // If we just need a flush, do so now with all threads quiesced.
- if (codecRecoveryType.get() == CR_RECOVERY_TYPE_FLUSH) {
- LimeLog.warning("Flushing decoder");
- try {
- videoDecoder.flush();
- codecRecoveryType.set(CR_RECOVERY_TYPE_NONE);
- } catch (IllegalStateException e) {
- e.printStackTrace();
-
- // Something went wrong during the restart, let's use a bigger hammer
- // and try a reset instead.
- codecRecoveryType.set(CR_RECOVERY_TYPE_RESTART);
- }
- }
-
- // We don't count flushes as codec recovery attempts
- if (codecRecoveryType.get() != CR_RECOVERY_TYPE_NONE) {
- codecRecoveryAttempts++;
- LimeLog.info("Codec recovery attempt: "+codecRecoveryAttempts);
- }
-
- // For "recoverable" exceptions, we can just stop, reconfigure, and restart.
- if (codecRecoveryType.get() == CR_RECOVERY_TYPE_RESTART) {
- LimeLog.warning("Trying to restart decoder after CodecException");
- try {
- videoDecoder.stop();
- configureAndStartDecoder(configuredFormat);
- codecRecoveryType.set(CR_RECOVERY_TYPE_NONE);
- } catch (IllegalArgumentException e) {
- e.printStackTrace();
-
- // Our Surface is probably invalid, so just stop
- stopping = true;
- codecRecoveryType.set(CR_RECOVERY_TYPE_NONE);
- } catch (IllegalStateException e) {
- e.printStackTrace();
-
- // Something went wrong during the restart, let's use a bigger hammer
- // and try a reset instead.
- codecRecoveryType.set(CR_RECOVERY_TYPE_RESET);
- }
- }
-
- // For "non-recoverable" exceptions on L+, we can call reset() to recover
- // without having to recreate the entire decoder again.
- if (codecRecoveryType.get() == CR_RECOVERY_TYPE_RESET && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- LimeLog.warning("Trying to reset decoder after CodecException");
- try {
- videoDecoder.reset();
- configureAndStartDecoder(configuredFormat);
- codecRecoveryType.set(CR_RECOVERY_TYPE_NONE);
- } catch (IllegalArgumentException e) {
- e.printStackTrace();
-
- // Our Surface is probably invalid, so just stop
- stopping = true;
- codecRecoveryType.set(CR_RECOVERY_TYPE_NONE);
- } catch (IllegalStateException e) {
- e.printStackTrace();
-
- // Something went wrong during the reset, we'll have to resort to
- // releasing and recreating the decoder now.
- }
- }
-
- // If we _still_ haven't managed to recover, go for the nuclear option and just
- // throw away the old decoder and reinitialize a new one from scratch.
- if (codecRecoveryType.get() == CR_RECOVERY_TYPE_RESET) {
- LimeLog.warning("Trying to recreate decoder after CodecException");
- videoDecoder.release();
-
- try {
- int err = initializeDecoder(true);
- if (err != 0) {
- throw new IllegalStateException("Decoder reset failed: " + err);
- }
- codecRecoveryType.set(CR_RECOVERY_TYPE_NONE);
- } catch (IllegalArgumentException e) {
- e.printStackTrace();
-
- // Our Surface is probably invalid, so just stop
- stopping = true;
- codecRecoveryType.set(CR_RECOVERY_TYPE_NONE);
- } catch (IllegalStateException e) {
- // If we failed to recover after all of these attempts, just crash
- if (!reportedCrash) {
- reportedCrash = true;
- crashListener.notifyCrash(e);
- }
- throw new RendererException(this, e);
- }
- }
-
- // Wake all quiesced threads and allow them to begin work again
- codecRecoveryThreadQuiescedFlags = 0;
- codecRecoveryMonitor.notifyAll();
- }
- else {
- // If we haven't quiesced all threads yet, wait to be signalled after recovery.
- // The final thread to be quiesced will handle the codec recovery.
- while (codecRecoveryType.get() != CR_RECOVERY_TYPE_NONE) {
- try {
- LimeLog.info("Waiting to quiesce decoder threads: "+codecRecoveryThreadQuiescedFlags);
- codecRecoveryMonitor.wait(1000);
- } catch (InterruptedException e) {
- e.printStackTrace();
-
- // InterruptedException clears the thread's interrupt status. Since we can't
- // handle that here, we will re-interrupt the thread to set the interrupt
- // status back to true.
- Thread.currentThread().interrupt();
-
- break;
- }
- }
- }
- }
-
- return true;
- }
-
- // Returns true if the exception is transient
- private boolean handleDecoderException(IllegalStateException e) {
- // Eat decoder exceptions if we're in the process of stopping
- if (stopping) {
- return false;
- }
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && e instanceof CodecException) {
- CodecException codecExc = (CodecException) e;
-
- if (codecExc.isTransient()) {
- // We'll let transient exceptions go
- LimeLog.warning(codecExc.getDiagnosticInfo());
- return true;
- }
-
- LimeLog.severe(codecExc.getDiagnosticInfo());
-
- // We can attempt a recovery or reset at this stage to try to start decoding again
- if (codecRecoveryAttempts < CR_MAX_TRIES) {
- // If the exception is non-recoverable or we already require a reset, perform a reset.
- // If we have no prior unrecoverable failure, we will try a restart instead.
- if (codecExc.isRecoverable()) {
- if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_NONE, CR_RECOVERY_TYPE_RESTART)) {
- LimeLog.info("Decoder requires restart for recoverable CodecException");
- e.printStackTrace();
- }
- else if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_FLUSH, CR_RECOVERY_TYPE_RESTART)) {
- LimeLog.info("Decoder flush promoted to restart for recoverable CodecException");
- e.printStackTrace();
- }
- else if (codecRecoveryType.get() != CR_RECOVERY_TYPE_RESET && codecRecoveryType.get() != CR_RECOVERY_TYPE_RESTART) {
- throw new IllegalStateException("Unexpected codec recovery type: " + codecRecoveryType.get());
- }
- }
- else if (!codecExc.isRecoverable()) {
- if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_NONE, CR_RECOVERY_TYPE_RESET)) {
- LimeLog.info("Decoder requires reset for non-recoverable CodecException");
- e.printStackTrace();
- }
- else if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_FLUSH, CR_RECOVERY_TYPE_RESET)) {
- LimeLog.info("Decoder flush promoted to reset for non-recoverable CodecException");
- e.printStackTrace();
- }
- else if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_RESTART, CR_RECOVERY_TYPE_RESET)) {
- LimeLog.info("Decoder restart promoted to reset for non-recoverable CodecException");
- e.printStackTrace();
- }
- else if (codecRecoveryType.get() != CR_RECOVERY_TYPE_RESET) {
- throw new IllegalStateException("Unexpected codec recovery type: " + codecRecoveryType.get());
- }
- }
-
- // The recovery will take place when all threads reach doCodecRecoveryIfRequired().
- return false;
- }
- }
- else {
- // IllegalStateException was primarily used prior to the introduction of CodecException.
- // Recovery from this requires a full decoder reset.
- //
- // NB: CodecException is an IllegalStateException, so we must check for it first.
- if (codecRecoveryAttempts < CR_MAX_TRIES) {
- if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_NONE, CR_RECOVERY_TYPE_RESET)) {
- LimeLog.info("Decoder requires reset for IllegalStateException");
- e.printStackTrace();
- }
- else if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_FLUSH, CR_RECOVERY_TYPE_RESET)) {
- LimeLog.info("Decoder flush promoted to reset for IllegalStateException");
- e.printStackTrace();
- }
- else if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_RESTART, CR_RECOVERY_TYPE_RESET)) {
- LimeLog.info("Decoder restart promoted to reset for IllegalStateException");
- e.printStackTrace();
- }
- else if (codecRecoveryType.get() != CR_RECOVERY_TYPE_RESET) {
- throw new IllegalStateException("Unexpected codec recovery type: " + codecRecoveryType.get());
- }
-
- return false;
- }
- }
-
- // Only throw if we're not in the middle of codec recovery
- if (codecRecoveryType.get() == CR_RECOVERY_TYPE_NONE) {
- //
- // There seems to be a race condition with decoder/surface teardown causing some
- // decoders to to throw IllegalStateExceptions even before 'stopping' is set.
- // To workaround this while allowing real exceptions to propagate, we will eat the
- // first exception. If we are still receiving exceptions 3 seconds later, we will
- // throw the original exception again.
- //
- if (initialException != null) {
- // This isn't the first time we've had an exception processing video
- if (SystemClock.uptimeMillis() - initialExceptionTimestamp >= EXCEPTION_REPORT_DELAY_MS) {
- // It's been over 3 seconds and we're still getting exceptions. Throw the original now.
- if (!reportedCrash) {
- reportedCrash = true;
- crashListener.notifyCrash(initialException);
- }
- throw initialException;
- }
- }
- else {
- // This is the first exception we've hit
- initialException = new RendererException(this, e);
- initialExceptionTimestamp = SystemClock.uptimeMillis();
- }
- }
-
- // Not transient
- return false;
- }
-
- @Override
- public void doFrame(long frameTimeNanos) {
- // Do nothing if we're stopping
- if (stopping) {
- return;
- }
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- frameTimeNanos -= activity.getWindowManager().getDefaultDisplay().getAppVsyncOffsetNanos();
- }
-
- // Don't render unless a new frame is due. This prevents microstutter when streaming
- // at a frame rate that doesn't match the display (such as 60 FPS on 120 Hz).
- long actualFrameTimeDeltaNs = frameTimeNanos - lastRenderedFrameTimeNanos;
- long expectedFrameTimeDeltaNs = 800000000 / refreshRate; // within 80% of the next frame
- if (actualFrameTimeDeltaNs >= expectedFrameTimeDeltaNs) {
- // Render up to one frame when in frame pacing mode.
- //
- // NB: Since the queue limit is 2, we won't starve the decoder of output buffers
- // by holding onto them for too long. This also ensures we will have that 1 extra
- // frame of buffer to smooth over network/rendering jitter.
- Integer nextOutputBuffer = outputBufferQueue.poll();
- if (nextOutputBuffer != null) {
- try {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- videoDecoder.releaseOutputBuffer(nextOutputBuffer, frameTimeNanos);
- }
- else {
- videoDecoder.releaseOutputBuffer(nextOutputBuffer, true);
- }
-
- lastRenderedFrameTimeNanos = frameTimeNanos;
- activeWindowVideoStats.totalFramesRendered++;
- } catch (IllegalStateException ignored) {
- try {
- // Try to avoid leaking the output buffer by releasing it without rendering
- videoDecoder.releaseOutputBuffer(nextOutputBuffer, false);
- } catch (IllegalStateException e) {
- // This will leak nextOutputBuffer, but there's really nothing else we can do
- e.printStackTrace();
- handleDecoderException(e);
- }
- }
- }
- }
-
- // Attempt codec recovery even if we have nothing to render right now. Recovery can still
- // be required even if the codec died before giving any output.
- doCodecRecoveryIfRequired(CR_FLAG_CHOREOGRAPHER);
-
- // Request another callback for next frame
- Choreographer.getInstance().postFrameCallback(this);
- }
-
- private void startChoreographerThread() {
- if (prefs.framePacing != PreferenceConfiguration.FRAME_PACING_BALANCED) {
- // Not using Choreographer in this pacing mode
- return;
- }
-
- // We use a separate thread to avoid any main thread delays from delaying rendering
- choreographerHandlerThread = new HandlerThread("Video - Choreographer", Process.THREAD_PRIORITY_DEFAULT + Process.THREAD_PRIORITY_MORE_FAVORABLE);
- choreographerHandlerThread.start();
-
- // Start the frame callbacks
- choreographerHandler = new Handler(choreographerHandlerThread.getLooper());
- choreographerHandler.post(new Runnable() {
- @Override
- public void run() {
- Choreographer.getInstance().postFrameCallback(MediaCodecDecoderRenderer.this);
- }
- });
- }
-
- private void startRendererThread()
- {
- rendererThread = new Thread() {
- @Override
- public void run() {
- BufferInfo info = new BufferInfo();
- while (!stopping) {
- try {
- // Try to output a frame
- int outIndex = videoDecoder.dequeueOutputBuffer(info, 50000);
- if (outIndex >= 0) {
- long presentationTimeUs = info.presentationTimeUs;
- int lastIndex = outIndex;
-
- numFramesOut++;
-
- // Render the latest frame now if frame pacing isn't in balanced mode
- if (prefs.framePacing != PreferenceConfiguration.FRAME_PACING_BALANCED) {
- // Get the last output buffer in the queue
- while ((outIndex = videoDecoder.dequeueOutputBuffer(info, 0)) >= 0) {
- videoDecoder.releaseOutputBuffer(lastIndex, false);
-
- numFramesOut++;
-
- lastIndex = outIndex;
- presentationTimeUs = info.presentationTimeUs;
- }
-
- if (prefs.framePacing == PreferenceConfiguration.FRAME_PACING_MAX_SMOOTHNESS ||
- prefs.framePacing == PreferenceConfiguration.FRAME_PACING_CAP_FPS) {
- // In max smoothness or cap FPS mode, we want to never drop frames
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- // Use a PTS that will cause this frame to never be dropped
- videoDecoder.releaseOutputBuffer(lastIndex, 0);
- }
- else {
- videoDecoder.releaseOutputBuffer(lastIndex, true);
- }
- }
- else {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- // Use a PTS that will cause this frame to be dropped if another comes in within
- // the same V-sync period
- videoDecoder.releaseOutputBuffer(lastIndex, System.nanoTime());
- }
- else {
- videoDecoder.releaseOutputBuffer(lastIndex, true);
- }
- }
-
- activeWindowVideoStats.totalFramesRendered++;
- }
- else {
- // For balanced frame pacing case, the Choreographer callback will handle rendering.
- // We just put all frames into the output buffer queue and let it handle things.
-
- // Discard the oldest buffer if we've exceeded our limit.
- //
- // NB: We have to do this on the producer side because the consumer may not
- // run for a while (if there is a huge mismatch between stream FPS and display
- // refresh rate).
- if (outputBufferQueue.size() == OUTPUT_BUFFER_QUEUE_LIMIT) {
- try {
- videoDecoder.releaseOutputBuffer(outputBufferQueue.take(), false);
- } catch (InterruptedException e) {
- // We're shutting down, so we can just drop this buffer on the floor
- // and it will be reclaimed when the codec is released.
- return;
- }
- }
-
- // Add this buffer
- outputBufferQueue.add(lastIndex);
- }
-
- // Add delta time to the totals (excluding probable outliers)
- long delta = SystemClock.uptimeMillis() - (presentationTimeUs / 1000);
- if (delta >= 0 && delta < 1000) {
- activeWindowVideoStats.decoderTimeMs += delta;
- if (!USE_FRAME_RENDER_TIME) {
- activeWindowVideoStats.totalTimeMs += delta;
- }
- }
- } else {
- switch (outIndex) {
- case MediaCodec.INFO_TRY_AGAIN_LATER:
- break;
- case MediaCodec.INFO_OUTPUT_FORMAT_CHANGED:
- LimeLog.info("Output format changed");
- outputFormat = videoDecoder.getOutputFormat();
- LimeLog.info("New output format: " + outputFormat);
- break;
- default:
- break;
- }
- }
- } catch (IllegalStateException e) {
- handleDecoderException(e);
- } finally {
- doCodecRecoveryIfRequired(CR_FLAG_RENDER_THREAD);
- }
- }
- }
- };
- rendererThread.setName("Video - Renderer (MediaCodec)");
- rendererThread.setPriority(Thread.NORM_PRIORITY + 2);
- rendererThread.start();
- }
-
- private boolean fetchNextInputBuffer() {
- long startTime;
- boolean codecRecovered;
-
- if (nextInputBuffer != null) {
- // We already have an input buffer
- return true;
- }
-
- startTime = SystemClock.uptimeMillis();
-
- try {
- // If we don't have an input buffer index yet, fetch one now
- while (nextInputBufferIndex < 0 && !stopping) {
- nextInputBufferIndex = videoDecoder.dequeueInputBuffer(10000);
- }
-
- // Get the backing ByteBuffer for the input buffer index
- if (nextInputBufferIndex >= 0) {
- // Using the new getInputBuffer() API on Lollipop allows
- // the framework to do some performance optimizations for us
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- nextInputBuffer = videoDecoder.getInputBuffer(nextInputBufferIndex);
- if (nextInputBuffer == null) {
- // According to the Android docs, getInputBuffer() can return null "if the
- // index is not a dequeued input buffer". I don't think this ever should
- // happen but if it does, let's try to get a new input buffer next time.
- nextInputBufferIndex = -1;
- }
- }
- else {
- nextInputBuffer = legacyInputBuffers[nextInputBufferIndex];
-
- // Clear old input data pre-Lollipop
- nextInputBuffer.clear();
- }
- }
- } catch (IllegalStateException e) {
- handleDecoderException(e);
- return false;
- } finally {
- codecRecovered = doCodecRecoveryIfRequired(CR_FLAG_INPUT_THREAD);
- }
-
- // If codec recovery is required, always return false to ensure the caller will request
- // an IDR frame to complete the codec recovery.
- if (codecRecovered) {
- return false;
- }
-
- int deltaMs = (int)(SystemClock.uptimeMillis() - startTime);
-
- if (deltaMs >= 20) {
- LimeLog.warning("Dequeue input buffer ran long: " + deltaMs + " ms");
- }
-
- if (nextInputBuffer == null) {
- // We've been hung for 5 seconds and no other exception was reported,
- // so generate a decoder hung exception
- if (deltaMs >= 5000 && initialException == null) {
- DecoderHungException decoderHungException = new DecoderHungException(deltaMs);
- if (!reportedCrash) {
- reportedCrash = true;
- crashListener.notifyCrash(decoderHungException);
- }
- throw new RendererException(this, decoderHungException);
- }
-
- return false;
- }
-
- return true;
- }
-
- @Override
- public void start() {
- startRendererThread();
- startChoreographerThread();
- }
-
- // !!! May be called even if setup()/start() fails !!!
- public void prepareForStop() {
- // Let the decoding code know to ignore codec exceptions now
- stopping = true;
-
- // Halt the rendering thread
- if (rendererThread != null) {
- rendererThread.interrupt();
- }
-
- // Stop any active codec recovery operations
- synchronized (codecRecoveryMonitor) {
- codecRecoveryType.set(CR_RECOVERY_TYPE_NONE);
- codecRecoveryMonitor.notifyAll();
- }
-
- // Post a quit message to the Choreographer looper (if we have one)
- if (choreographerHandler != null) {
- choreographerHandler.post(new Runnable() {
- @Override
- public void run() {
- // Don't allow any further messages to be queued
- choreographerHandlerThread.quit();
-
- // Deregister the frame callback (if registered)
- Choreographer.getInstance().removeFrameCallback(MediaCodecDecoderRenderer.this);
- }
- });
- }
- }
-
- @Override
- public void stop() {
- // May be called already, but we'll call it now to be safe
- prepareForStop();
-
- // Wait for the Choreographer looper to shut down (if we have one)
- if (choreographerHandlerThread != null) {
- try {
- choreographerHandlerThread.join();
- } catch (InterruptedException e) {
- e.printStackTrace();
-
- // InterruptedException clears the thread's interrupt status. Since we can't
- // handle that here, we will re-interrupt the thread to set the interrupt
- // status back to true.
- Thread.currentThread().interrupt();
- }
- }
-
- // Wait for the renderer thread to shut down
- try {
- rendererThread.join();
- } catch (InterruptedException e) {
- e.printStackTrace();
-
- // InterruptedException clears the thread's interrupt status. Since we can't
- // handle that here, we will re-interrupt the thread to set the interrupt
- // status back to true.
- Thread.currentThread().interrupt();
- }
- }
-
- @Override
- public void cleanup() {
- videoDecoder.release();
- }
-
- @Override
- public void setHdrMode(boolean enabled, byte[] hdrMetadata) {
- // HDR metadata is only supported in Android 7.0 and later, so don't bother
- // restarting the codec on anything earlier than that.
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- if (currentHdrMetadata != null && (!enabled || hdrMetadata == null)) {
- currentHdrMetadata = null;
- }
- else if (enabled && hdrMetadata != null && !Arrays.equals(currentHdrMetadata, hdrMetadata)) {
- currentHdrMetadata = hdrMetadata;
- }
- else {
- // Nothing to do
- return;
- }
-
- // If we reach this point, we need to restart the MediaCodec instance to
- // pick up the HDR metadata change. This will happen on the next input
- // or output buffer.
-
- // HACK: Reset codec recovery attempt counter, since this is an expected "recovery"
- codecRecoveryAttempts = 0;
-
- // Promote None/Flush to Restart and leave Reset alone
- if (!codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_NONE, CR_RECOVERY_TYPE_RESTART)) {
- codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_FLUSH, CR_RECOVERY_TYPE_RESTART);
- }
- }
- }
-
- private boolean queueNextInputBuffer(long timestampUs, int codecFlags) {
- boolean codecRecovered;
-
- try {
- videoDecoder.queueInputBuffer(nextInputBufferIndex,
- 0, nextInputBuffer.position(),
- timestampUs, codecFlags);
-
- // We need a new buffer now
- nextInputBufferIndex = -1;
- nextInputBuffer = null;
- } catch (IllegalStateException e) {
- if (handleDecoderException(e)) {
- // We encountered a transient error. In this case, just hold onto the buffer
- // (to avoid leaking it), clear it, and keep it for the next frame. We'll return
- // false to trigger an IDR frame to recover.
- nextInputBuffer.clear();
- }
- else {
- // We encountered a non-transient error. In this case, we will simply leak the
- // buffer because we cannot be sure we will ever succeed in queuing it.
- nextInputBufferIndex = -1;
- nextInputBuffer = null;
- }
- return false;
- } finally {
- codecRecovered = doCodecRecoveryIfRequired(CR_FLAG_INPUT_THREAD);
- }
-
- // If codec recovery is required, always return false to ensure the caller will request
- // an IDR frame to complete the codec recovery.
- if (codecRecovered) {
- return false;
- }
-
- // Fetch a new input buffer now while we have some time between frames
- // to have it ready immediately when the next frame arrives.
- //
- // We must propagate the return value here in order to properly handle
- // codec recovery happening in fetchNextInputBuffer(). If we don't, we'll
- // never get an IDR frame to complete the recovery process.
- return fetchNextInputBuffer();
- }
-
- private void doProfileSpecificSpsPatching(SeqParameterSet sps) {
- // Some devices benefit from setting constraint flags 4 & 5 to make this Constrained
- // High Profile which allows the decoder to assume there will be no B-frames and
- // reduce delay and buffering accordingly. Some devices (Marvell, Exynos 4) don't
- // like it so we only set them on devices that are confirmed to benefit from it.
- if (sps.profileIdc == 100 && constrainedHighProfile) {
- LimeLog.info("Setting constraint set flags for constrained high profile");
- sps.constraintSet4Flag = true;
- sps.constraintSet5Flag = true;
- }
- else {
- // Force the constraints unset otherwise (some may be set by default)
- sps.constraintSet4Flag = false;
- sps.constraintSet5Flag = false;
- }
- }
-
- @SuppressWarnings("deprecation")
- @Override
- public int submitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength, int decodeUnitType,
- int frameNumber, int frameType, char frameHostProcessingLatency,
- long receiveTimeMs, long enqueueTimeMs) {
- if (stopping) {
- // Don't bother if we're stopping
- return MoonBridge.DR_OK;
- }
-
- if (lastFrameNumber == 0) {
- activeWindowVideoStats.measurementStartTimestamp = SystemClock.uptimeMillis();
- } else if (frameNumber != lastFrameNumber && frameNumber != lastFrameNumber + 1) {
- // We can receive the same "frame" multiple times if it's an IDR frame.
- // In that case, each frame start NALU is submitted independently.
- activeWindowVideoStats.framesLost += frameNumber - lastFrameNumber - 1;
- activeWindowVideoStats.totalFrames += frameNumber - lastFrameNumber - 1;
- activeWindowVideoStats.frameLossEvents++;
- }
-
- // Reset CSD data for each IDR frame
- if (lastFrameNumber != frameNumber && frameType == MoonBridge.FRAME_TYPE_IDR) {
- vpsBuffers.clear();
- spsBuffers.clear();
- ppsBuffers.clear();
- }
-
- lastFrameNumber = frameNumber;
-
- // Flip stats windows roughly every second
- if (SystemClock.uptimeMillis() >= activeWindowVideoStats.measurementStartTimestamp + 1000) {
- if (prefs.enablePerfOverlay) {
- VideoStats lastTwo = new VideoStats();
- lastTwo.add(lastWindowVideoStats);
- lastTwo.add(activeWindowVideoStats);
- VideoStatsFps fps = lastTwo.getFps();
- String decoder;
-
- if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H264) != 0) {
- decoder = avcDecoder.getName();
- } else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H265) != 0) {
- decoder = hevcDecoder.getName();
- } else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_AV1) != 0) {
- decoder = av1Decoder.getName();
- } else {
- decoder = "(unknown)";
- }
-
- float decodeTimeMs = (float)lastTwo.decoderTimeMs / lastTwo.totalFramesReceived;
- long rttInfo = MoonBridge.getEstimatedRttInfo();
- StringBuilder sb = new StringBuilder();
- sb.append(context.getString(R.string.perf_overlay_streamdetails, initialWidth + "x" + initialHeight, fps.totalFps)).append('\n');
- sb.append(context.getString(R.string.perf_overlay_decoder, decoder)).append('\n');
- sb.append(context.getString(R.string.perf_overlay_incomingfps, fps.receivedFps)).append('\n');
- sb.append(context.getString(R.string.perf_overlay_renderingfps, fps.renderedFps)).append('\n');
- sb.append(context.getString(R.string.perf_overlay_netdrops,
- (float)lastTwo.framesLost / lastTwo.totalFrames * 100)).append('\n');
- sb.append(context.getString(R.string.perf_overlay_netlatency,
- (int)(rttInfo >> 32), (int)rttInfo)).append('\n');
- if (lastTwo.framesWithHostProcessingLatency > 0) {
- sb.append(context.getString(R.string.perf_overlay_hostprocessinglatency,
- (float)lastTwo.minHostProcessingLatency / 10,
- (float)lastTwo.maxHostProcessingLatency / 10,
- (float)lastTwo.totalHostProcessingLatency / 10 / lastTwo.framesWithHostProcessingLatency)).append('\n');
- }
- sb.append(context.getString(R.string.perf_overlay_dectime, decodeTimeMs));
- perfListener.onPerfUpdate(sb.toString());
- }
-
- globalVideoStats.add(activeWindowVideoStats);
- lastWindowVideoStats.copy(activeWindowVideoStats);
- activeWindowVideoStats.clear();
- activeWindowVideoStats.measurementStartTimestamp = SystemClock.uptimeMillis();
- }
-
- boolean csdSubmittedForThisFrame = false;
-
- // IDR frames require special handling for CSD buffer submission
- if (frameType == MoonBridge.FRAME_TYPE_IDR) {
- // H264 SPS
- if (decodeUnitType == MoonBridge.BUFFER_TYPE_SPS && (videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H264) != 0) {
- numSpsIn++;
-
- ByteBuffer spsBuf = ByteBuffer.wrap(decodeUnitData);
- int startSeqLen = decodeUnitData[2] == 0x01 ? 3 : 4;
-
- // Skip to the start of the NALU data
- spsBuf.position(startSeqLen + 1);
-
- // The H264Utils.readSPS function safely handles
- // Annex B NALUs (including NALUs with escape sequences)
- SeqParameterSet sps = H264Utils.readSPS(spsBuf);
-
- // Some decoders rely on H264 level to decide how many buffers are needed
- // Since we only need one frame buffered, we'll set the level as low as we can
- // for known resolution combinations. Reference frame invalidation may need
- // these, so leave them be for those decoders.
- if (!refFrameInvalidationActive) {
- if (initialWidth <= 720 && initialHeight <= 480 && refreshRate <= 60) {
- // Max 5 buffered frames at 720x480x60
- LimeLog.info("Patching level_idc to 31");
- sps.levelIdc = 31;
- }
- else if (initialWidth <= 1280 && initialHeight <= 720 && refreshRate <= 60) {
- // Max 5 buffered frames at 1280x720x60
- LimeLog.info("Patching level_idc to 32");
- sps.levelIdc = 32;
- }
- else if (initialWidth <= 1920 && initialHeight <= 1080 && refreshRate <= 60) {
- // Max 4 buffered frames at 1920x1080x64
- LimeLog.info("Patching level_idc to 42");
- sps.levelIdc = 42;
- }
- else {
- // Leave the profile alone (currently 5.0)
- }
- }
-
- // TI OMAP4 requires a reference frame count of 1 to decode successfully. Exynos 4
- // also requires this fixup.
- //
- // I'm doing this fixup for all devices because I haven't seen any devices that
- // this causes issues for. At worst, it seems to do nothing and at best it fixes
- // issues with video lag, hangs, and crashes.
- //
- // It does break reference frame invalidation, so we will not do that for decoders
- // where we've enabled reference frame invalidation.
- if (!refFrameInvalidationActive) {
- LimeLog.info("Patching num_ref_frames in SPS");
- sps.numRefFrames = 1;
- }
-
- // GFE 2.5.11 changed the SPS to add additional extensions. Some devices don't like these
- // so we remove them here on old devices unless these devices also support HEVC.
- // See getPreferredColorSpace() for further information.
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O &&
- sps.vuiParams != null &&
- hevcDecoder == null &&
- av1Decoder == null) {
- sps.vuiParams.videoSignalTypePresentFlag = false;
- sps.vuiParams.colourDescriptionPresentFlag = false;
- sps.vuiParams.chromaLocInfoPresentFlag = false;
- }
-
- // Some older devices used to choke on a bitstream restrictions, so we won't provide them
- // unless explicitly whitelisted. For newer devices, leave the bitstream restrictions present.
- if (needsSpsBitstreamFixup || isExynos4 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- // The SPS that comes in the current H264 bytestream doesn't set bitstream_restriction_flag
- // or max_dec_frame_buffering which increases decoding latency on Tegra.
-
- // If the encoder didn't include VUI parameters in the SPS, add them now
- if (sps.vuiParams == null) {
- LimeLog.info("Adding VUI parameters");
- sps.vuiParams = new VUIParameters();
- }
-
- // GFE 2.5.11 started sending bitstream restrictions
- if (sps.vuiParams.bitstreamRestriction == null) {
- LimeLog.info("Adding bitstream restrictions");
- sps.vuiParams.bitstreamRestriction = new VUIParameters.BitstreamRestriction();
- sps.vuiParams.bitstreamRestriction.motionVectorsOverPicBoundariesFlag = true;
- sps.vuiParams.bitstreamRestriction.maxBytesPerPicDenom = 2;
- sps.vuiParams.bitstreamRestriction.maxBitsPerMbDenom = 1;
- sps.vuiParams.bitstreamRestriction.log2MaxMvLengthHorizontal = 16;
- sps.vuiParams.bitstreamRestriction.log2MaxMvLengthVertical = 16;
- sps.vuiParams.bitstreamRestriction.numReorderFrames = 0;
- }
- else {
- LimeLog.info("Patching bitstream restrictions");
- }
-
- // Some devices throw errors if maxDecFrameBuffering < numRefFrames
- sps.vuiParams.bitstreamRestriction.maxDecFrameBuffering = sps.numRefFrames;
-
- // These values are the defaults for the fields, but they are more aggressive
- // than what GFE sends in 2.5.11, but it doesn't seem to cause picture problems.
- // We'll leave these alone for "modern" devices just in case they care.
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
- sps.vuiParams.bitstreamRestriction.maxBytesPerPicDenom = 2;
- sps.vuiParams.bitstreamRestriction.maxBitsPerMbDenom = 1;
- }
-
- // log2_max_mv_length_horizontal and log2_max_mv_length_vertical are set to more
- // conservative values by GFE 2.5.11. We'll let those values stand.
- }
- else if (sps.vuiParams != null) {
- // Devices that didn't/couldn't get bitstream restrictions before GFE 2.5.11
- // will continue to not receive them now
- sps.vuiParams.bitstreamRestriction = null;
- }
-
- // If we need to hack this SPS to say we're baseline, do so now
- if (needsBaselineSpsHack) {
- LimeLog.info("Hacking SPS to baseline");
- sps.profileIdc = 66;
- savedSps = sps;
- }
-
- // Patch the SPS constraint flags
- doProfileSpecificSpsPatching(sps);
-
- // The H264Utils.writeSPS function safely handles
- // Annex B NALUs (including NALUs with escape sequences)
- ByteBuffer escapedNalu = H264Utils.writeSPS(sps, decodeUnitLength);
-
- // Construct the patched SPS
- byte[] naluBuffer = new byte[startSeqLen + 1 + escapedNalu.limit()];
- System.arraycopy(decodeUnitData, 0, naluBuffer, 0, startSeqLen + 1);
- escapedNalu.get(naluBuffer, startSeqLen + 1, escapedNalu.limit());
-
- // Batch this to submit together with other CSD per AOSP docs
- spsBuffers.add(naluBuffer);
- return MoonBridge.DR_OK;
- }
- else if (decodeUnitType == MoonBridge.BUFFER_TYPE_VPS) {
- numVpsIn++;
-
- // Batch this to submit together with other CSD per AOSP docs
- byte[] naluBuffer = new byte[decodeUnitLength];
- System.arraycopy(decodeUnitData, 0, naluBuffer, 0, decodeUnitLength);
- vpsBuffers.add(naluBuffer);
- return MoonBridge.DR_OK;
- }
- // Only the HEVC SPS hits this path (H.264 is handled above)
- else if (decodeUnitType == MoonBridge.BUFFER_TYPE_SPS) {
- numSpsIn++;
-
- // Batch this to submit together with other CSD per AOSP docs
- byte[] naluBuffer = new byte[decodeUnitLength];
- System.arraycopy(decodeUnitData, 0, naluBuffer, 0, decodeUnitLength);
- spsBuffers.add(naluBuffer);
- return MoonBridge.DR_OK;
- }
- else if (decodeUnitType == MoonBridge.BUFFER_TYPE_PPS) {
- numPpsIn++;
-
- // Batch this to submit together with other CSD per AOSP docs
- byte[] naluBuffer = new byte[decodeUnitLength];
- System.arraycopy(decodeUnitData, 0, naluBuffer, 0, decodeUnitLength);
- ppsBuffers.add(naluBuffer);
- return MoonBridge.DR_OK;
- }
- else if ((videoFormat & (MoonBridge.VIDEO_FORMAT_MASK_H264 | MoonBridge.VIDEO_FORMAT_MASK_H265)) != 0) {
- // If this is the first CSD blob or we aren't supporting fused IDR frames, we will
- // submit the CSD blob in a separate input buffer for each IDR frame.
- if (!submittedCsd || !fusedIdrFrame) {
- if (!fetchNextInputBuffer()) {
- return MoonBridge.DR_NEED_IDR;
- }
-
- // Submit all CSD when we receive the first non-CSD blob in an IDR frame
- for (byte[] vpsBuffer : vpsBuffers) {
- nextInputBuffer.put(vpsBuffer);
- }
- for (byte[] spsBuffer : spsBuffers) {
- nextInputBuffer.put(spsBuffer);
- }
- for (byte[] ppsBuffer : ppsBuffers) {
- nextInputBuffer.put(ppsBuffer);
- }
-
- if (!queueNextInputBuffer(0, MediaCodec.BUFFER_FLAG_CODEC_CONFIG)) {
- return MoonBridge.DR_NEED_IDR;
- }
-
- // Remember that we already submitted CSD for this frame, so we don't do it
- // again in the fused IDR case below.
- csdSubmittedForThisFrame = true;
-
- // Remember that we submitted CSD globally for this MediaCodec instance
- submittedCsd = true;
-
- if (needsBaselineSpsHack) {
- needsBaselineSpsHack = false;
-
- if (!replaySps()) {
- return MoonBridge.DR_NEED_IDR;
- }
-
- LimeLog.info("SPS replay complete");
- }
- }
- }
- }
-
- if (frameHostProcessingLatency != 0) {
- if (activeWindowVideoStats.minHostProcessingLatency != 0) {
- activeWindowVideoStats.minHostProcessingLatency = (char) Math.min(activeWindowVideoStats.minHostProcessingLatency, frameHostProcessingLatency);
- } else {
- activeWindowVideoStats.minHostProcessingLatency = frameHostProcessingLatency;
- }
- activeWindowVideoStats.framesWithHostProcessingLatency += 1;
- }
- activeWindowVideoStats.maxHostProcessingLatency = (char) Math.max(activeWindowVideoStats.maxHostProcessingLatency, frameHostProcessingLatency);
- activeWindowVideoStats.totalHostProcessingLatency += frameHostProcessingLatency;
-
- activeWindowVideoStats.totalFramesReceived++;
- activeWindowVideoStats.totalFrames++;
-
- if (!FRAME_RENDER_TIME_ONLY) {
- // Count time from first packet received to enqueue time as receive time
- // We will count DU queue time as part of decoding, because it is directly
- // caused by a slow decoder.
- activeWindowVideoStats.totalTimeMs += enqueueTimeMs - receiveTimeMs;
- }
-
- if (!fetchNextInputBuffer()) {
- return MoonBridge.DR_NEED_IDR;
- }
-
- int codecFlags = 0;
-
- if (frameType == MoonBridge.FRAME_TYPE_IDR) {
- codecFlags |= MediaCodec.BUFFER_FLAG_SYNC_FRAME;
-
- // If we are using fused IDR frames, submit the CSD with each IDR frame
- if (fusedIdrFrame && !csdSubmittedForThisFrame) {
- for (byte[] vpsBuffer : vpsBuffers) {
- nextInputBuffer.put(vpsBuffer);
- }
- for (byte[] spsBuffer : spsBuffers) {
- nextInputBuffer.put(spsBuffer);
- }
- for (byte[] ppsBuffer : ppsBuffers) {
- nextInputBuffer.put(ppsBuffer);
- }
- }
- }
-
- long timestampUs = enqueueTimeMs * 1000;
- if (timestampUs <= lastTimestampUs) {
- // We can't submit multiple buffers with the same timestamp
- // so bump it up by one before queuing
- timestampUs = lastTimestampUs + 1;
- }
- lastTimestampUs = timestampUs;
-
- numFramesIn++;
-
- if (decodeUnitLength > nextInputBuffer.limit() - nextInputBuffer.position()) {
- IllegalArgumentException exception = new IllegalArgumentException(
- "Decode unit length "+decodeUnitLength+" too large for input buffer "+nextInputBuffer.limit());
- if (!reportedCrash) {
- reportedCrash = true;
- crashListener.notifyCrash(exception);
- }
- throw new RendererException(this, exception);
- }
-
- // Copy data from our buffer list into the input buffer
- nextInputBuffer.put(decodeUnitData, 0, decodeUnitLength);
-
- if (!queueNextInputBuffer(timestampUs, codecFlags)) {
- return MoonBridge.DR_NEED_IDR;
- }
-
- return MoonBridge.DR_OK;
- }
-
- private boolean replaySps() {
- if (!fetchNextInputBuffer()) {
- return false;
- }
-
- // Write the Annex B header
- nextInputBuffer.put(new byte[]{0x00, 0x00, 0x00, 0x01, 0x67});
-
- // Switch the H264 profile back to high
- savedSps.profileIdc = 100;
-
- // Patch the SPS constraint flags
- doProfileSpecificSpsPatching(savedSps);
-
- // The H264Utils.writeSPS function safely handles
- // Annex B NALUs (including NALUs with escape sequences)
- ByteBuffer escapedNalu = H264Utils.writeSPS(savedSps, 128);
- nextInputBuffer.put(escapedNalu);
-
- // No need for the SPS anymore
- savedSps = null;
-
- // Queue the new SPS
- return queueNextInputBuffer(0, MediaCodec.BUFFER_FLAG_CODEC_CONFIG);
- }
-
- @Override
- public int getCapabilities() {
- int capabilities = 0;
-
- // Request the optimal number of slices per frame for this decoder
- capabilities |= MoonBridge.CAPABILITY_SLICES_PER_FRAME(optimalSlicesPerFrame);
-
- // Enable reference frame invalidation on supported hardware
- if (refFrameInvalidationAvc) {
- capabilities |= MoonBridge.CAPABILITY_REFERENCE_FRAME_INVALIDATION_AVC;
- }
- if (refFrameInvalidationHevc) {
- capabilities |= MoonBridge.CAPABILITY_REFERENCE_FRAME_INVALIDATION_HEVC;
- }
- if (refFrameInvalidationAv1) {
- capabilities |= MoonBridge.CAPABILITY_REFERENCE_FRAME_INVALIDATION_AV1;
- }
-
- // Enable direct submit on supported hardware
- if (directSubmit) {
- capabilities |= MoonBridge.CAPABILITY_DIRECT_SUBMIT;
- }
-
- return capabilities;
- }
-
- public int getAverageEndToEndLatency() {
- if (globalVideoStats.totalFramesReceived == 0) {
- return 0;
- }
- return (int)(globalVideoStats.totalTimeMs / globalVideoStats.totalFramesReceived);
- }
-
- public int getAverageDecoderLatency() {
- if (globalVideoStats.totalFramesReceived == 0) {
- return 0;
- }
- return (int)(globalVideoStats.decoderTimeMs / globalVideoStats.totalFramesReceived);
- }
-
- static class DecoderHungException extends RuntimeException {
- private int hangTimeMs;
-
- DecoderHungException(int hangTimeMs) {
- this.hangTimeMs = hangTimeMs;
- }
-
- public String toString() {
- String str = "";
-
- str += "Hang time: "+hangTimeMs+" ms"+ RendererException.DELIMITER;
- str += super.toString();
-
- return str;
- }
- }
-
- static class RendererException extends RuntimeException {
- private static final long serialVersionUID = 8985937536997012406L;
- protected static final String DELIMITER = BuildConfig.DEBUG ? "\n" : " | ";
-
- private String text;
-
- RendererException(MediaCodecDecoderRenderer renderer, Exception e) {
- this.text = generateText(renderer, e);
- }
-
- public String toString() {
- return text;
- }
-
- private String generateText(MediaCodecDecoderRenderer renderer, Exception originalException) {
- String str;
-
- if (renderer.numVpsIn == 0 && renderer.numSpsIn == 0 && renderer.numPpsIn == 0) {
- str = "PreSPSError";
- }
- else if (renderer.numSpsIn > 0 && renderer.numPpsIn == 0) {
- str = "PrePPSError";
- }
- else if (renderer.numPpsIn > 0 && renderer.numFramesIn == 0) {
- str = "PreIFrameError";
- }
- else if (renderer.numFramesIn > 0 && renderer.outputFormat == null) {
- str = "PreOutputConfigError";
- }
- else if (renderer.outputFormat != null && renderer.numFramesOut == 0) {
- str = "PreOutputError";
- }
- else if (renderer.numFramesOut <= renderer.refreshRate * 30) {
- str = "EarlyOutputError";
- }
- else {
- str = "ErrorWhileStreaming";
- }
-
- str += "Format: "+String.format("%x", renderer.videoFormat)+DELIMITER;
- str += "AVC Decoder: "+((renderer.avcDecoder != null) ? renderer.avcDecoder.getName():"(none)")+DELIMITER;
- str += "HEVC Decoder: "+((renderer.hevcDecoder != null) ? renderer.hevcDecoder.getName():"(none)")+DELIMITER;
- str += "AV1 Decoder: "+((renderer.av1Decoder != null) ? renderer.av1Decoder.getName():"(none)")+DELIMITER;
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && renderer.avcDecoder != null) {
- Range avcWidthRange = renderer.avcDecoder.getCapabilitiesForType("video/avc").getVideoCapabilities().getSupportedWidths();
- str += "AVC supported width range: "+avcWidthRange+DELIMITER;
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- try {
- Range avcFpsRange = renderer.avcDecoder.getCapabilitiesForType("video/avc").getVideoCapabilities().getAchievableFrameRatesFor(renderer.initialWidth, renderer.initialHeight);
- str += "AVC achievable FPS range: "+avcFpsRange+DELIMITER;
- } catch (IllegalArgumentException e) {
- str += "AVC achievable FPS range: UNSUPPORTED!"+DELIMITER;
- }
- }
- }
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && renderer.hevcDecoder != null) {
- Range hevcWidthRange = renderer.hevcDecoder.getCapabilitiesForType("video/hevc").getVideoCapabilities().getSupportedWidths();
- str += "HEVC supported width range: "+hevcWidthRange+DELIMITER;
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- try {
- Range hevcFpsRange = renderer.hevcDecoder.getCapabilitiesForType("video/hevc").getVideoCapabilities().getAchievableFrameRatesFor(renderer.initialWidth, renderer.initialHeight);
- str += "HEVC achievable FPS range: " + hevcFpsRange + DELIMITER;
- } catch (IllegalArgumentException e) {
- str += "HEVC achievable FPS range: UNSUPPORTED!"+DELIMITER;
- }
- }
- }
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && renderer.av1Decoder != null) {
- Range av1WidthRange = renderer.av1Decoder.getCapabilitiesForType("video/av01").getVideoCapabilities().getSupportedWidths();
- str += "AV1 supported width range: "+av1WidthRange+DELIMITER;
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- try {
- Range av1FpsRange = renderer.av1Decoder.getCapabilitiesForType("video/av01").getVideoCapabilities().getAchievableFrameRatesFor(renderer.initialWidth, renderer.initialHeight);
- str += "AV1 achievable FPS range: " + av1FpsRange + DELIMITER;
- } catch (IllegalArgumentException e) {
- str += "AV1 achievable FPS range: UNSUPPORTED!"+DELIMITER;
- }
- }
- }
- str += "Configured format: "+renderer.configuredFormat+DELIMITER;
- str += "Input format: "+renderer.inputFormat+DELIMITER;
- str += "Output format: "+renderer.outputFormat+DELIMITER;
- str += "Adaptive playback: "+renderer.adaptivePlayback+DELIMITER;
- str += "GL Renderer: "+renderer.glRenderer+DELIMITER;
- //str += "Build fingerprint: "+Build.FINGERPRINT+DELIMITER;
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- str += "SOC: "+Build.SOC_MANUFACTURER+" - "+Build.SOC_MODEL+DELIMITER;
- str += "Performance class: "+Build.VERSION.MEDIA_PERFORMANCE_CLASS+DELIMITER;
- /*str += "Vendor params: ";
- List params = renderer.videoDecoder.getSupportedVendorParameters();
- if (params.isEmpty()) {
- str += "NONE";
- }
- else {
- for (String param : params) {
- str += param + " ";
- }
- }
- str += DELIMITER;*/
- }
- str += "Consecutive crashes: "+renderer.consecutiveCrashCount+DELIMITER;
- str += "RFI active: "+renderer.refFrameInvalidationActive+DELIMITER;
- str += "Using modern SPS patching: "+(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)+DELIMITER;
- str += "Fused IDR frames: "+renderer.fusedIdrFrame+DELIMITER;
- str += "Video dimensions: "+renderer.initialWidth+"x"+renderer.initialHeight+DELIMITER;
- str += "FPS target: "+renderer.refreshRate+DELIMITER;
- str += "Bitrate: "+renderer.prefs.bitrate+" Kbps"+DELIMITER;
- str += "CSD stats: "+renderer.numVpsIn+", "+renderer.numSpsIn+", "+renderer.numPpsIn+DELIMITER;
- str += "Frames in-out: "+renderer.numFramesIn+", "+renderer.numFramesOut+DELIMITER;
- str += "Total frames received: "+renderer.globalVideoStats.totalFramesReceived+DELIMITER;
- str += "Total frames rendered: "+renderer.globalVideoStats.totalFramesRendered+DELIMITER;
- str += "Frame losses: "+renderer.globalVideoStats.framesLost+" in "+renderer.globalVideoStats.frameLossEvents+" loss events"+DELIMITER;
- str += "Average end-to-end client latency: "+renderer.getAverageEndToEndLatency()+"ms"+DELIMITER;
- str += "Average hardware decoder latency: "+renderer.getAverageDecoderLatency()+"ms"+DELIMITER;
- str += "Frame pacing mode: "+renderer.prefs.framePacing+DELIMITER;
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- if (originalException instanceof CodecException) {
- CodecException ce = (CodecException) originalException;
-
- str += "Diagnostic Info: "+ce.getDiagnosticInfo()+DELIMITER;
- str += "Recoverable: "+ce.isRecoverable()+DELIMITER;
- str += "Transient: "+ce.isTransient()+DELIMITER;
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- str += "Codec Error Code: "+ce.getErrorCode()+DELIMITER;
- }
- }
- }
-
- str += originalException.toString();
-
- return str;
- }
- }
-}
+package com.limelight.binding.video;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.jcodec.codecs.h264.H264Utils;
+import org.jcodec.codecs.h264.io.model.SeqParameterSet;
+import org.jcodec.codecs.h264.io.model.VUIParameters;
+
+import com.limelight.BuildConfig;
+import com.limelight.LimeLog;
+import com.limelight.R;
+import com.limelight.nvstream.av.video.VideoDecoderRenderer;
+import com.limelight.nvstream.jni.MoonBridge;
+import com.limelight.preferences.PreferenceConfiguration;
+import com.limelight.utils.TrafficStatsHelper;
+
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.content.Context;
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo;
+import android.media.MediaFormat;
+import android.media.MediaCodec.BufferInfo;
+import android.media.MediaCodec.CodecException;
+import android.net.TrafficStats;
+import android.os.Build;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Process;
+import android.os.SystemClock;
+import android.util.Range;
+import android.view.Choreographer;
+import android.view.Surface;
+
+public class MediaCodecDecoderRenderer extends VideoDecoderRenderer implements Choreographer.FrameCallback {
+
+ private static final boolean USE_FRAME_RENDER_TIME = false;
+ private static final boolean FRAME_RENDER_TIME_ONLY = USE_FRAME_RENDER_TIME && false;
+
+ // Used on versions < 5.0
+ private ByteBuffer[] legacyInputBuffers;
+
+ private MediaCodecInfo avcDecoder;
+ private MediaCodecInfo hevcDecoder;
+ private MediaCodecInfo av1Decoder;
+
+ private final ArrayList vpsBuffers = new ArrayList<>();
+ private final ArrayList spsBuffers = new ArrayList<>();
+ private final ArrayList ppsBuffers = new ArrayList<>();
+ private boolean submittedCsd;
+ private byte[] currentHdrMetadata;
+
+ private int nextInputBufferIndex = -1;
+ private ByteBuffer nextInputBuffer;
+
+ private Context context;
+ private Activity activity;
+ private MediaCodec videoDecoder;
+ private Thread rendererThread;
+ private boolean needsSpsBitstreamFixup, isExynos4;
+ private boolean adaptivePlayback, directSubmit, fusedIdrFrame;
+ private boolean constrainedHighProfile;
+ private boolean refFrameInvalidationAvc, refFrameInvalidationHevc, refFrameInvalidationAv1;
+ private byte optimalSlicesPerFrame;
+ private boolean refFrameInvalidationActive;
+ private int initialWidth, initialHeight;
+ private boolean invertResolution;
+ private int videoFormat;
+ private Surface renderTarget;
+ private volatile boolean stopping;
+ private CrashListener crashListener;
+ private boolean reportedCrash;
+ private int consecutiveCrashCount;
+ private String glRenderer;
+ private boolean foreground = true;
+ private PerfOverlayListener perfListener;
+
+ private static final int CR_MAX_TRIES = 10;
+ private static final int CR_RECOVERY_TYPE_NONE = 0;
+ private static final int CR_RECOVERY_TYPE_FLUSH = 1;
+ private static final int CR_RECOVERY_TYPE_RESTART = 2;
+ private static final int CR_RECOVERY_TYPE_RESET = 3;
+ private AtomicInteger codecRecoveryType = new AtomicInteger(CR_RECOVERY_TYPE_NONE);
+ private final Object codecRecoveryMonitor = new Object();
+
+ // Each thread that touches the MediaCodec object or any associated buffers must have a flag
+ // here and must call doCodecRecoveryIfRequired() on a regular basis.
+ private static final int CR_FLAG_INPUT_THREAD = 0x1;
+ private static final int CR_FLAG_RENDER_THREAD = 0x2;
+ private static final int CR_FLAG_CHOREOGRAPHER = 0x4;
+ private static final int CR_FLAG_ALL = CR_FLAG_INPUT_THREAD | CR_FLAG_RENDER_THREAD | CR_FLAG_CHOREOGRAPHER;
+ private int codecRecoveryThreadQuiescedFlags = 0;
+ private int codecRecoveryAttempts = 0;
+
+ private MediaFormat inputFormat;
+ private MediaFormat outputFormat;
+ private MediaFormat configuredFormat;
+
+ private boolean needsBaselineSpsHack;
+ private SeqParameterSet savedSps;
+
+ private RendererException initialException;
+ private long initialExceptionTimestamp;
+ private static final int EXCEPTION_REPORT_DELAY_MS = 3000;
+
+ private VideoStats activeWindowVideoStats;
+ private VideoStats lastWindowVideoStats;
+ private VideoStats globalVideoStats;
+
+ private long lastTimestampUs;
+ private int lastFrameNumber;
+ private int refreshRate;
+ private PreferenceConfiguration prefs;
+
+ private long lastNetDataNum;
+ private LinkedBlockingQueue outputBufferQueue = new LinkedBlockingQueue<>();
+ private static final int OUTPUT_BUFFER_QUEUE_LIMIT = 2;
+ private long lastRenderedFrameTimeNanos;
+ private HandlerThread choreographerHandlerThread;
+ private Handler choreographerHandler;
+
+ private int numSpsIn;
+ private int numPpsIn;
+ private int numVpsIn;
+ private int numFramesIn;
+ private int numFramesOut;
+
+ private MediaCodecInfo findAvcDecoder() {
+ MediaCodecInfo decoder = MediaCodecHelper.findProbableSafeDecoder("video/avc", MediaCodecInfo.CodecProfileLevel.AVCProfileHigh);
+ if (decoder == null) {
+ decoder = MediaCodecHelper.findFirstDecoder("video/avc");
+ }
+ return decoder;
+ }
+
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ private boolean decoderCanMeetPerformancePoint(MediaCodecInfo.VideoCapabilities caps, PreferenceConfiguration prefs) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ MediaCodecInfo.VideoCapabilities.PerformancePoint targetPerfPoint = new MediaCodecInfo.VideoCapabilities.PerformancePoint(initialWidth, initialHeight, Math.round(prefs.fps));
+ List perfPoints = caps.getSupportedPerformancePoints();
+ if (perfPoints != null) {
+ for (MediaCodecInfo.VideoCapabilities.PerformancePoint perfPoint : perfPoints) {
+ // If we find a performance point that covers our target, we're good to go
+ if (perfPoint.covers(targetPerfPoint)) {
+ return true;
+ }
+ }
+
+ // We had performance point data but none met the specified streaming settings
+ return false;
+ }
+
+ // Fall-through to try the Android M API if there's no performance point data
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ try {
+ // We'll ask the decoder what it can do for us at this resolution and see if our
+ // requested frame rate falls below or inside the range of achievable frame rates.
+ Range fpsRange = caps.getAchievableFrameRatesFor(initialWidth, initialHeight);
+ if (fpsRange != null) {
+ return prefs.fps <= fpsRange.getUpper();
+ }
+
+ // Fall-through to try the Android L API if there's no performance point data
+ } catch (IllegalArgumentException e) {
+ // Video size not supported at any frame rate
+ return false;
+ }
+ }
+
+ // As a last resort, we will use areSizeAndRateSupported() which is explicitly NOT a
+ // performance metric, but it can work at least for the purpose of determining if
+ // the codec is going to die when given a stream with the specified settings.
+ return caps.areSizeAndRateSupported(initialWidth, initialHeight, prefs.fps);
+ }
+
+ private boolean decoderCanMeetPerformancePointWithHevcAndNotAvc(MediaCodecInfo hevcDecoderInfo, MediaCodecInfo avcDecoderInfo, PreferenceConfiguration prefs) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ MediaCodecInfo.VideoCapabilities avcCaps = avcDecoderInfo.getCapabilitiesForType("video/avc").getVideoCapabilities();
+ MediaCodecInfo.VideoCapabilities hevcCaps = hevcDecoderInfo.getCapabilitiesForType("video/hevc").getVideoCapabilities();
+
+ return !decoderCanMeetPerformancePoint(avcCaps, prefs) && decoderCanMeetPerformancePoint(hevcCaps, prefs);
+ }
+ else {
+ // No performance data
+ return false;
+ }
+ }
+
+ private boolean decoderCanMeetPerformancePointWithAv1AndNotHevc(MediaCodecInfo av1DecoderInfo, MediaCodecInfo hevcDecoderInfo, PreferenceConfiguration prefs) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ MediaCodecInfo.VideoCapabilities av1Caps = av1DecoderInfo.getCapabilitiesForType("video/av01").getVideoCapabilities();
+ MediaCodecInfo.VideoCapabilities hevcCaps = hevcDecoderInfo.getCapabilitiesForType("video/hevc").getVideoCapabilities();
+
+ return !decoderCanMeetPerformancePoint(hevcCaps, prefs) && decoderCanMeetPerformancePoint(av1Caps, prefs);
+ }
+ else {
+ // No performance data
+ return false;
+ }
+ }
+
+ private boolean decoderCanMeetPerformancePointWithAv1AndNotAvc(MediaCodecInfo av1DecoderInfo, MediaCodecInfo avcDecoderInfo, PreferenceConfiguration prefs) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ MediaCodecInfo.VideoCapabilities avcCaps = avcDecoderInfo.getCapabilitiesForType("video/avc").getVideoCapabilities();
+ MediaCodecInfo.VideoCapabilities av1Caps = av1DecoderInfo.getCapabilitiesForType("video/av01").getVideoCapabilities();
+
+ return !decoderCanMeetPerformancePoint(avcCaps, prefs) && decoderCanMeetPerformancePoint(av1Caps, prefs);
+ }
+ else {
+ // No performance data
+ return false;
+ }
+ }
+
+ private MediaCodecInfo findHevcDecoder(PreferenceConfiguration prefs, boolean meteredNetwork, boolean requestedHdr) {
+ // Don't return anything if H.264 is forced
+ if (prefs.videoFormat == PreferenceConfiguration.FormatOption.FORCE_H264) {
+ return null;
+ }
+
+ // We don't try the first HEVC decoder. We'd rather fall back to hardware accelerated AVC instead
+ //
+ // We need HEVC Main profile, so we could pass that constant to findProbableSafeDecoder, however
+ // some decoders (at least Qualcomm's Snapdragon 805) don't properly report support
+ // for even required levels of HEVC.
+ MediaCodecInfo hevcDecoderInfo = MediaCodecHelper.findProbableSafeDecoder("video/hevc", -1);
+ if (hevcDecoderInfo != null) {
+ if (!MediaCodecHelper.decoderIsWhitelistedForHevc(hevcDecoderInfo)) {
+ LimeLog.info("Found HEVC decoder, but it's not whitelisted - "+hevcDecoderInfo.getName());
+
+ // Force HEVC enabled if the user asked for it
+ if (prefs.videoFormat == PreferenceConfiguration.FormatOption.FORCE_HEVC) {
+ LimeLog.info("Forcing HEVC enabled despite non-whitelisted decoder");
+ }
+ // HDR implies HEVC forced on, since HEVCMain10HDR10 is required for HDR.
+ else if (requestedHdr) {
+ LimeLog.info("Forcing HEVC enabled for HDR streaming");
+ }
+ // > 4K streaming also requires HEVC, so force it on there too.
+ else if (initialWidth > 4096 || initialHeight > 4096) {
+ LimeLog.info("Forcing HEVC enabled for over 4K streaming");
+ }
+ // Use HEVC if the H.264 decoder is unable to meet the performance point
+ else if (avcDecoder != null && decoderCanMeetPerformancePointWithHevcAndNotAvc(hevcDecoderInfo, avcDecoder, prefs)) {
+ LimeLog.info("Using non-whitelisted HEVC decoder to meet performance point");
+ }
+ else {
+ return null;
+ }
+ }
+ }
+
+ return hevcDecoderInfo;
+ }
+
+ private MediaCodecInfo findAv1Decoder(PreferenceConfiguration prefs) {
+ // For now, don't use AV1 unless explicitly requested
+ if (prefs.videoFormat != PreferenceConfiguration.FormatOption.FORCE_AV1) {
+ return null;
+ }
+
+ MediaCodecInfo decoderInfo = MediaCodecHelper.findProbableSafeDecoder("video/av01", -1);
+ if (decoderInfo != null) {
+ if (!MediaCodecHelper.isDecoderWhitelistedForAv1(decoderInfo)) {
+ LimeLog.info("Found AV1 decoder, but it's not whitelisted - "+decoderInfo.getName());
+
+ // Force HEVC enabled if the user asked for it
+ if (prefs.videoFormat == PreferenceConfiguration.FormatOption.FORCE_AV1) {
+ LimeLog.info("Forcing AV1 enabled despite non-whitelisted decoder");
+ }
+ // Use AV1 if the HEVC decoder is unable to meet the performance point
+ else if (hevcDecoder != null && decoderCanMeetPerformancePointWithAv1AndNotHevc(decoderInfo, hevcDecoder, prefs)) {
+ LimeLog.info("Using non-whitelisted AV1 decoder to meet performance point");
+ }
+ // Use AV1 if the H.264 decoder is unable to meet the performance point and we have no HEVC decoder
+ else if (hevcDecoder == null && decoderCanMeetPerformancePointWithAv1AndNotAvc(decoderInfo, avcDecoder, prefs)) {
+ LimeLog.info("Using non-whitelisted AV1 decoder to meet performance point");
+ }
+ else {
+ return null;
+ }
+ }
+ }
+
+ return decoderInfo;
+ }
+
+ public void setRenderTarget(Surface renderTarget) {
+ this.renderTarget = renderTarget;
+ }
+
+ public MediaCodecDecoderRenderer(Activity activity, PreferenceConfiguration prefs,
+ CrashListener crashListener, int consecutiveCrashCount,
+ boolean meteredData, boolean requestedHdr, boolean invertResolution,
+ String glRenderer, PerfOverlayListener perfListener) {
+ //dumpDecoders();
+
+ this.context = activity;
+ this.activity = activity;
+ this.prefs = prefs;
+ this.crashListener = crashListener;
+ this.consecutiveCrashCount = consecutiveCrashCount;
+ this.glRenderer = glRenderer;
+ this.perfListener = perfListener;
+ this.invertResolution = invertResolution;
+
+ this.activeWindowVideoStats = new VideoStats();
+ this.lastWindowVideoStats = new VideoStats();
+ this.globalVideoStats = new VideoStats();
+
+ avcDecoder = findAvcDecoder();
+ if (avcDecoder != null) {
+ LimeLog.info("Selected AVC decoder: "+avcDecoder.getName());
+ }
+ else {
+ LimeLog.warning("No AVC decoder found");
+ }
+
+ hevcDecoder = findHevcDecoder(prefs, meteredData, requestedHdr);
+ if (hevcDecoder != null) {
+ LimeLog.info("Selected HEVC decoder: "+hevcDecoder.getName());
+ }
+ else {
+ LimeLog.info("No HEVC decoder found");
+ }
+
+ av1Decoder = findAv1Decoder(prefs);
+ if (av1Decoder != null) {
+ LimeLog.info("Selected AV1 decoder: "+av1Decoder.getName());
+ }
+ else {
+ LimeLog.info("No AV1 decoder found");
+ }
+
+ // Set attributes that are queried in getCapabilities(). This must be done here
+ // because getCapabilities() may be called before setup() in current versions of the common
+ // library. The limitation of this is that we don't know whether we're using HEVC or AVC.
+ int avcOptimalSlicesPerFrame = 0;
+ int hevcOptimalSlicesPerFrame = 0;
+ if (avcDecoder != null) {
+ directSubmit = MediaCodecHelper.decoderCanDirectSubmit(avcDecoder.getName());
+ refFrameInvalidationAvc = MediaCodecHelper.decoderSupportsRefFrameInvalidationAvc(avcDecoder.getName(), initialHeight);
+ avcOptimalSlicesPerFrame = MediaCodecHelper.getDecoderOptimalSlicesPerFrame(avcDecoder.getName());
+
+ if (directSubmit) {
+ LimeLog.info("Decoder "+avcDecoder.getName()+" will use direct submit");
+ }
+ if (refFrameInvalidationAvc) {
+ LimeLog.info("Decoder "+avcDecoder.getName()+" will use reference frame invalidation for AVC");
+ }
+ LimeLog.info("Decoder "+avcDecoder.getName()+" wants "+avcOptimalSlicesPerFrame+" slices per frame");
+ }
+
+ if (hevcDecoder != null) {
+ refFrameInvalidationHevc = MediaCodecHelper.decoderSupportsRefFrameInvalidationHevc(hevcDecoder);
+ hevcOptimalSlicesPerFrame = MediaCodecHelper.getDecoderOptimalSlicesPerFrame(hevcDecoder.getName());
+
+ if (refFrameInvalidationHevc) {
+ LimeLog.info("Decoder "+hevcDecoder.getName()+" will use reference frame invalidation for HEVC");
+ }
+
+ LimeLog.info("Decoder "+hevcDecoder.getName()+" wants "+hevcOptimalSlicesPerFrame+" slices per frame");
+ }
+
+ if (av1Decoder != null) {
+ refFrameInvalidationAv1 = MediaCodecHelper.decoderSupportsRefFrameInvalidationAv1(av1Decoder);
+
+ if (refFrameInvalidationAv1) {
+ LimeLog.info("Decoder "+av1Decoder.getName()+" will use reference frame invalidation for AV1");
+ }
+ }
+
+ // Use the larger of the two slices per frame preferences
+ optimalSlicesPerFrame = (byte)Math.max(avcOptimalSlicesPerFrame, hevcOptimalSlicesPerFrame);
+ LimeLog.info("Requesting "+optimalSlicesPerFrame+" slices per frame");
+
+ if (consecutiveCrashCount % 2 == 1) {
+ refFrameInvalidationAvc = refFrameInvalidationHevc = false;
+ LimeLog.warning("Disabling RFI due to previous crash");
+ }
+ }
+
+ public boolean isHevcSupported() {
+ return hevcDecoder != null;
+ }
+
+ public boolean isAvcSupported() {
+ return avcDecoder != null;
+ }
+
+ public boolean isHevcMain10Hdr10Supported() {
+ if (hevcDecoder == null) {
+ return false;
+ }
+
+ for (MediaCodecInfo.CodecProfileLevel profileLevel : hevcDecoder.getCapabilitiesForType("video/hevc").profileLevels) {
+ if (profileLevel.profile == MediaCodecInfo.CodecProfileLevel.HEVCProfileMain10HDR10) {
+ LimeLog.info("HEVC decoder "+hevcDecoder.getName()+" supports HEVC Main10 HDR10");
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public boolean isAv1Supported() {
+ return av1Decoder != null;
+ }
+
+ public boolean isAv1Main10Supported() {
+ if (av1Decoder == null) {
+ return false;
+ }
+
+ for (MediaCodecInfo.CodecProfileLevel profileLevel : av1Decoder.getCapabilitiesForType("video/av01").profileLevels) {
+ if (profileLevel.profile == MediaCodecInfo.CodecProfileLevel.AV1ProfileMain10HDR10) {
+ LimeLog.info("AV1 decoder "+av1Decoder.getName()+" supports AV1 Main 10 HDR10");
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public int getPreferredColorSpace() {
+ // Default to Rec 709 which is probably better supported on modern devices.
+ //
+ // We are sticking to Rec 601 on older devices unless the device has an HEVC decoder
+ // to avoid possible regressions (and they are < 5% of installed devices). If we have
+ // an HEVC decoder, we will use Rec 709 (even for H.264) since we can't choose a
+ // colorspace by codec (and it's probably safe to say a SoC with HEVC decoding is
+ // plenty modern enough to handle H.264 VUI colorspace info).
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O || hevcDecoder != null || av1Decoder != null) {
+ return MoonBridge.COLORSPACE_REC_709;
+ }
+ else {
+ return MoonBridge.COLORSPACE_REC_601;
+ }
+ }
+
+ public int getPreferredColorRange() {
+ if (prefs.fullRange) {
+ return MoonBridge.COLOR_RANGE_FULL;
+ }
+ else {
+ return MoonBridge.COLOR_RANGE_LIMITED;
+ }
+ }
+
+ public void notifyVideoForeground() {
+ foreground = true;
+ }
+
+ public void notifyVideoBackground() {
+ foreground = false;
+ }
+
+ public int getActiveVideoFormat() {
+ return this.videoFormat;
+ }
+
+ private MediaFormat createBaseMediaFormat(String mimeType) {
+ MediaFormat videoFormat = MediaFormat.createVideoFormat(mimeType, initialWidth, initialHeight);
+
+ // Avoid setting KEY_FRAME_RATE on Lollipop and earlier to reduce compatibility risk
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ videoFormat.setInteger(MediaFormat.KEY_FRAME_RATE, refreshRate);
+ }
+
+ // Populate keys for adaptive playback
+ if (adaptivePlayback) {
+ videoFormat.setInteger(MediaFormat.KEY_MAX_WIDTH, initialWidth);
+ videoFormat.setInteger(MediaFormat.KEY_MAX_HEIGHT, initialHeight);
+ }
+
+ // Android 7.0 adds color options to the MediaFormat
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ videoFormat.setInteger(MediaFormat.KEY_COLOR_RANGE,
+ getPreferredColorRange() == MoonBridge.COLOR_RANGE_FULL ?
+ MediaFormat.COLOR_RANGE_FULL : MediaFormat.COLOR_RANGE_LIMITED);
+
+ // If the stream is HDR-capable, the decoder will detect transitions in color standards
+ // rather than us hardcoding them into the MediaFormat.
+ if ((getActiveVideoFormat() & MoonBridge.VIDEO_FORMAT_MASK_10BIT) == 0) {
+ // Set color format keys when not in HDR mode, since we know they won't change
+ videoFormat.setInteger(MediaFormat.KEY_COLOR_TRANSFER, MediaFormat.COLOR_TRANSFER_SDR_VIDEO);
+ switch (getPreferredColorSpace()) {
+ case MoonBridge.COLORSPACE_REC_601:
+ videoFormat.setInteger(MediaFormat.KEY_COLOR_STANDARD, MediaFormat.COLOR_STANDARD_BT601_NTSC);
+ break;
+ case MoonBridge.COLORSPACE_REC_709:
+ videoFormat.setInteger(MediaFormat.KEY_COLOR_STANDARD, MediaFormat.COLOR_STANDARD_BT709);
+ break;
+ case MoonBridge.COLORSPACE_REC_2020:
+ videoFormat.setInteger(MediaFormat.KEY_COLOR_STANDARD, MediaFormat.COLOR_STANDARD_BT2020);
+ break;
+ }
+ }
+ }
+ return videoFormat;
+ }
+
+ private void configureAndStartDecoder(MediaFormat format) {
+ // Set HDR metadata if present
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ if (currentHdrMetadata != null) {
+ ByteBuffer hdrStaticInfo = ByteBuffer.allocate(25).order(ByteOrder.LITTLE_ENDIAN);
+ ByteBuffer hdrMetadata = ByteBuffer.wrap(currentHdrMetadata).order(ByteOrder.LITTLE_ENDIAN);
+
+ // Create a HDMI Dynamic Range and Mastering InfoFrame as defined by CTA-861.3
+ hdrStaticInfo.put((byte) 0); // Metadata type
+ hdrStaticInfo.putShort(hdrMetadata.getShort()); // RX
+ hdrStaticInfo.putShort(hdrMetadata.getShort()); // RY
+ hdrStaticInfo.putShort(hdrMetadata.getShort()); // GX
+ hdrStaticInfo.putShort(hdrMetadata.getShort()); // GY
+ hdrStaticInfo.putShort(hdrMetadata.getShort()); // BX
+ hdrStaticInfo.putShort(hdrMetadata.getShort()); // BY
+ hdrStaticInfo.putShort(hdrMetadata.getShort()); // White X
+ hdrStaticInfo.putShort(hdrMetadata.getShort()); // White Y
+ hdrStaticInfo.putShort(hdrMetadata.getShort()); // Max mastering luminance
+ hdrStaticInfo.putShort(hdrMetadata.getShort()); // Min mastering luminance
+ hdrStaticInfo.putShort(hdrMetadata.getShort()); // Max content luminance
+ hdrStaticInfo.putShort(hdrMetadata.getShort()); // Max frame average luminance
+
+ hdrStaticInfo.rewind();
+ format.setByteBuffer(MediaFormat.KEY_HDR_STATIC_INFO, hdrStaticInfo);
+ }
+ else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ format.removeKey(MediaFormat.KEY_HDR_STATIC_INFO);
+ }
+ }
+
+ LimeLog.info("Configuring with format: "+format);
+
+ videoDecoder.configure(format, renderTarget, null, 0);
+
+ configuredFormat = format;
+
+ // After reconfiguration, we must resubmit CSD buffers
+ submittedCsd = false;
+ vpsBuffers.clear();
+ spsBuffers.clear();
+ ppsBuffers.clear();
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ // This will contain the actual accepted input format attributes
+ inputFormat = videoDecoder.getInputFormat();
+ LimeLog.info("Input format: "+inputFormat);
+ }
+
+ videoDecoder.setVideoScalingMode(MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT);
+
+ // Start the decoder
+ videoDecoder.start();
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
+ legacyInputBuffers = videoDecoder.getInputBuffers();
+ }
+ }
+
+ private boolean tryConfigureDecoder(MediaCodecInfo selectedDecoderInfo, MediaFormat format, boolean throwOnCodecError) {
+ boolean configured = false;
+ try {
+ videoDecoder = MediaCodec.createByCodecName(selectedDecoderInfo.getName());
+ configureAndStartDecoder(format);
+ LimeLog.info("Using codec " + selectedDecoderInfo.getName() + " for hardware decoding " + format.getString(MediaFormat.KEY_MIME));
+ configured = true;
+ } catch (IllegalArgumentException e) {
+ e.printStackTrace();
+ if (throwOnCodecError) {
+ throw e;
+ }
+ } catch (IllegalStateException e) {
+ e.printStackTrace();
+ if (throwOnCodecError) {
+ throw e;
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ if (throwOnCodecError) {
+ throw new RuntimeException(e);
+ }
+ } finally {
+ if (!configured && videoDecoder != null) {
+ videoDecoder.release();
+ videoDecoder = null;
+ }
+ }
+ return configured;
+ }
+
+ public int initializeDecoder(boolean throwOnCodecError) {
+ String mimeType;
+ MediaCodecInfo selectedDecoderInfo;
+
+ if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H264) != 0) {
+ mimeType = "video/avc";
+ selectedDecoderInfo = avcDecoder;
+
+ if (avcDecoder == null) {
+ LimeLog.severe("No available AVC decoder!");
+ return -1;
+ }
+
+ if (initialWidth > 4096 || initialHeight > 4096) {
+ LimeLog.severe("> 4K streaming only supported on HEVC");
+ return -1;
+ }
+
+ // These fixups only apply to H264 decoders
+ needsSpsBitstreamFixup = MediaCodecHelper.decoderNeedsSpsBitstreamRestrictions(selectedDecoderInfo.getName());
+ needsBaselineSpsHack = MediaCodecHelper.decoderNeedsBaselineSpsHack(selectedDecoderInfo.getName());
+ constrainedHighProfile = MediaCodecHelper.decoderNeedsConstrainedHighProfile(selectedDecoderInfo.getName());
+ isExynos4 = MediaCodecHelper.isExynos4Device();
+ if (needsSpsBitstreamFixup) {
+ LimeLog.info("Decoder "+selectedDecoderInfo.getName()+" needs SPS bitstream restrictions fixup");
+ }
+ if (needsBaselineSpsHack) {
+ LimeLog.info("Decoder "+selectedDecoderInfo.getName()+" needs baseline SPS hack");
+ }
+ if (constrainedHighProfile) {
+ LimeLog.info("Decoder "+selectedDecoderInfo.getName()+" needs constrained high profile");
+ }
+ if (isExynos4) {
+ LimeLog.info("Decoder "+selectedDecoderInfo.getName()+" is on Exynos 4");
+ }
+
+ refFrameInvalidationActive = refFrameInvalidationAvc;
+ }
+ else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H265) != 0) {
+ mimeType = "video/hevc";
+ selectedDecoderInfo = hevcDecoder;
+
+ if (hevcDecoder == null) {
+ LimeLog.severe("No available HEVC decoder!");
+ return -2;
+ }
+
+ refFrameInvalidationActive = refFrameInvalidationHevc;
+ }
+ else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_AV1) != 0) {
+ mimeType = "video/av01";
+ selectedDecoderInfo = av1Decoder;
+
+ if (av1Decoder == null) {
+ LimeLog.severe("No available AV1 decoder!");
+ return -2;
+ }
+
+ refFrameInvalidationActive = refFrameInvalidationAv1;
+ }
+ else {
+ // Unknown format
+ LimeLog.severe("Unknown format");
+ return -3;
+ }
+ adaptivePlayback = MediaCodecHelper.decoderSupportsAdaptivePlayback(selectedDecoderInfo, mimeType);
+ fusedIdrFrame = MediaCodecHelper.decoderSupportsFusedIdrFrame(selectedDecoderInfo, mimeType);
+
+ for (int tryNumber = 0;; tryNumber++) {
+ LimeLog.info("Decoder configuration try: "+tryNumber);
+
+ MediaFormat mediaFormat = createBaseMediaFormat(mimeType);
+ // This will try low latency options until we find one that works (or we give up).
+ boolean newFormat = MediaCodecHelper.setDecoderLowLatencyOptions(mediaFormat, selectedDecoderInfo, prefs.enableUltraLowLatency, tryNumber);
+ //todo 色彩格式
+// MediaCodecInfo.CodecCapabilities codecCapabilities = selectedDecoderInfo.getCapabilitiesForType(mimeType);
+// int[] colorFormats=codecCapabilities.colorFormats;
+// for (int colorFormat : colorFormats) {
+// LimeLog.info("Decoder configuration colorFormats: "+colorFormat);
+// }
+ // Throw the underlying codec exception on the last attempt if the caller requested it
+ if (tryConfigureDecoder(selectedDecoderInfo, mediaFormat, !newFormat && throwOnCodecError)) {
+ // Success!
+ break;
+ }
+
+ if (!newFormat) {
+ // We couldn't even configure a decoder without any low latency options
+ return -5;
+ }
+ }
+
+ if (USE_FRAME_RENDER_TIME && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ videoDecoder.setOnFrameRenderedListener(new MediaCodec.OnFrameRenderedListener() {
+ @Override
+ public void onFrameRendered(MediaCodec mediaCodec, long presentationTimeUs, long renderTimeNanos) {
+ long delta = (renderTimeNanos / 1000000L) - (presentationTimeUs / 1000);
+ if (delta >= 0 && delta < 1000) {
+ if (USE_FRAME_RENDER_TIME) {
+ activeWindowVideoStats.totalTimeMs += delta;
+ }
+ }
+ }
+ }, null);
+ }
+
+ return 0;
+ }
+
+ @Override
+ public int setup(int format, int width, int height, int redrawRate) {
+ this.initialWidth = invertResolution ? height : width;
+ this.initialHeight = invertResolution ? width : height;
+ this.videoFormat = format;
+ this.refreshRate = redrawRate;
+
+ return initializeDecoder(false);
+ }
+
+ // All threads that interact with the MediaCodec instance must call this function regularly!
+ private boolean doCodecRecoveryIfRequired(int quiescenceFlag) {
+ // NB: We cannot check 'stopping' here because we could end up bailing in a partially
+ // quiesced state that will cause the quiesced threads to never wake up.
+ if (codecRecoveryType.get() == CR_RECOVERY_TYPE_NONE) {
+ // Common case
+ return false;
+ }
+
+ // We need some sort of recovery, so quiesce all threads before starting that
+ synchronized (codecRecoveryMonitor) {
+ if (choreographerHandlerThread == null) {
+ // If we have no choreographer thread, we can just mark that as quiesced right now.
+ codecRecoveryThreadQuiescedFlags |= CR_FLAG_CHOREOGRAPHER;
+ }
+
+ codecRecoveryThreadQuiescedFlags |= quiescenceFlag;
+
+ // This is the final thread to quiesce, so let's perform the codec recovery now.
+ if (codecRecoveryThreadQuiescedFlags == CR_FLAG_ALL) {
+ // Input and output buffers are invalidated by stop() and reset().
+ nextInputBuffer = null;
+ nextInputBufferIndex = -1;
+ outputBufferQueue.clear();
+
+ // If we just need a flush, do so now with all threads quiesced.
+ if (codecRecoveryType.get() == CR_RECOVERY_TYPE_FLUSH) {
+ LimeLog.warning("Flushing decoder");
+ try {
+ videoDecoder.flush();
+ codecRecoveryType.set(CR_RECOVERY_TYPE_NONE);
+ } catch (IllegalStateException e) {
+ e.printStackTrace();
+
+ // Something went wrong during the restart, let's use a bigger hammer
+ // and try a reset instead.
+ codecRecoveryType.set(CR_RECOVERY_TYPE_RESTART);
+ }
+ }
+
+ // We don't count flushes as codec recovery attempts
+ if (codecRecoveryType.get() != CR_RECOVERY_TYPE_NONE) {
+ codecRecoveryAttempts++;
+ LimeLog.info("Codec recovery attempt: "+codecRecoveryAttempts);
+ }
+
+ // For "recoverable" exceptions, we can just stop, reconfigure, and restart.
+ if (codecRecoveryType.get() == CR_RECOVERY_TYPE_RESTART) {
+ LimeLog.warning("Trying to restart decoder after CodecException");
+ try {
+ videoDecoder.stop();
+ configureAndStartDecoder(configuredFormat);
+ codecRecoveryType.set(CR_RECOVERY_TYPE_NONE);
+ } catch (IllegalArgumentException e) {
+ e.printStackTrace();
+
+ // Our Surface is probably invalid, so just stop
+ stopping = true;
+ codecRecoveryType.set(CR_RECOVERY_TYPE_NONE);
+ } catch (IllegalStateException e) {
+ e.printStackTrace();
+
+ // Something went wrong during the restart, let's use a bigger hammer
+ // and try a reset instead.
+ codecRecoveryType.set(CR_RECOVERY_TYPE_RESET);
+ }
+ }
+
+ // For "non-recoverable" exceptions on L+, we can call reset() to recover
+ // without having to recreate the entire decoder again.
+ if (codecRecoveryType.get() == CR_RECOVERY_TYPE_RESET && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ LimeLog.warning("Trying to reset decoder after CodecException");
+ try {
+ videoDecoder.reset();
+ configureAndStartDecoder(configuredFormat);
+ codecRecoveryType.set(CR_RECOVERY_TYPE_NONE);
+ } catch (IllegalArgumentException e) {
+ e.printStackTrace();
+
+ // Our Surface is probably invalid, so just stop
+ stopping = true;
+ codecRecoveryType.set(CR_RECOVERY_TYPE_NONE);
+ } catch (IllegalStateException e) {
+ e.printStackTrace();
+
+ // Something went wrong during the reset, we'll have to resort to
+ // releasing and recreating the decoder now.
+ }
+ }
+
+ // If we _still_ haven't managed to recover, go for the nuclear option and just
+ // throw away the old decoder and reinitialize a new one from scratch.
+ if (codecRecoveryType.get() == CR_RECOVERY_TYPE_RESET) {
+ LimeLog.warning("Trying to recreate decoder after CodecException");
+ videoDecoder.release();
+
+ try {
+ int err = initializeDecoder(true);
+ if (err != 0) {
+ throw new IllegalStateException("Decoder reset failed: " + err);
+ }
+ codecRecoveryType.set(CR_RECOVERY_TYPE_NONE);
+ } catch (IllegalArgumentException e) {
+ e.printStackTrace();
+
+ // Our Surface is probably invalid, so just stop
+ stopping = true;
+ codecRecoveryType.set(CR_RECOVERY_TYPE_NONE);
+ } catch (IllegalStateException e) {
+ // If we failed to recover after all of these attempts, just crash
+ if (!reportedCrash) {
+ reportedCrash = true;
+ crashListener.notifyCrash(e);
+ }
+ throw new RendererException(this, e);
+ }
+ }
+
+ // Wake all quiesced threads and allow them to begin work again
+ codecRecoveryThreadQuiescedFlags = 0;
+ codecRecoveryMonitor.notifyAll();
+ }
+ else {
+ // If we haven't quiesced all threads yet, wait to be signalled after recovery.
+ // The final thread to be quiesced will handle the codec recovery.
+ while (codecRecoveryType.get() != CR_RECOVERY_TYPE_NONE) {
+ try {
+ LimeLog.info("Waiting to quiesce decoder threads: "+codecRecoveryThreadQuiescedFlags);
+ codecRecoveryMonitor.wait(1000);
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+
+ // InterruptedException clears the thread's interrupt status. Since we can't
+ // handle that here, we will re-interrupt the thread to set the interrupt
+ // status back to true.
+ Thread.currentThread().interrupt();
+
+ break;
+ }
+ }
+ }
+ }
+
+ return true;
+ }
+
+ // Returns true if the exception is transient
+ private boolean handleDecoderException(IllegalStateException e) {
+ // Eat decoder exceptions if we're in the process of stopping
+ if (stopping) {
+ return false;
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && e instanceof CodecException) {
+ CodecException codecExc = (CodecException) e;
+
+ if (codecExc.isTransient()) {
+ // We'll let transient exceptions go
+ LimeLog.warning(codecExc.getDiagnosticInfo());
+ return true;
+ }
+
+ LimeLog.severe(codecExc.getDiagnosticInfo());
+
+ // We can attempt a recovery or reset at this stage to try to start decoding again
+ if (codecRecoveryAttempts < CR_MAX_TRIES) {
+ // If the exception is non-recoverable or we already require a reset, perform a reset.
+ // If we have no prior unrecoverable failure, we will try a restart instead.
+ if (codecExc.isRecoverable()) {
+ if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_NONE, CR_RECOVERY_TYPE_RESTART)) {
+ LimeLog.info("Decoder requires restart for recoverable CodecException");
+ e.printStackTrace();
+ }
+ else if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_FLUSH, CR_RECOVERY_TYPE_RESTART)) {
+ LimeLog.info("Decoder flush promoted to restart for recoverable CodecException");
+ e.printStackTrace();
+ }
+ else if (codecRecoveryType.get() != CR_RECOVERY_TYPE_RESET && codecRecoveryType.get() != CR_RECOVERY_TYPE_RESTART) {
+ throw new IllegalStateException("Unexpected codec recovery type: " + codecRecoveryType.get());
+ }
+ }
+ else if (!codecExc.isRecoverable()) {
+ if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_NONE, CR_RECOVERY_TYPE_RESET)) {
+ LimeLog.info("Decoder requires reset for non-recoverable CodecException");
+ e.printStackTrace();
+ }
+ else if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_FLUSH, CR_RECOVERY_TYPE_RESET)) {
+ LimeLog.info("Decoder flush promoted to reset for non-recoverable CodecException");
+ e.printStackTrace();
+ }
+ else if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_RESTART, CR_RECOVERY_TYPE_RESET)) {
+ LimeLog.info("Decoder restart promoted to reset for non-recoverable CodecException");
+ e.printStackTrace();
+ }
+ else if (codecRecoveryType.get() != CR_RECOVERY_TYPE_RESET) {
+ throw new IllegalStateException("Unexpected codec recovery type: " + codecRecoveryType.get());
+ }
+ }
+
+ // The recovery will take place when all threads reach doCodecRecoveryIfRequired().
+ return false;
+ }
+ }
+ else {
+ // IllegalStateException was primarily used prior to the introduction of CodecException.
+ // Recovery from this requires a full decoder reset.
+ //
+ // NB: CodecException is an IllegalStateException, so we must check for it first.
+ if (codecRecoveryAttempts < CR_MAX_TRIES) {
+ if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_NONE, CR_RECOVERY_TYPE_RESET)) {
+ LimeLog.info("Decoder requires reset for IllegalStateException");
+ e.printStackTrace();
+ }
+ else if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_FLUSH, CR_RECOVERY_TYPE_RESET)) {
+ LimeLog.info("Decoder flush promoted to reset for IllegalStateException");
+ e.printStackTrace();
+ }
+ else if (codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_RESTART, CR_RECOVERY_TYPE_RESET)) {
+ LimeLog.info("Decoder restart promoted to reset for IllegalStateException");
+ e.printStackTrace();
+ }
+ else if (codecRecoveryType.get() != CR_RECOVERY_TYPE_RESET) {
+ throw new IllegalStateException("Unexpected codec recovery type: " + codecRecoveryType.get());
+ }
+
+ return false;
+ }
+ }
+
+ // Only throw if we're not in the middle of codec recovery
+ if (codecRecoveryType.get() == CR_RECOVERY_TYPE_NONE) {
+ //
+ // There seems to be a race condition with decoder/surface teardown causing some
+ // decoders to to throw IllegalStateExceptions even before 'stopping' is set.
+ // To workaround this while allowing real exceptions to propagate, we will eat the
+ // first exception. If we are still receiving exceptions 3 seconds later, we will
+ // throw the original exception again.
+ //
+ if (initialException != null) {
+ // This isn't the first time we've had an exception processing video
+ if (SystemClock.uptimeMillis() - initialExceptionTimestamp >= EXCEPTION_REPORT_DELAY_MS) {
+ // It's been over 3 seconds and we're still getting exceptions. Throw the original now.
+ if (!reportedCrash) {
+ reportedCrash = true;
+ crashListener.notifyCrash(initialException);
+ }
+ throw initialException;
+ }
+ }
+ else {
+ // This is the first exception we've hit
+ initialException = new RendererException(this, e);
+ initialExceptionTimestamp = SystemClock.uptimeMillis();
+ }
+ }
+
+ // Not transient
+ return false;
+ }
+
+ @Override
+ public void doFrame(long frameTimeNanos) {
+ // Do nothing if we're stopping
+ if (stopping) {
+ return;
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ frameTimeNanos -= activity.getWindowManager().getDefaultDisplay().getAppVsyncOffsetNanos();
+ }
+
+ // Don't render unless a new frame is due. This prevents microstutter when streaming
+ // at a frame rate that doesn't match the display (such as 60 FPS on 120 Hz).
+ long actualFrameTimeDeltaNs = frameTimeNanos - lastRenderedFrameTimeNanos;
+ long expectedFrameTimeDeltaNs = 800000000 / refreshRate; // within 80% of the next frame
+ if (actualFrameTimeDeltaNs >= expectedFrameTimeDeltaNs) {
+ // Render up to one frame when in frame pacing mode.
+ //
+ // NB: Since the queue limit is 2, we won't starve the decoder of output buffers
+ // by holding onto them for too long. This also ensures we will have that 1 extra
+ // frame of buffer to smooth over network/rendering jitter.
+ Integer nextOutputBuffer = outputBufferQueue.poll();
+ if (nextOutputBuffer != null) {
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ videoDecoder.releaseOutputBuffer(nextOutputBuffer, frameTimeNanos);
+ }
+ else {
+ videoDecoder.releaseOutputBuffer(nextOutputBuffer, true);
+ }
+
+ lastRenderedFrameTimeNanos = frameTimeNanos;
+ activeWindowVideoStats.totalFramesRendered++;
+ } catch (IllegalStateException ignored) {
+ try {
+ // Try to avoid leaking the output buffer by releasing it without rendering
+ videoDecoder.releaseOutputBuffer(nextOutputBuffer, false);
+ } catch (IllegalStateException e) {
+ // This will leak nextOutputBuffer, but there's really nothing else we can do
+ e.printStackTrace();
+ handleDecoderException(e);
+ }
+ }
+ }
+ }
+
+ // Attempt codec recovery even if we have nothing to render right now. Recovery can still
+ // be required even if the codec died before giving any output.
+ doCodecRecoveryIfRequired(CR_FLAG_CHOREOGRAPHER);
+
+ // Request another callback for next frame
+ Choreographer.getInstance().postFrameCallback(this);
+ }
+
+ private void startChoreographerThread() {
+ if (prefs.framePacing != PreferenceConfiguration.FRAME_PACING_BALANCED) {
+ // Not using Choreographer in this pacing mode
+ return;
+ }
+
+ // We use a separate thread to avoid any main thread delays from delaying rendering
+ choreographerHandlerThread = new HandlerThread("Video - Choreographer", Process.THREAD_PRIORITY_DEFAULT + Process.THREAD_PRIORITY_MORE_FAVORABLE);
+ choreographerHandlerThread.start();
+
+ // Start the frame callbacks
+ choreographerHandler = new Handler(choreographerHandlerThread.getLooper());
+ choreographerHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ Choreographer.getInstance().postFrameCallback(MediaCodecDecoderRenderer.this);
+ }
+ });
+ }
+
+ private void startRendererThread()
+ {
+ rendererThread = new Thread() {
+ @Override
+ public void run() {
+ BufferInfo info = new BufferInfo();
+ while (!stopping) {
+ try {
+ // Try to output a frame
+ int outIndex = videoDecoder.dequeueOutputBuffer(info, 50000);
+ if (outIndex >= 0) {
+ long presentationTimeUs = info.presentationTimeUs;
+ int lastIndex = outIndex;
+
+ numFramesOut++;
+
+ // Render the latest frame now if frame pacing isn't in balanced mode
+ if (prefs.framePacing != PreferenceConfiguration.FRAME_PACING_BALANCED) {
+ // Get the last output buffer in the queue
+ while ((outIndex = videoDecoder.dequeueOutputBuffer(info, 0)) >= 0) {
+ videoDecoder.releaseOutputBuffer(lastIndex, false);
+
+ numFramesOut++;
+
+ lastIndex = outIndex;
+ presentationTimeUs = info.presentationTimeUs;
+ }
+
+ if (prefs.framePacing == PreferenceConfiguration.FRAME_PACING_MAX_SMOOTHNESS ||
+ prefs.framePacing == PreferenceConfiguration.FRAME_PACING_CAP_FPS) {
+ // In max smoothness or cap FPS mode, we want to never drop frames
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ // Use a PTS that will cause this frame to never be dropped
+ videoDecoder.releaseOutputBuffer(lastIndex, 0);
+ }
+ else {
+ videoDecoder.releaseOutputBuffer(lastIndex, true);
+ }
+ }
+ else {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ // Use a PTS that will cause this frame to be dropped if another comes in within
+ // the same V-sync period
+ videoDecoder.releaseOutputBuffer(lastIndex, System.nanoTime());
+ }
+ else {
+ videoDecoder.releaseOutputBuffer(lastIndex, true);
+ }
+ }
+
+ activeWindowVideoStats.totalFramesRendered++;
+ }
+ else {
+ // For balanced frame pacing case, the Choreographer callback will handle rendering.
+ // We just put all frames into the output buffer queue and let it handle things.
+
+ // Discard the oldest buffer if we've exceeded our limit.
+ //
+ // NB: We have to do this on the producer side because the consumer may not
+ // run for a while (if there is a huge mismatch between stream FPS and display
+ // refresh rate).
+ if (outputBufferQueue.size() == OUTPUT_BUFFER_QUEUE_LIMIT) {
+ try {
+ videoDecoder.releaseOutputBuffer(outputBufferQueue.take(), false);
+ } catch (InterruptedException e) {
+ // We're shutting down, so we can just drop this buffer on the floor
+ // and it will be reclaimed when the codec is released.
+ return;
+ }
+ }
+
+ // Add this buffer
+ outputBufferQueue.add(lastIndex);
+ }
+
+ // Add delta time to the totals (excluding probable outliers)
+ long delta = SystemClock.uptimeMillis() - (presentationTimeUs / 1000);
+ if (delta >= 0 && delta < 1000) {
+ activeWindowVideoStats.decoderTimeMs += delta;
+ if (!USE_FRAME_RENDER_TIME) {
+ activeWindowVideoStats.totalTimeMs += delta;
+ }
+ }
+ } else {
+ switch (outIndex) {
+ case MediaCodec.INFO_TRY_AGAIN_LATER:
+ break;
+ case MediaCodec.INFO_OUTPUT_FORMAT_CHANGED:
+ LimeLog.info("Output format changed");
+ outputFormat = videoDecoder.getOutputFormat();
+ LimeLog.info("New output format: " + outputFormat);
+ break;
+ default:
+ break;
+ }
+ }
+ } catch (IllegalStateException e) {
+ handleDecoderException(e);
+ } finally {
+ doCodecRecoveryIfRequired(CR_FLAG_RENDER_THREAD);
+ }
+ }
+ }
+ };
+ rendererThread.setName("Video - Renderer (MediaCodec)");
+ rendererThread.setPriority(Thread.NORM_PRIORITY + 2);
+ rendererThread.start();
+ }
+
+ private boolean fetchNextInputBuffer() {
+ long startTime;
+ boolean codecRecovered;
+
+ if (nextInputBuffer != null) {
+ // We already have an input buffer
+ return true;
+ }
+
+ startTime = SystemClock.uptimeMillis();
+
+ try {
+ // If we don't have an input buffer index yet, fetch one now
+ while (nextInputBufferIndex < 0 && !stopping) {
+ nextInputBufferIndex = videoDecoder.dequeueInputBuffer(10000);
+ }
+
+ // Get the backing ByteBuffer for the input buffer index
+ if (nextInputBufferIndex >= 0) {
+ // Using the new getInputBuffer() API on Lollipop allows
+ // the framework to do some performance optimizations for us
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ nextInputBuffer = videoDecoder.getInputBuffer(nextInputBufferIndex);
+ if (nextInputBuffer == null) {
+ // According to the Android docs, getInputBuffer() can return null "if the
+ // index is not a dequeued input buffer". I don't think this ever should
+ // happen but if it does, let's try to get a new input buffer next time.
+ nextInputBufferIndex = -1;
+ }
+ }
+ else {
+ nextInputBuffer = legacyInputBuffers[nextInputBufferIndex];
+
+ // Clear old input data pre-Lollipop
+ nextInputBuffer.clear();
+ }
+ }
+ } catch (IllegalStateException e) {
+ handleDecoderException(e);
+ return false;
+ } finally {
+ codecRecovered = doCodecRecoveryIfRequired(CR_FLAG_INPUT_THREAD);
+ }
+
+ // If codec recovery is required, always return false to ensure the caller will request
+ // an IDR frame to complete the codec recovery.
+ if (codecRecovered) {
+ return false;
+ }
+
+ int deltaMs = (int)(SystemClock.uptimeMillis() - startTime);
+
+ if (deltaMs >= 20) {
+ LimeLog.warning("Dequeue input buffer ran long: " + deltaMs + " ms");
+ }
+
+ if (nextInputBuffer == null) {
+ // We've been hung for 5 seconds and no other exception was reported,
+ // so generate a decoder hung exception
+ if (deltaMs >= 5000 && initialException == null) {
+ DecoderHungException decoderHungException = new DecoderHungException(deltaMs);
+ if (!reportedCrash) {
+ reportedCrash = true;
+ crashListener.notifyCrash(decoderHungException);
+ }
+ throw new RendererException(this, decoderHungException);
+ }
+
+ return false;
+ }
+
+ return true;
+ }
+
+ @Override
+ public void start() {
+ startRendererThread();
+ startChoreographerThread();
+ }
+
+ // !!! May be called even if setup()/start() fails !!!
+ public void prepareForStop() {
+ // Let the decoding code know to ignore codec exceptions now
+ stopping = true;
+
+ // Halt the rendering thread
+ if (rendererThread != null) {
+ rendererThread.interrupt();
+ }
+
+ // Stop any active codec recovery operations
+ synchronized (codecRecoveryMonitor) {
+ codecRecoveryType.set(CR_RECOVERY_TYPE_NONE);
+ codecRecoveryMonitor.notifyAll();
+ }
+
+ // Post a quit message to the Choreographer looper (if we have one)
+ if (choreographerHandler != null) {
+ choreographerHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ // Don't allow any further messages to be queued
+ choreographerHandlerThread.quit();
+
+ // Deregister the frame callback (if registered)
+ Choreographer.getInstance().removeFrameCallback(MediaCodecDecoderRenderer.this);
+ }
+ });
+ }
+ }
+
+ @Override
+ public void stop() {
+ // May be called already, but we'll call it now to be safe
+ prepareForStop();
+
+ // Wait for the Choreographer looper to shut down (if we have one)
+ if (choreographerHandlerThread != null) {
+ try {
+ choreographerHandlerThread.join();
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+
+ // InterruptedException clears the thread's interrupt status. Since we can't
+ // handle that here, we will re-interrupt the thread to set the interrupt
+ // status back to true.
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ // Wait for the renderer thread to shut down
+ try {
+ rendererThread.join();
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+
+ // InterruptedException clears the thread's interrupt status. Since we can't
+ // handle that here, we will re-interrupt the thread to set the interrupt
+ // status back to true.
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ @Override
+ public void cleanup() {
+ videoDecoder.release();
+ }
+
+ @Override
+ public void setHdrMode(boolean enabled, byte[] hdrMetadata) {
+ // HDR metadata is only supported in Android 7.0 and later, so don't bother
+ // restarting the codec on anything earlier than that.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ if (currentHdrMetadata != null && (!enabled || hdrMetadata == null)) {
+ currentHdrMetadata = null;
+ }
+ else if (enabled && hdrMetadata != null && !Arrays.equals(currentHdrMetadata, hdrMetadata)) {
+ currentHdrMetadata = hdrMetadata;
+ }
+ else {
+ // Nothing to do
+ return;
+ }
+
+ // If we reach this point, we need to restart the MediaCodec instance to
+ // pick up the HDR metadata change. This will happen on the next input
+ // or output buffer.
+
+ // HACK: Reset codec recovery attempt counter, since this is an expected "recovery"
+ codecRecoveryAttempts = 0;
+
+ // Promote None/Flush to Restart and leave Reset alone
+ if (!codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_NONE, CR_RECOVERY_TYPE_RESTART)) {
+ codecRecoveryType.compareAndSet(CR_RECOVERY_TYPE_FLUSH, CR_RECOVERY_TYPE_RESTART);
+ }
+ }
+ }
+
+ private boolean queueNextInputBuffer(long timestampUs, int codecFlags) {
+ boolean codecRecovered;
+
+ try {
+ videoDecoder.queueInputBuffer(nextInputBufferIndex,
+ 0, nextInputBuffer.position(),
+ timestampUs, codecFlags);
+
+ // We need a new buffer now
+ nextInputBufferIndex = -1;
+ nextInputBuffer = null;
+ } catch (IllegalStateException e) {
+ if (handleDecoderException(e)) {
+ // We encountered a transient error. In this case, just hold onto the buffer
+ // (to avoid leaking it), clear it, and keep it for the next frame. We'll return
+ // false to trigger an IDR frame to recover.
+ nextInputBuffer.clear();
+ }
+ else {
+ // We encountered a non-transient error. In this case, we will simply leak the
+ // buffer because we cannot be sure we will ever succeed in queuing it.
+ nextInputBufferIndex = -1;
+ nextInputBuffer = null;
+ }
+ return false;
+ } finally {
+ codecRecovered = doCodecRecoveryIfRequired(CR_FLAG_INPUT_THREAD);
+ }
+
+ // If codec recovery is required, always return false to ensure the caller will request
+ // an IDR frame to complete the codec recovery.
+ if (codecRecovered) {
+ return false;
+ }
+
+ // Fetch a new input buffer now while we have some time between frames
+ // to have it ready immediately when the next frame arrives.
+ //
+ // We must propagate the return value here in order to properly handle
+ // codec recovery happening in fetchNextInputBuffer(). If we don't, we'll
+ // never get an IDR frame to complete the recovery process.
+ return fetchNextInputBuffer();
+ }
+
+ private void doProfileSpecificSpsPatching(SeqParameterSet sps) {
+ // Some devices benefit from setting constraint flags 4 & 5 to make this Constrained
+ // High Profile which allows the decoder to assume there will be no B-frames and
+ // reduce delay and buffering accordingly. Some devices (Marvell, Exynos 4) don't
+ // like it so we only set them on devices that are confirmed to benefit from it.
+ if (sps.profileIdc == 100 && constrainedHighProfile) {
+ LimeLog.info("Setting constraint set flags for constrained high profile");
+ sps.constraintSet4Flag = true;
+ sps.constraintSet5Flag = true;
+ }
+ else {
+ // Force the constraints unset otherwise (some may be set by default)
+ sps.constraintSet4Flag = false;
+ sps.constraintSet5Flag = false;
+ }
+ }
+
+ @SuppressWarnings("deprecation")
+ @Override
+ public int submitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength, int decodeUnitType,
+ int frameNumber, int frameType, char frameHostProcessingLatency,
+ long receiveTimeMs, long enqueueTimeMs) {
+ if (stopping) {
+ // Don't bother if we're stopping
+ return MoonBridge.DR_OK;
+ }
+
+ if (lastFrameNumber == 0) {
+ activeWindowVideoStats.measurementStartTimestamp = SystemClock.uptimeMillis();
+ } else if (frameNumber != lastFrameNumber && frameNumber != lastFrameNumber + 1) {
+ // We can receive the same "frame" multiple times if it's an IDR frame.
+ // In that case, each frame start NALU is submitted independently.
+ activeWindowVideoStats.framesLost += frameNumber - lastFrameNumber - 1;
+ activeWindowVideoStats.totalFrames += frameNumber - lastFrameNumber - 1;
+ activeWindowVideoStats.frameLossEvents++;
+ }
+
+ // Reset CSD data for each IDR frame
+ if (lastFrameNumber != frameNumber && frameType == MoonBridge.FRAME_TYPE_IDR) {
+ vpsBuffers.clear();
+ spsBuffers.clear();
+ ppsBuffers.clear();
+ }
+
+ lastFrameNumber = frameNumber;
+
+ // Flip stats windows roughly every second
+ if (SystemClock.uptimeMillis() >= activeWindowVideoStats.measurementStartTimestamp + 1000) {
+ if (prefs.enablePerfOverlay) {
+ VideoStats lastTwo = new VideoStats();
+ lastTwo.add(lastWindowVideoStats);
+ lastTwo.add(activeWindowVideoStats);
+ VideoStatsFps fps = lastTwo.getFps();
+ String decoder;
+
+ if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H264) != 0) {
+ decoder = avcDecoder.getName();
+ } else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H265) != 0) {
+ decoder = hevcDecoder.getName();
+ } else if ((videoFormat & MoonBridge.VIDEO_FORMAT_MASK_AV1) != 0) {
+ decoder = av1Decoder.getName();
+ } else {
+ decoder = "(unknown)";
+ }
+
+ float decodeTimeMs = (float)lastTwo.decoderTimeMs / lastTwo.totalFramesReceived;
+ long rttInfo = MoonBridge.getEstimatedRttInfo();
+ StringBuilder sb = new StringBuilder();
+ if(prefs.enablePerfOverlayLite){
+ if(TrafficStatsHelper.getPackageRxBytes(Process.myUid()) != TrafficStats.UNSUPPORTED){
+ long netData=TrafficStatsHelper.getPackageRxBytes(Process.myUid())+TrafficStatsHelper.getPackageTxBytes(Process.myUid());
+ if(lastNetDataNum!=0){
+ sb.append(context.getString(R.string.perf_overlay_lite_bandwidth) + ": ");
+ float realtimeNetData=(netData-lastNetDataNum)/1024f;
+ if(realtimeNetData>=1000){
+ sb.append(String.format("%.2f", realtimeNetData/1024f) +"M/s\t ");
+ }else{
+ sb.append(String.format("%.2f", realtimeNetData) +"K/s\t ");
+ }
+ }
+ lastNetDataNum=netData;
+ }
+// sb.append("分辨率:");
+// sb.append(initialWidth + "x" + initialHeight);
+ sb.append(context.getString(R.string.perf_overlay_lite_network_decoding_delay) + ": ");
+ sb.append(context.getString(R.string.perf_overlay_lite_net,(int)(rttInfo >> 32)));
+ sb.append(" / ");
+ sb.append(context.getString(R.string.perf_overlay_lite_dectime,decodeTimeMs));
+ sb.append("\t");
+ sb.append(context.getString(R.string.perf_overlay_lite_packet_loss) + ": ");
+ sb.append(context.getString(R.string.perf_overlay_lite_netdrops,(float)lastTwo.framesLost / lastTwo.totalFrames * 100));
+ sb.append("\t FPS:");
+ sb.append(context.getString(R.string.perf_overlay_lite_fps,fps.totalFps));
+// sb.append("\n");
+// sb.append(context.getString(R.string.perf_overlay_lite_decoder,decoder));
+ }else{
+ sb.append(context.getString(R.string.perf_overlay_streamdetails, initialWidth + "x" + initialHeight, fps.totalFps)).append('\n');
+ sb.append(context.getString(R.string.perf_overlay_decoder, decoder)).append('\n');
+ sb.append(context.getString(R.string.perf_overlay_incomingfps, fps.receivedFps)).append('\n');
+ sb.append(context.getString(R.string.perf_overlay_renderingfps, fps.renderedFps)).append('\n');
+ sb.append(context.getString(R.string.perf_overlay_netdrops,
+ (float)lastTwo.framesLost / lastTwo.totalFrames * 100)).append('\n');
+ if(TrafficStatsHelper.getPackageRxBytes(Process.myUid()) != TrafficStats.UNSUPPORTED){
+ long netData=TrafficStatsHelper.getPackageRxBytes(Process.myUid())+TrafficStatsHelper.getPackageTxBytes(Process.myUid());
+ if(lastNetDataNum!=0){
+ sb.append(context.getString(R.string.perf_overlay_lite_bandwidth) + ": ");
+ float realtimeNetData=(netData-lastNetDataNum)/1024f;
+ if(realtimeNetData>=1000){
+ sb.append(String.format("%.2f", realtimeNetData/1024f) +"M/s\n");
+ }else{
+ sb.append(String.format("%.2f", realtimeNetData) +"K/s\n");
+ }
+ }
+ lastNetDataNum=netData;
+ }
+ sb.append(context.getString(R.string.perf_overlay_netlatency,
+ (int)(rttInfo >> 32), (int)rttInfo)).append('\n');
+ if (lastTwo.framesWithHostProcessingLatency > 0) {
+ sb.append(context.getString(R.string.perf_overlay_hostprocessinglatency,
+ (float)lastTwo.minHostProcessingLatency / 10,
+ (float)lastTwo.maxHostProcessingLatency / 10,
+ (float)lastTwo.totalHostProcessingLatency / 10 / lastTwo.framesWithHostProcessingLatency)).append('\n');
+ }
+ sb.append(context.getString(R.string.perf_overlay_dectime, decodeTimeMs));
+ }
+
+ perfListener.onPerfUpdate(sb.toString());
+ }
+
+ globalVideoStats.add(activeWindowVideoStats);
+ lastWindowVideoStats.copy(activeWindowVideoStats);
+ activeWindowVideoStats.clear();
+ activeWindowVideoStats.measurementStartTimestamp = SystemClock.uptimeMillis();
+ }
+
+ boolean csdSubmittedForThisFrame = false;
+
+ // IDR frames require special handling for CSD buffer submission
+ if (frameType == MoonBridge.FRAME_TYPE_IDR) {
+ // H264 SPS
+ if (decodeUnitType == MoonBridge.BUFFER_TYPE_SPS && (videoFormat & MoonBridge.VIDEO_FORMAT_MASK_H264) != 0) {
+ numSpsIn++;
+
+ ByteBuffer spsBuf = ByteBuffer.wrap(decodeUnitData);
+ int startSeqLen = decodeUnitData[2] == 0x01 ? 3 : 4;
+
+ // Skip to the start of the NALU data
+ spsBuf.position(startSeqLen + 1);
+
+ // The H264Utils.readSPS function safely handles
+ // Annex B NALUs (including NALUs with escape sequences)
+ SeqParameterSet sps = H264Utils.readSPS(spsBuf);
+
+ // Some decoders rely on H264 level to decide how many buffers are needed
+ // Since we only need one frame buffered, we'll set the level as low as we can
+ // for known resolution combinations. Reference frame invalidation may need
+ // these, so leave them be for those decoders.
+ if (!refFrameInvalidationActive) {
+ if (initialWidth <= 720 && initialHeight <= 480 && refreshRate <= 60) {
+ // Max 5 buffered frames at 720x480x60
+ LimeLog.info("Patching level_idc to 31");
+ sps.levelIdc = 31;
+ }
+ else if (initialWidth <= 1280 && initialHeight <= 720 && refreshRate <= 60) {
+ // Max 5 buffered frames at 1280x720x60
+ LimeLog.info("Patching level_idc to 32");
+ sps.levelIdc = 32;
+ }
+ else if (initialWidth <= 1920 && initialHeight <= 1080 && refreshRate <= 60) {
+ // Max 4 buffered frames at 1920x1080x64
+ LimeLog.info("Patching level_idc to 42");
+ sps.levelIdc = 42;
+ }
+ else {
+ // Leave the profile alone (currently 5.0)
+ }
+ }
+
+ // TI OMAP4 requires a reference frame count of 1 to decode successfully. Exynos 4
+ // also requires this fixup.
+ //
+ // I'm doing this fixup for all devices because I haven't seen any devices that
+ // this causes issues for. At worst, it seems to do nothing and at best it fixes
+ // issues with video lag, hangs, and crashes.
+ //
+ // It does break reference frame invalidation, so we will not do that for decoders
+ // where we've enabled reference frame invalidation.
+ if (!refFrameInvalidationActive) {
+ LimeLog.info("Patching num_ref_frames in SPS");
+ sps.numRefFrames = 1;
+ }
+
+ // GFE 2.5.11 changed the SPS to add additional extensions. Some devices don't like these
+ // so we remove them here on old devices unless these devices also support HEVC.
+ // See getPreferredColorSpace() for further information.
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O &&
+ sps.vuiParams != null &&
+ hevcDecoder == null &&
+ av1Decoder == null) {
+ sps.vuiParams.videoSignalTypePresentFlag = false;
+ sps.vuiParams.colourDescriptionPresentFlag = false;
+ sps.vuiParams.chromaLocInfoPresentFlag = false;
+ }
+
+ // Some older devices used to choke on a bitstream restrictions, so we won't provide them
+ // unless explicitly whitelisted. For newer devices, leave the bitstream restrictions present.
+ if (needsSpsBitstreamFixup || isExynos4 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ // The SPS that comes in the current H264 bytestream doesn't set bitstream_restriction_flag
+ // or max_dec_frame_buffering which increases decoding latency on Tegra.
+
+ // If the encoder didn't include VUI parameters in the SPS, add them now
+ if (sps.vuiParams == null) {
+ LimeLog.info("Adding VUI parameters");
+ sps.vuiParams = new VUIParameters();
+ }
+
+ // GFE 2.5.11 started sending bitstream restrictions
+ if (sps.vuiParams.bitstreamRestriction == null) {
+ LimeLog.info("Adding bitstream restrictions");
+ sps.vuiParams.bitstreamRestriction = new VUIParameters.BitstreamRestriction();
+ sps.vuiParams.bitstreamRestriction.motionVectorsOverPicBoundariesFlag = true;
+ sps.vuiParams.bitstreamRestriction.maxBytesPerPicDenom = 2;
+ sps.vuiParams.bitstreamRestriction.maxBitsPerMbDenom = 1;
+ sps.vuiParams.bitstreamRestriction.log2MaxMvLengthHorizontal = 16;
+ sps.vuiParams.bitstreamRestriction.log2MaxMvLengthVertical = 16;
+ sps.vuiParams.bitstreamRestriction.numReorderFrames = 0;
+ }
+ else {
+ LimeLog.info("Patching bitstream restrictions");
+ }
+
+ // Some devices throw errors if maxDecFrameBuffering < numRefFrames
+ sps.vuiParams.bitstreamRestriction.maxDecFrameBuffering = sps.numRefFrames;
+
+ // These values are the defaults for the fields, but they are more aggressive
+ // than what GFE sends in 2.5.11, but it doesn't seem to cause picture problems.
+ // We'll leave these alone for "modern" devices just in case they care.
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ sps.vuiParams.bitstreamRestriction.maxBytesPerPicDenom = 2;
+ sps.vuiParams.bitstreamRestriction.maxBitsPerMbDenom = 1;
+ }
+
+ // log2_max_mv_length_horizontal and log2_max_mv_length_vertical are set to more
+ // conservative values by GFE 2.5.11. We'll let those values stand.
+ }
+ else if (sps.vuiParams != null) {
+ // Devices that didn't/couldn't get bitstream restrictions before GFE 2.5.11
+ // will continue to not receive them now
+ sps.vuiParams.bitstreamRestriction = null;
+ }
+
+ // If we need to hack this SPS to say we're baseline, do so now
+ if (needsBaselineSpsHack) {
+ LimeLog.info("Hacking SPS to baseline");
+ sps.profileIdc = 66;
+ savedSps = sps;
+ }
+
+ // Patch the SPS constraint flags
+ doProfileSpecificSpsPatching(sps);
+
+ // The H264Utils.writeSPS function safely handles
+ // Annex B NALUs (including NALUs with escape sequences)
+ ByteBuffer escapedNalu = H264Utils.writeSPS(sps, decodeUnitLength);
+
+ // Construct the patched SPS
+ byte[] naluBuffer = new byte[startSeqLen + 1 + escapedNalu.limit()];
+ System.arraycopy(decodeUnitData, 0, naluBuffer, 0, startSeqLen + 1);
+ escapedNalu.get(naluBuffer, startSeqLen + 1, escapedNalu.limit());
+
+ // Batch this to submit together with other CSD per AOSP docs
+ spsBuffers.add(naluBuffer);
+ return MoonBridge.DR_OK;
+ }
+ else if (decodeUnitType == MoonBridge.BUFFER_TYPE_VPS) {
+ numVpsIn++;
+
+ // Batch this to submit together with other CSD per AOSP docs
+ byte[] naluBuffer = new byte[decodeUnitLength];
+ System.arraycopy(decodeUnitData, 0, naluBuffer, 0, decodeUnitLength);
+ vpsBuffers.add(naluBuffer);
+ return MoonBridge.DR_OK;
+ }
+ // Only the HEVC SPS hits this path (H.264 is handled above)
+ else if (decodeUnitType == MoonBridge.BUFFER_TYPE_SPS) {
+ numSpsIn++;
+
+ // Batch this to submit together with other CSD per AOSP docs
+ byte[] naluBuffer = new byte[decodeUnitLength];
+ System.arraycopy(decodeUnitData, 0, naluBuffer, 0, decodeUnitLength);
+ spsBuffers.add(naluBuffer);
+ return MoonBridge.DR_OK;
+ }
+ else if (decodeUnitType == MoonBridge.BUFFER_TYPE_PPS) {
+ numPpsIn++;
+
+ // Batch this to submit together with other CSD per AOSP docs
+ byte[] naluBuffer = new byte[decodeUnitLength];
+ System.arraycopy(decodeUnitData, 0, naluBuffer, 0, decodeUnitLength);
+ ppsBuffers.add(naluBuffer);
+ return MoonBridge.DR_OK;
+ }
+ else if ((videoFormat & (MoonBridge.VIDEO_FORMAT_MASK_H264 | MoonBridge.VIDEO_FORMAT_MASK_H265)) != 0) {
+ // If this is the first CSD blob or we aren't supporting fused IDR frames, we will
+ // submit the CSD blob in a separate input buffer for each IDR frame.
+ if (!submittedCsd || !fusedIdrFrame) {
+ if (!fetchNextInputBuffer()) {
+ return MoonBridge.DR_NEED_IDR;
+ }
+
+ // Submit all CSD when we receive the first non-CSD blob in an IDR frame
+ for (byte[] vpsBuffer : vpsBuffers) {
+ nextInputBuffer.put(vpsBuffer);
+ }
+ for (byte[] spsBuffer : spsBuffers) {
+ nextInputBuffer.put(spsBuffer);
+ }
+ for (byte[] ppsBuffer : ppsBuffers) {
+ nextInputBuffer.put(ppsBuffer);
+ }
+
+ if (!queueNextInputBuffer(0, MediaCodec.BUFFER_FLAG_CODEC_CONFIG)) {
+ return MoonBridge.DR_NEED_IDR;
+ }
+
+ // Remember that we already submitted CSD for this frame, so we don't do it
+ // again in the fused IDR case below.
+ csdSubmittedForThisFrame = true;
+
+ // Remember that we submitted CSD globally for this MediaCodec instance
+ submittedCsd = true;
+
+ if (needsBaselineSpsHack) {
+ needsBaselineSpsHack = false;
+
+ if (!replaySps()) {
+ return MoonBridge.DR_NEED_IDR;
+ }
+
+ LimeLog.info("SPS replay complete");
+ }
+ }
+ }
+ }
+
+ if (frameHostProcessingLatency != 0) {
+ if (activeWindowVideoStats.minHostProcessingLatency != 0) {
+ activeWindowVideoStats.minHostProcessingLatency = (char) Math.min(activeWindowVideoStats.minHostProcessingLatency, frameHostProcessingLatency);
+ } else {
+ activeWindowVideoStats.minHostProcessingLatency = frameHostProcessingLatency;
+ }
+ activeWindowVideoStats.framesWithHostProcessingLatency += 1;
+ }
+ activeWindowVideoStats.maxHostProcessingLatency = (char) Math.max(activeWindowVideoStats.maxHostProcessingLatency, frameHostProcessingLatency);
+ activeWindowVideoStats.totalHostProcessingLatency += frameHostProcessingLatency;
+
+ activeWindowVideoStats.totalFramesReceived++;
+ activeWindowVideoStats.totalFrames++;
+
+ if (!FRAME_RENDER_TIME_ONLY) {
+ // Count time from first packet received to enqueue time as receive time
+ // We will count DU queue time as part of decoding, because it is directly
+ // caused by a slow decoder.
+ activeWindowVideoStats.totalTimeMs += enqueueTimeMs - receiveTimeMs;
+ }
+
+ if (!fetchNextInputBuffer()) {
+ return MoonBridge.DR_NEED_IDR;
+ }
+
+ int codecFlags = 0;
+
+ if (frameType == MoonBridge.FRAME_TYPE_IDR) {
+ codecFlags |= MediaCodec.BUFFER_FLAG_SYNC_FRAME;
+
+ // If we are using fused IDR frames, submit the CSD with each IDR frame
+ if (fusedIdrFrame && !csdSubmittedForThisFrame) {
+ for (byte[] vpsBuffer : vpsBuffers) {
+ nextInputBuffer.put(vpsBuffer);
+ }
+ for (byte[] spsBuffer : spsBuffers) {
+ nextInputBuffer.put(spsBuffer);
+ }
+ for (byte[] ppsBuffer : ppsBuffers) {
+ nextInputBuffer.put(ppsBuffer);
+ }
+ }
+ }
+
+ long timestampUs = enqueueTimeMs * 1000;
+ if (timestampUs <= lastTimestampUs) {
+ // We can't submit multiple buffers with the same timestamp
+ // so bump it up by one before queuing
+ timestampUs = lastTimestampUs + 1;
+ }
+ lastTimestampUs = timestampUs;
+
+ numFramesIn++;
+
+ if (decodeUnitLength > nextInputBuffer.limit() - nextInputBuffer.position()) {
+ IllegalArgumentException exception = new IllegalArgumentException(
+ "Decode unit length "+decodeUnitLength+" too large for input buffer "+nextInputBuffer.limit());
+ if (!reportedCrash) {
+ reportedCrash = true;
+ crashListener.notifyCrash(exception);
+ }
+ throw new RendererException(this, exception);
+ }
+
+ // Copy data from our buffer list into the input buffer
+ nextInputBuffer.put(decodeUnitData, 0, decodeUnitLength);
+
+ if (!queueNextInputBuffer(timestampUs, codecFlags)) {
+ return MoonBridge.DR_NEED_IDR;
+ }
+
+ return MoonBridge.DR_OK;
+ }
+
+ private boolean replaySps() {
+ if (!fetchNextInputBuffer()) {
+ return false;
+ }
+
+ // Write the Annex B header
+ nextInputBuffer.put(new byte[]{0x00, 0x00, 0x00, 0x01, 0x67});
+
+ // Switch the H264 profile back to high
+ savedSps.profileIdc = 100;
+
+ // Patch the SPS constraint flags
+ doProfileSpecificSpsPatching(savedSps);
+
+ // The H264Utils.writeSPS function safely handles
+ // Annex B NALUs (including NALUs with escape sequences)
+ ByteBuffer escapedNalu = H264Utils.writeSPS(savedSps, 128);
+ nextInputBuffer.put(escapedNalu);
+
+ // No need for the SPS anymore
+ savedSps = null;
+
+ // Queue the new SPS
+ return queueNextInputBuffer(0, MediaCodec.BUFFER_FLAG_CODEC_CONFIG);
+ }
+
+ @Override
+ public int getCapabilities() {
+ int capabilities = 0;
+
+ // Request the optimal number of slices per frame for this decoder
+ capabilities |= MoonBridge.CAPABILITY_SLICES_PER_FRAME(optimalSlicesPerFrame);
+
+ // Enable reference frame invalidation on supported hardware
+ if (refFrameInvalidationAvc) {
+ capabilities |= MoonBridge.CAPABILITY_REFERENCE_FRAME_INVALIDATION_AVC;
+ }
+ if (refFrameInvalidationHevc) {
+ capabilities |= MoonBridge.CAPABILITY_REFERENCE_FRAME_INVALIDATION_HEVC;
+ }
+ if (refFrameInvalidationAv1) {
+ capabilities |= MoonBridge.CAPABILITY_REFERENCE_FRAME_INVALIDATION_AV1;
+ }
+
+ // Enable direct submit on supported hardware
+ if (directSubmit) {
+ capabilities |= MoonBridge.CAPABILITY_DIRECT_SUBMIT;
+ }
+
+ return capabilities;
+ }
+
+ public int getAverageEndToEndLatency() {
+ if (globalVideoStats.totalFramesReceived == 0) {
+ return 0;
+ }
+ return (int)(globalVideoStats.totalTimeMs / globalVideoStats.totalFramesReceived);
+ }
+
+ public int getAverageDecoderLatency() {
+ if (globalVideoStats.totalFramesReceived == 0) {
+ return 0;
+ }
+ return (int)(globalVideoStats.decoderTimeMs / globalVideoStats.totalFramesReceived);
+ }
+
+ static class DecoderHungException extends RuntimeException {
+ private int hangTimeMs;
+
+ DecoderHungException(int hangTimeMs) {
+ this.hangTimeMs = hangTimeMs;
+ }
+
+ public String toString() {
+ String str = "";
+
+ str += "Hang time: "+hangTimeMs+" ms"+ RendererException.DELIMITER;
+ str += super.toString();
+
+ return str;
+ }
+ }
+
+ static class RendererException extends RuntimeException {
+ private static final long serialVersionUID = 8985937536997012406L;
+ protected static final String DELIMITER = BuildConfig.DEBUG ? "\n" : " | ";
+
+ private String text;
+
+ RendererException(MediaCodecDecoderRenderer renderer, Exception e) {
+ this.text = generateText(renderer, e);
+ }
+
+ public String toString() {
+ return text;
+ }
+
+ private String generateText(MediaCodecDecoderRenderer renderer, Exception originalException) {
+ String str;
+
+ if (renderer.numVpsIn == 0 && renderer.numSpsIn == 0 && renderer.numPpsIn == 0) {
+ str = "PreSPSError";
+ }
+ else if (renderer.numSpsIn > 0 && renderer.numPpsIn == 0) {
+ str = "PrePPSError";
+ }
+ else if (renderer.numPpsIn > 0 && renderer.numFramesIn == 0) {
+ str = "PreIFrameError";
+ }
+ else if (renderer.numFramesIn > 0 && renderer.outputFormat == null) {
+ str = "PreOutputConfigError";
+ }
+ else if (renderer.outputFormat != null && renderer.numFramesOut == 0) {
+ str = "PreOutputError";
+ }
+ else if (renderer.numFramesOut <= renderer.refreshRate * 30) {
+ str = "EarlyOutputError";
+ }
+ else {
+ str = "ErrorWhileStreaming";
+ }
+
+ str += "Format: "+String.format("%x", renderer.videoFormat)+DELIMITER;
+ str += "AVC Decoder: "+((renderer.avcDecoder != null) ? renderer.avcDecoder.getName():"(none)")+DELIMITER;
+ str += "HEVC Decoder: "+((renderer.hevcDecoder != null) ? renderer.hevcDecoder.getName():"(none)")+DELIMITER;
+ str += "AV1 Decoder: "+((renderer.av1Decoder != null) ? renderer.av1Decoder.getName():"(none)")+DELIMITER;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && renderer.avcDecoder != null) {
+ Range avcWidthRange = renderer.avcDecoder.getCapabilitiesForType("video/avc").getVideoCapabilities().getSupportedWidths();
+ str += "AVC supported width range: "+avcWidthRange+DELIMITER;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ try {
+ Range avcFpsRange = renderer.avcDecoder.getCapabilitiesForType("video/avc").getVideoCapabilities().getAchievableFrameRatesFor(renderer.initialWidth, renderer.initialHeight);
+ str += "AVC achievable FPS range: "+avcFpsRange+DELIMITER;
+ } catch (IllegalArgumentException e) {
+ str += "AVC achievable FPS range: UNSUPPORTED!"+DELIMITER;
+ }
+ }
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && renderer.hevcDecoder != null) {
+ Range hevcWidthRange = renderer.hevcDecoder.getCapabilitiesForType("video/hevc").getVideoCapabilities().getSupportedWidths();
+ str += "HEVC supported width range: "+hevcWidthRange+DELIMITER;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ try {
+ Range hevcFpsRange = renderer.hevcDecoder.getCapabilitiesForType("video/hevc").getVideoCapabilities().getAchievableFrameRatesFor(renderer.initialWidth, renderer.initialHeight);
+ str += "HEVC achievable FPS range: " + hevcFpsRange + DELIMITER;
+ } catch (IllegalArgumentException e) {
+ str += "HEVC achievable FPS range: UNSUPPORTED!"+DELIMITER;
+ }
+ }
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && renderer.av1Decoder != null) {
+ Range av1WidthRange = renderer.av1Decoder.getCapabilitiesForType("video/av01").getVideoCapabilities().getSupportedWidths();
+ str += "AV1 supported width range: "+av1WidthRange+DELIMITER;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ try {
+ Range av1FpsRange = renderer.av1Decoder.getCapabilitiesForType("video/av01").getVideoCapabilities().getAchievableFrameRatesFor(renderer.initialWidth, renderer.initialHeight);
+ str += "AV1 achievable FPS range: " + av1FpsRange + DELIMITER;
+ } catch (IllegalArgumentException e) {
+ str += "AV1 achievable FPS range: UNSUPPORTED!"+DELIMITER;
+ }
+ }
+ }
+ str += "Configured format: "+renderer.configuredFormat+DELIMITER;
+ str += "Input format: "+renderer.inputFormat+DELIMITER;
+ str += "Output format: "+renderer.outputFormat+DELIMITER;
+ str += "Adaptive playback: "+renderer.adaptivePlayback+DELIMITER;
+ str += "GL Renderer: "+renderer.glRenderer+DELIMITER;
+ //str += "Build fingerprint: "+Build.FINGERPRINT+DELIMITER;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ str += "SOC: "+Build.SOC_MANUFACTURER+" - "+Build.SOC_MODEL+DELIMITER;
+ str += "Performance class: "+Build.VERSION.MEDIA_PERFORMANCE_CLASS+DELIMITER;
+ /*str += "Vendor params: ";
+ List params = renderer.videoDecoder.getSupportedVendorParameters();
+ if (params.isEmpty()) {
+ str += "NONE";
+ }
+ else {
+ for (String param : params) {
+ str += param + " ";
+ }
+ }
+ str += DELIMITER;*/
+ }
+ str += "Consecutive crashes: "+renderer.consecutiveCrashCount+DELIMITER;
+ str += "RFI active: "+renderer.refFrameInvalidationActive+DELIMITER;
+ str += "Using modern SPS patching: "+(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)+DELIMITER;
+ str += "Fused IDR frames: "+renderer.fusedIdrFrame+DELIMITER;
+ str += "Video dimensions: "+renderer.initialWidth+"x"+renderer.initialHeight+DELIMITER;
+ str += "FPS target: "+renderer.refreshRate+DELIMITER;
+ str += "Bitrate: "+renderer.prefs.bitrate+" Kbps"+DELIMITER;
+ str += "CSD stats: "+renderer.numVpsIn+", "+renderer.numSpsIn+", "+renderer.numPpsIn+DELIMITER;
+ str += "Frames in-out: "+renderer.numFramesIn+", "+renderer.numFramesOut+DELIMITER;
+ str += "Total frames received: "+renderer.globalVideoStats.totalFramesReceived+DELIMITER;
+ str += "Total frames rendered: "+renderer.globalVideoStats.totalFramesRendered+DELIMITER;
+ str += "Frame losses: "+renderer.globalVideoStats.framesLost+" in "+renderer.globalVideoStats.frameLossEvents+" loss events"+DELIMITER;
+ str += "Average end-to-end client latency: "+renderer.getAverageEndToEndLatency()+"ms"+DELIMITER;
+ str += "Average hardware decoder latency: "+renderer.getAverageDecoderLatency()+"ms"+DELIMITER;
+ str += "Frame pacing mode: "+renderer.prefs.framePacing+DELIMITER;
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ if (originalException instanceof CodecException) {
+ CodecException ce = (CodecException) originalException;
+
+ str += "Diagnostic Info: "+ce.getDiagnosticInfo()+DELIMITER;
+ str += "Recoverable: "+ce.isRecoverable()+DELIMITER;
+ str += "Transient: "+ce.isTransient()+DELIMITER;
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ str += "Codec Error Code: "+ce.getErrorCode()+DELIMITER;
+ }
+ }
+ }
+
+ str += originalException.toString();
+
+ return str;
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/video/MediaCodecHelper.java b/app/src/main/java/com/limelight/binding/video/MediaCodecHelper.java
old mode 100644
new mode 100755
index 24d1f01ab0..20e0d10330
--- a/app/src/main/java/com/limelight/binding/video/MediaCodecHelper.java
+++ b/app/src/main/java/com/limelight/binding/video/MediaCodecHelper.java
@@ -1,1017 +1,1027 @@
-package com.limelight.binding.video;
-
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.FileReader;
-import java.util.Collections;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.Locale;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-import android.annotation.SuppressLint;
-import android.app.ActivityManager;
-import android.content.Context;
-import android.content.pm.ConfigurationInfo;
-import android.media.MediaCodec;
-import android.media.MediaCodecInfo;
-import android.media.MediaCodecList;
-import android.media.MediaCodecInfo.CodecCapabilities;
-import android.media.MediaCodecInfo.CodecProfileLevel;
-import android.media.MediaFormat;
-import android.os.Build;
-
-import com.limelight.LimeLog;
-import com.limelight.preferences.PreferenceConfiguration;
-
-public class MediaCodecHelper {
-
- private static final List preferredDecoders;
-
- private static final List blacklistedDecoderPrefixes;
- private static final List spsFixupBitstreamFixupDecoderPrefixes;
- private static final List blacklistedAdaptivePlaybackPrefixes;
- private static final List baselineProfileHackPrefixes;
- private static final List directSubmitPrefixes;
- private static final List constrainedHighProfilePrefixes;
- private static final List whitelistedHevcDecoders;
- private static final List refFrameInvalidationAvcPrefixes;
- private static final List refFrameInvalidationHevcPrefixes;
- private static final List useFourSlicesPrefixes;
- private static final List qualcommDecoderPrefixes;
- private static final List kirinDecoderPrefixes;
- private static final List exynosDecoderPrefixes;
- private static final List amlogicDecoderPrefixes;
- private static final List knownVendorLowLatencyOptions;
-
- public static final boolean SHOULD_BYPASS_SOFTWARE_BLOCK =
- Build.HARDWARE.equals("ranchu") || Build.HARDWARE.equals("cheets") || Build.BRAND.equals("Android-x86");
-
- private static boolean isLowEndSnapdragon = false;
- private static boolean isAdreno620 = false;
- private static boolean initialized = false;
-
- static {
- directSubmitPrefixes = new LinkedList<>();
-
- // These decoders have low enough input buffer latency that they
- // can be directly invoked from the receive thread
- directSubmitPrefixes.add("omx.qcom");
- directSubmitPrefixes.add("omx.sec");
- directSubmitPrefixes.add("omx.exynos");
- directSubmitPrefixes.add("omx.intel");
- directSubmitPrefixes.add("omx.brcm");
- directSubmitPrefixes.add("omx.TI");
- directSubmitPrefixes.add("omx.arc");
- directSubmitPrefixes.add("omx.nvidia");
-
- // All Codec2 decoders
- directSubmitPrefixes.add("c2.");
- }
-
- static {
- refFrameInvalidationAvcPrefixes = new LinkedList<>();
-
- refFrameInvalidationHevcPrefixes = new LinkedList<>();
- refFrameInvalidationHevcPrefixes.add("omx.exynos");
- refFrameInvalidationHevcPrefixes.add("c2.exynos");
-
- // Qualcomm and NVIDIA may be added at runtime
- }
-
- static {
- preferredDecoders = new LinkedList<>();
- }
-
- static {
- blacklistedDecoderPrefixes = new LinkedList<>();
-
- // Blacklist software decoders that don't support H264 high profile except on systems
- // that are expected to only have software decoders (like emulators).
- if (!SHOULD_BYPASS_SOFTWARE_BLOCK) {
- blacklistedDecoderPrefixes.add("omx.google");
- blacklistedDecoderPrefixes.add("AVCDecoder");
-
- // We want to avoid ffmpeg decoders since they're usually software decoders,
- // but we'll defer to the Android 10 isSoftwareOnly() API on newer devices
- // to determine if we should use these or not.
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
- blacklistedDecoderPrefixes.add("OMX.ffmpeg");
- }
- }
-
- // Force these decoders disabled because:
- // 1) They are software decoders, so the performance is terrible
- // 2) They crash with our HEVC stream anyway (at least prior to CSD batching)
- blacklistedDecoderPrefixes.add("OMX.qcom.video.decoder.hevcswvdec");
- blacklistedDecoderPrefixes.add("OMX.SEC.hevc.sw.dec");
- }
-
- static {
- // If a decoder qualifies for reference frame invalidation,
- // these entries will be ignored for those decoders.
- spsFixupBitstreamFixupDecoderPrefixes = new LinkedList<>();
- spsFixupBitstreamFixupDecoderPrefixes.add("omx.nvidia");
- spsFixupBitstreamFixupDecoderPrefixes.add("omx.qcom");
- spsFixupBitstreamFixupDecoderPrefixes.add("omx.brcm");
-
- baselineProfileHackPrefixes = new LinkedList<>();
- baselineProfileHackPrefixes.add("omx.intel");
-
- blacklistedAdaptivePlaybackPrefixes = new LinkedList<>();
- // The Intel decoder on Lollipop on Nexus Player would increase latency badly
- // if adaptive playback was enabled so let's avoid it to be safe.
- blacklistedAdaptivePlaybackPrefixes.add("omx.intel");
- // The MediaTek decoder crashes at 1080p when adaptive playback is enabled
- // on some Android TV devices with HEVC only.
- blacklistedAdaptivePlaybackPrefixes.add("omx.mtk");
-
- constrainedHighProfilePrefixes = new LinkedList<>();
- constrainedHighProfilePrefixes.add("omx.intel");
- }
-
- static {
- whitelistedHevcDecoders = new LinkedList<>();
-
- // Allow software HEVC decoding in the official AOSP emulator
- if (Build.HARDWARE.equals("ranchu")) {
- whitelistedHevcDecoders.add("omx.google");
- }
-
- // Exynos seems to be the only HEVC decoder that works reliably
- whitelistedHevcDecoders.add("omx.exynos");
-
- // On Darcy (Shield 2017), HEVC runs fine with no fixups required. For some reason,
- // other X1 implementations require bitstream fixups. However, since numReferenceFrames
- // has been supported in GFE since late 2017, we'll go ahead and enable HEVC for all
- // device models.
- //
- // NVIDIA does partial HEVC acceleration on the Shield Tablet. I don't know
- // whether the performance is good enough to use for streaming, but they're
- // using the same omx.nvidia.h265.decode name as the Shield TV which has a
- // fully accelerated HEVC pipeline. AFAIK, the only K1 devices with this
- // partially accelerated HEVC decoder are the Shield Tablet and Xiaomi MiPad,
- // so I'll check for those here.
- //
- // In case there are some that I missed, I will also exclude pre-Oreo OSes since
- // only Shield ATV got an Oreo update and any newer Tegra devices will not ship
- // with an old OS like Nougat.
- if (!Build.DEVICE.equalsIgnoreCase("shieldtablet") &&
- !Build.DEVICE.equalsIgnoreCase("mocha") &&
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- whitelistedHevcDecoders.add("omx.nvidia");
- }
-
- // Plot twist: On newer Sony devices (BRAVIA_ATV2, BRAVIA_ATV3_4K, BRAVIA_UR1_4K) the H.264 decoder crashes
- // on several configurations (> 60 FPS and 1440p) that work with HEVC, so we'll whitelist those devices for HEVC.
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && Build.DEVICE.startsWith("BRAVIA_")) {
- whitelistedHevcDecoders.add("omx.mtk");
- }
-
- // Amlogic requires 1 reference frame for HEVC to avoid hanging. Since it's been years
- // since GFE added support for maxNumReferenceFrames, we'll just enable all Amlogic SoCs
- // running Android 9 or later.
- //
- // NB: We don't do this on Sabrina (GCWGTV) because H.264 is lower latency when we use
- // vendor.low-latency.enable. We will still use HEVC if decoderCanMeetPerformancePointWithHevcAndNotAvc()
- // determines it's the only way to meet the performance requirements.
- //
- // With the Android 12 update, Sabrina now uses HEVC (with RFI) based upon FEATURE_LowLatency
- // support, which provides equivalent latency to H.264 now.
- //
- // FIXME: Should we do this for all Amlogic S905X SoCs?
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && !Build.DEVICE.equalsIgnoreCase("sabrina")) {
- whitelistedHevcDecoders.add("omx.amlogic");
- }
-
- // Realtek SoCs are used inside many Android TV devices and can only do 4K60 with HEVC.
- // We'll enable those HEVC decoders by default and see if anything breaks.
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- whitelistedHevcDecoders.add("omx.realtek");
- }
-
- // These theoretically have good HEVC decoding capabilities (potentially better than
- // their AVC decoders), but haven't been tested enough
- //whitelistedHevcDecoders.add("omx.rk");
-
- // Let's see if HEVC decoders are finally stable with C2
- whitelistedHevcDecoders.add("c2.");
-
- // Based on GPU attributes queried at runtime, the omx.qcom/c2.qti prefix will be added
- // during initialization to avoid SoCs with broken HEVC decoders.
- }
-
- static {
- useFourSlicesPrefixes = new LinkedList<>();
-
- // Software decoders will use 4 slices per frame to allow for slice multithreading
- useFourSlicesPrefixes.add("omx.google");
- useFourSlicesPrefixes.add("AVCDecoder");
- useFourSlicesPrefixes.add("omx.ffmpeg");
- useFourSlicesPrefixes.add("c2.android");
-
- // Old Qualcomm decoders are detected at runtime
- }
-
- static {
- knownVendorLowLatencyOptions = new LinkedList<>();
-
- knownVendorLowLatencyOptions.add("vendor.qti-ext-dec-low-latency.enable");
- knownVendorLowLatencyOptions.add("vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-req");
- knownVendorLowLatencyOptions.add("vendor.rtc-ext-dec-low-latency.enable");
- knownVendorLowLatencyOptions.add("vendor.low-latency.enable");
- }
-
- static {
- qualcommDecoderPrefixes = new LinkedList<>();
-
- qualcommDecoderPrefixes.add("omx.qcom");
- qualcommDecoderPrefixes.add("c2.qti");
- }
-
- static {
- kirinDecoderPrefixes = new LinkedList<>();
-
- kirinDecoderPrefixes.add("omx.hisi");
- kirinDecoderPrefixes.add("c2.hisi"); // Unconfirmed
- }
-
- static {
- exynosDecoderPrefixes = new LinkedList<>();
-
- exynosDecoderPrefixes.add("omx.exynos");
- exynosDecoderPrefixes.add("c2.exynos");
- }
-
- static {
- amlogicDecoderPrefixes = new LinkedList<>();
-
- amlogicDecoderPrefixes.add("omx.amlogic");
- amlogicDecoderPrefixes.add("c2.amlogic"); // Unconfirmed
- }
-
- private static boolean isPowerVR(String glRenderer) {
- return glRenderer.toLowerCase().contains("powervr");
- }
-
- private static String getAdrenoVersionString(String glRenderer) {
- glRenderer = glRenderer.toLowerCase().trim();
-
- if (!glRenderer.contains("adreno")) {
- return null;
- }
-
- Pattern modelNumberPattern = Pattern.compile("(.*)([0-9]{3})(.*)");
-
- Matcher matcher = modelNumberPattern.matcher(glRenderer);
- if (!matcher.matches()) {
- return null;
- }
-
- String modelNumber = matcher.group(2);
- LimeLog.info("Found Adreno GPU: "+modelNumber);
- return modelNumber;
- }
-
- private static boolean isLowEndSnapdragonRenderer(String glRenderer) {
- String modelNumber = getAdrenoVersionString(glRenderer);
- if (modelNumber == null) {
- // Not an Adreno GPU
- return false;
- }
-
- // The current logic is to identify low-end SoCs based on a zero in the x0x place.
- return modelNumber.charAt(1) == '0';
- }
-
- private static int getAdrenoRendererModelNumber(String glRenderer) {
- String modelNumber = getAdrenoVersionString(glRenderer);
- if (modelNumber == null) {
- // Not an Adreno GPU
- return -1;
- }
-
- return Integer.parseInt(modelNumber);
- }
-
- // This is a workaround for some broken devices that report
- // only GLES 3.0 even though the GPU is an Adreno 4xx series part.
- // An example of such a device is the Huawei Honor 5x with the
- // Snapdragon 616 SoC (Adreno 405).
- private static boolean isGLES31SnapdragonRenderer(String glRenderer) {
- // Snapdragon 4xx and higher support GLES 3.1
- return getAdrenoRendererModelNumber(glRenderer) >= 400;
- }
-
- public static void initialize(Context context, String glRenderer) {
- if (initialized) {
- return;
- }
-
- // Older Sony ATVs (SVP-DTV15) have broken MediaTek codecs (decoder hangs after rendering the first frame).
- // I know the Fire TV 2 and 3 works, so I'll whitelist Amazon devices which seem to actually be tested.
- // We still have to check Build.MANUFACTURER to catch Amazon Fire tablets.
- if (context.getPackageManager().hasSystemFeature("amazon.hardware.fire_tv") ||
- Build.MANUFACTURER.equalsIgnoreCase("Amazon")) {
- // HEVC and RFI have been confirmed working on Fire TV 2, Fire TV Stick 2, Fire TV 4K Max,
- // Fire HD 8 2020, and Fire HD 8 2022 models.
- //
- // This is probably a good enough sample to conclude that all MediaTek Fire OS devices
- // are likely to be okay.
- whitelistedHevcDecoders.add("omx.mtk");
- refFrameInvalidationHevcPrefixes.add("omx.mtk");
- refFrameInvalidationHevcPrefixes.add("c2.mtk");
-
- // This requires setting vdec-lowlatency on the Fire TV 3, otherwise the decoder
- // never produces any output frames. See comment above for details on why we only
- // do this for Fire TV devices.
- whitelistedHevcDecoders.add("omx.amlogic");
-
- // Fire TV 3 seems to produce random artifacts on HEVC streams after packet loss.
- // Enabling RFI turns these artifacts into full decoder output hangs, so let's not enable
- // that for Fire OS 6 Amlogic devices. We will leave HEVC enabled because that's the only
- // way these devices can hit 4K. Hopefully this is just a problem with the BSP used in
- // the Fire OS 6 Amlogic devices, so we will leave this enabled for Fire OS 7+.
- //
- // Apart from a few TV models, the main Amlogic-based Fire TV devices are the Fire TV
- // Cubes and Fire TV 3. This check will exclude the Fire TV 3 and Fire TV Cube 1, but
- // allow the newer Fire TV Cubes to use HEVC RFI.
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- refFrameInvalidationHevcPrefixes.add("omx.amlogic");
- refFrameInvalidationHevcPrefixes.add("c2.amlogic");
- }
- }
-
- ActivityManager activityManager =
- (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
- ConfigurationInfo configInfo = activityManager.getDeviceConfigurationInfo();
- if (configInfo.reqGlEsVersion != ConfigurationInfo.GL_ES_VERSION_UNDEFINED) {
- LimeLog.info("OpenGL ES version: "+configInfo.reqGlEsVersion);
-
- isLowEndSnapdragon = isLowEndSnapdragonRenderer(glRenderer);
- isAdreno620 = getAdrenoRendererModelNumber(glRenderer) == 620;
-
- // Tegra K1 and later can do reference frame invalidation properly
- if (configInfo.reqGlEsVersion >= 0x30000) {
- LimeLog.info("Added omx.nvidia/c2.nvidia to reference frame invalidation support list");
- refFrameInvalidationAvcPrefixes.add("omx.nvidia");
-
- // Exclude HEVC RFI on Pixel C and Tegra devices prior to Android 11. Misbehaving RFI
- // on these devices can cause hundreds of milliseconds of latency, so it's not worth
- // using it unless we're absolutely sure that it will not cause increased latency.
- if (!Build.DEVICE.equalsIgnoreCase("dragon") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- refFrameInvalidationHevcPrefixes.add("omx.nvidia");
- }
-
- refFrameInvalidationAvcPrefixes.add("c2.nvidia"); // Unconfirmed
- refFrameInvalidationHevcPrefixes.add("c2.nvidia"); // Unconfirmed
-
- LimeLog.info("Added omx.qcom/c2.qti to reference frame invalidation support list");
- refFrameInvalidationAvcPrefixes.add("omx.qcom");
- refFrameInvalidationHevcPrefixes.add("omx.qcom");
- refFrameInvalidationAvcPrefixes.add("c2.qti");
- refFrameInvalidationHevcPrefixes.add("c2.qti");
- }
-
- // Qualcomm's early HEVC decoders break hard on our HEVC stream. The best check to
- // tell the good from the bad decoders are the generation of Adreno GPU included:
- // 3xx - bad
- // 4xx - good
- //
- // The "good" GPUs support GLES 3.1, but we can't just check that directly
- // (see comment on isGLES31SnapdragonRenderer).
- //
- if (isGLES31SnapdragonRenderer(glRenderer)) {
- LimeLog.info("Added omx.qcom/c2.qti to HEVC decoders based on GLES 3.1+ support");
- whitelistedHevcDecoders.add("omx.qcom");
- whitelistedHevcDecoders.add("c2.qti");
- }
- else {
- blacklistedDecoderPrefixes.add("OMX.qcom.video.decoder.hevc");
-
- // These older decoders need 4 slices per frame for best performance
- useFourSlicesPrefixes.add("omx.qcom");
- }
-
- // Older MediaTek SoCs have issues with HEVC rendering but the newer chips with
- // PowerVR GPUs have good HEVC support.
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isPowerVR(glRenderer)) {
- LimeLog.info("Added omx.mtk to HEVC decoders based on PowerVR GPU");
- whitelistedHevcDecoders.add("omx.mtk");
-
- // This SoC (MT8176 in GPD XD+) supports AVC RFI too, but the maxNumReferenceFrames setting
- // required to make it work adds a huge amount of latency. However, RFI on HEVC causes
- // decoder hangs on the newer GE8100, GE8300, and GE8320 GPUs, so we limit it to the
- // Series6XT GPUs where we know it works.
- if (glRenderer.contains("GX6")) {
- LimeLog.info("Added omx.mtk/c2.mtk to RFI list for HEVC");
- refFrameInvalidationHevcPrefixes.add("omx.mtk");
- refFrameInvalidationHevcPrefixes.add("c2.mtk");
- }
- }
- }
-
- initialized = true;
- }
-
- private static boolean isDecoderInList(List decoderList, String decoderName) {
- if (!initialized) {
- throw new IllegalStateException("MediaCodecHelper must be initialized before use");
- }
-
- for (String badPrefix : decoderList) {
- if (decoderName.length() >= badPrefix.length()) {
- String prefix = decoderName.substring(0, badPrefix.length());
- if (prefix.equalsIgnoreCase(badPrefix)) {
- return true;
- }
- }
- }
-
- return false;
- }
-
- private static boolean decoderSupportsAndroidRLowLatency(MediaCodecInfo decoderInfo, String mimeType) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- try {
- if (decoderInfo.getCapabilitiesForType(mimeType).isFeatureSupported(CodecCapabilities.FEATURE_LowLatency)) {
- LimeLog.info("Low latency decoding mode supported (FEATURE_LowLatency)");
- return true;
- }
- } catch (Exception e) {
- // Tolerate buggy codecs
- e.printStackTrace();
- }
- }
-
- return false;
- }
-
- private static boolean decoderSupportsKnownVendorLowLatencyOption(String decoderName) {
- // It's only possible to probe vendor parameters on Android 12 and above.
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- MediaCodec testCodec = null;
- try {
- // Unfortunately we have to create an actual codec instance to get supported options.
- testCodec = MediaCodec.createByCodecName(decoderName);
-
- // See if any of the vendor parameters match ones we know about
- for (String supportedOption : testCodec.getSupportedVendorParameters()) {
- for (String knownLowLatencyOption : knownVendorLowLatencyOptions) {
- if (supportedOption.equalsIgnoreCase(knownLowLatencyOption)) {
- LimeLog.info(decoderName + " supports known low latency option: " + supportedOption);
- return true;
- }
- }
- }
- } catch (Exception e) {
- // Tolerate buggy codecs
- e.printStackTrace();
- } finally {
- if (testCodec != null) {
- testCodec.release();
- }
- }
- }
- return false;
- }
-
- private static boolean decoderSupportsMaxOperatingRate(String decoderName) {
- // Operate at maximum rate to lower latency as much as possible on
- // some Qualcomm platforms. We could also set KEY_PRIORITY to 0 (realtime)
- // but that will actually result in the decoder crashing if it can't satisfy
- // our (ludicrous) operating rate requirement. This seems to cause reliable
- // crashes on the Xiaomi Mi 10 lite 5G and Redmi K30i 5G on Android 10, so
- // we'll disable it on Snapdragon 765G and all non-Qualcomm devices to be safe.
- //
- // NB: Even on Android 10, this optimization still provides significant
- // performance gains on Pixel 2.
- return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
- isDecoderInList(qualcommDecoderPrefixes, decoderName) &&
- !isAdreno620;
- }
-
- public static boolean setDecoderLowLatencyOptions(MediaFormat videoFormat, MediaCodecInfo decoderInfo, int tryNumber) {
- // Options here should be tried in the order of most to least risky. The decoder will use
- // the first MediaFormat that doesn't fail in configure().
-
- boolean setNewOption = false;
-
- if (tryNumber < 1) {
- // Official Android 11+ low latency option (KEY_LOW_LATENCY).
- videoFormat.setInteger("low-latency", 1);
- setNewOption = true;
-
- // If this decoder officially supports FEATURE_LowLatency, we will just use that alone
- // for try 0. Otherwise, we'll include it as best effort with other options.
- if (decoderSupportsAndroidRLowLatency(decoderInfo, videoFormat.getString(MediaFormat.KEY_MIME))) {
- return true;
- }
- }
-
- if (tryNumber < 2 &&
- (!Build.MANUFACTURER.equalsIgnoreCase("xiaomi") || Build.VERSION.SDK_INT > Build.VERSION_CODES.M)) {
- // MediaTek decoders don't use vendor-defined keys for low latency mode. Instead, they have a modified
- // version of AOSP's ACodec.cpp which supports the "vdec-lowlatency" option. This option is passed down
- // to the decoder as OMX.MTK.index.param.video.LowLatencyDecode.
- //
- // This option is also plumbed for Amazon Amlogic-based devices like the Fire TV 3. Not only does it
- // reduce latency on Amlogic, it fixes the HEVC bug that causes the decoder to not output any frames.
- // Unfortunately, it does the exact opposite for the Xiaomi MITV4-ANSM0, breaking it in the way that
- // Fire TV was broken prior to vdec-lowlatency :(
- //
- // On Fire TV 3, vdec-lowlatency is translated to OMX.amazon.fireos.index.video.lowLatencyDecode.
- //
- // https://github.com/yuan1617/Framwork/blob/master/frameworks/av/media/libstagefright/ACodec.cpp
- // https://github.com/iykex/vendor_mediatek_proprietary_hardware/blob/master/libomx/video/MtkOmxVdecEx/MtkOmxVdecEx.h
- videoFormat.setInteger("vdec-lowlatency", 1);
- setNewOption = true;
- }
-
- if (tryNumber < 3) {
- if (MediaCodecHelper.decoderSupportsMaxOperatingRate(decoderInfo.getName())) {
- videoFormat.setInteger(MediaFormat.KEY_OPERATING_RATE, Short.MAX_VALUE);
- setNewOption = true;
- }
- else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- videoFormat.setInteger(MediaFormat.KEY_PRIORITY, 0);
- setNewOption = true;
- }
- }
-
- // MediaCodec supports vendor-defined format keys using the "vendor.." syntax.
- // These allow access to functionality that is not exposed through documented MediaFormat.KEY_* values.
- // https://cs.android.com/android/platform/superproject/+/master:hardware/qcom/sdm845/media/mm-video-v4l2/vidc/common/inc/vidc_vendor_extensions.h;l=67
- //
- // MediaCodec vendor extension support was introduced in Android 8.0:
- // https://cs.android.com/android/_/android/platform/frameworks/av/+/01c10f8cdcd58d1e7025f426a72e6e75ba5d7fc2
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- // Try vendor-specific low latency options
- //
- // NOTE: Update knownVendorLowLatencyOptions if you modify this code!
- if (isDecoderInList(qualcommDecoderPrefixes, decoderInfo.getName())) {
- // Examples of Qualcomm's vendor extensions for Snapdragon 845:
- // https://cs.android.com/android/platform/superproject/+/master:hardware/qcom/sdm845/media/mm-video-v4l2/vidc/vdec/src/omx_vdec_extensions.hpp
- // https://cs.android.com/android/_/android/platform/hardware/qcom/sm8150/media/+/0621ceb1c1b19564999db8293574a0e12952ff6c
- //
- // We will first try both, then try vendor.qti-ext-dec-low-latency.enable alone if that fails
- if (tryNumber < 4) {
- videoFormat.setInteger("vendor.qti-ext-dec-picture-order.enable", 1);
- setNewOption = true;
- }
- if (tryNumber < 5) {
- videoFormat.setInteger("vendor.qti-ext-dec-low-latency.enable", 1);
- setNewOption = true;
- }
- }
- else if (isDecoderInList(kirinDecoderPrefixes, decoderInfo.getName())) {
- if (tryNumber < 4) {
- // Kirin low latency options
- // https://developer.huawei.com/consumer/cn/forum/topic/0202325564295980115
- videoFormat.setInteger("vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-req", 1);
- videoFormat.setInteger("vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-rdy", -1);
- setNewOption = true;
- }
- }
- else if (isDecoderInList(exynosDecoderPrefixes, decoderInfo.getName())) {
- if (tryNumber < 4) {
- // Exynos low latency option for H.264 decoder
- videoFormat.setInteger("vendor.rtc-ext-dec-low-latency.enable", 1);
- setNewOption = true;
- }
- }
- else if (isDecoderInList(amlogicDecoderPrefixes, decoderInfo.getName())) {
- if (tryNumber < 4) {
- // Amlogic low latency vendor extension
- // https://github.com/codewalkerster/android_vendor_amlogic_common_prebuilt_libstagefrighthw/commit/41fefc4e035c476d58491324a5fe7666bfc2989e
- videoFormat.setInteger("vendor.low-latency.enable", 1);
- setNewOption = true;
- }
- }
- }
-
- return setNewOption;
- }
-
- public static boolean decoderSupportsFusedIdrFrame(MediaCodecInfo decoderInfo, String mimeType) {
- // If adaptive playback is supported, we can submit new CSD together with a keyframe
- try {
- if (decoderInfo.getCapabilitiesForType(mimeType).
- isFeatureSupported(CodecCapabilities.FEATURE_AdaptivePlayback)) {
- LimeLog.info("Decoder supports fused IDR frames (FEATURE_AdaptivePlayback)");
- return true;
- }
- } catch (Exception e) {
- // Tolerate buggy codecs
- e.printStackTrace();
- }
-
- return false;
- }
-
- public static boolean decoderSupportsAdaptivePlayback(MediaCodecInfo decoderInfo, String mimeType) {
- if (isDecoderInList(blacklistedAdaptivePlaybackPrefixes, decoderInfo.getName())) {
- LimeLog.info("Decoder blacklisted for adaptive playback");
- return false;
- }
-
- try {
- if (decoderInfo.getCapabilitiesForType(mimeType).
- isFeatureSupported(CodecCapabilities.FEATURE_AdaptivePlayback))
- {
- // This will make getCapabilities() return that adaptive playback is supported
- LimeLog.info("Adaptive playback supported (FEATURE_AdaptivePlayback)");
- return true;
- }
- } catch (Exception e) {
- // Tolerate buggy codecs
- e.printStackTrace();
- }
-
- return false;
- }
-
- public static boolean decoderNeedsConstrainedHighProfile(String decoderName) {
- return isDecoderInList(constrainedHighProfilePrefixes, decoderName);
- }
-
- public static boolean decoderCanDirectSubmit(String decoderName) {
- return isDecoderInList(directSubmitPrefixes, decoderName) && !isExynos4Device();
- }
-
- public static boolean decoderNeedsSpsBitstreamRestrictions(String decoderName) {
- return isDecoderInList(spsFixupBitstreamFixupDecoderPrefixes, decoderName);
- }
-
- public static boolean decoderNeedsBaselineSpsHack(String decoderName) {
- return isDecoderInList(baselineProfileHackPrefixes, decoderName);
- }
-
- public static byte getDecoderOptimalSlicesPerFrame(String decoderName) {
- if (isDecoderInList(useFourSlicesPrefixes, decoderName)) {
- // 4 slices per frame reduces decoding latency on older Qualcomm devices
- return 4;
- }
- else {
- // 1 slice per frame produces the optimal encoding efficiency
- return 1;
- }
- }
-
- public static boolean decoderSupportsRefFrameInvalidationAvc(String decoderName, int videoHeight) {
- // Reference frame invalidation is broken on low-end Snapdragon SoCs at 1080p.
- if (videoHeight > 720 && isLowEndSnapdragon) {
- return false;
- }
-
- // This device seems to crash constantly at 720p, so try disabling
- // RFI to see if we can get that under control.
- if (Build.DEVICE.equals("b3") || Build.DEVICE.equals("b5")) {
- return false;
- }
-
- return isDecoderInList(refFrameInvalidationAvcPrefixes, decoderName);
- }
-
- public static boolean decoderSupportsRefFrameInvalidationHevc(MediaCodecInfo decoderInfo) {
- // HEVC decoders seem to universally support RFI, but it can have huge latency penalties
- // for some decoders due to the number of references frames being > 1. Old Amlogic
- // decoders are known to have this problem.
- //
- // If the decoder supports FEATURE_LowLatency or any vendor low latency option,
- // we will use that as an indication that it can handle HEVC RFI without excessively
- // buffering frames.
- if (decoderSupportsAndroidRLowLatency(decoderInfo, "video/hevc") ||
- decoderSupportsKnownVendorLowLatencyOption(decoderInfo.getName())) {
- LimeLog.info("Enabling HEVC RFI based on low latency option support");
- return true;
- }
-
- return isDecoderInList(refFrameInvalidationHevcPrefixes, decoderInfo.getName());
- }
-
- public static boolean decoderSupportsRefFrameInvalidationAv1(MediaCodecInfo decoderInfo) {
- // We'll use the same heuristics as HEVC for now
- if (decoderSupportsAndroidRLowLatency(decoderInfo, "video/av01") ||
- decoderSupportsKnownVendorLowLatencyOption(decoderInfo.getName())) {
- LimeLog.info("Enabling AV1 RFI based on low latency option support");
- return true;
- }
-
- return false;
- }
-
- public static boolean decoderIsWhitelistedForHevc(MediaCodecInfo decoderInfo) {
- //
- // Software decoders are terrible and we never want to use them.
- // We want to catch decoders like:
- // OMX.qcom.video.decoder.hevcswvdec
- // OMX.SEC.hevc.sw.dec
- //
- if (decoderInfo.getName().contains("sw")) {
- LimeLog.info("Disallowing HEVC on software decoder: " + decoderInfo.getName());
- return false;
- }
- else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && (!decoderInfo.isHardwareAccelerated() || decoderInfo.isSoftwareOnly())) {
- LimeLog.info("Disallowing HEVC on software decoder: " + decoderInfo.getName());
- return false;
- }
-
- // If this device is media performance class 12 or higher, we will assume any hardware
- // HEVC decoder present is fast and modern enough for streaming.
- //
- // [5.3/H-1-1] MUST NOT drop more than 2 frames in 10 seconds (i.e less than 0.333 percent frame drop) for a 1080p 60 fps video session under load.
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- LimeLog.info("Media performance class: " + Build.VERSION.MEDIA_PERFORMANCE_CLASS);
- if (Build.VERSION.MEDIA_PERFORMANCE_CLASS >= Build.VERSION_CODES.S) {
- LimeLog.info("Allowing HEVC based on media performance class");
- return true;
- }
- }
-
- // If the decoder supports FEATURE_LowLatency, we will assume it is fast and modern enough
- // to be preferable for streaming over H.264 decoders.
- if (decoderSupportsAndroidRLowLatency(decoderInfo, "video/hevc")) {
- LimeLog.info("Allowing HEVC based on FEATURE_LowLatency support");
- return true;
- }
-
- // Otherwise, we use our list of known working HEVC decoders
- return isDecoderInList(whitelistedHevcDecoders, decoderInfo.getName());
- }
-
- public static boolean isDecoderWhitelistedForAv1(MediaCodecInfo decoderInfo) {
- // Google didn't have official support for AV1 (or more importantly, a CTS test) until
- // Android 10, so don't use any decoder before then.
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
- return false;
- }
-
- //
- // Software decoders are terrible and we never want to use them.
- // We want to catch decoders like:
- // OMX.qcom.video.decoder.hevcswvdec
- // OMX.SEC.hevc.sw.dec
- //
- if (decoderInfo.getName().contains("sw")) {
- LimeLog.info("Disallowing AV1 on software decoder: " + decoderInfo.getName());
- return false;
- }
- else if (!decoderInfo.isHardwareAccelerated() || decoderInfo.isSoftwareOnly()) {
- LimeLog.info("Disallowing AV1 on software decoder: " + decoderInfo.getName());
- return false;
- }
-
- // TODO: Test some AV1 decoders
- return false;
- }
-
- @SuppressWarnings("deprecation")
- @SuppressLint("NewApi")
- private static LinkedList getMediaCodecList() {
- LinkedList infoList = new LinkedList<>();
-
- MediaCodecList mcl = new MediaCodecList(MediaCodecList.REGULAR_CODECS);
- Collections.addAll(infoList, mcl.getCodecInfos());
-
- return infoList;
- }
-
- @SuppressWarnings("RedundantThrows")
- public static String dumpDecoders() throws Exception {
- String str = "";
- for (MediaCodecInfo codecInfo : getMediaCodecList()) {
- // Skip encoders
- if (codecInfo.isEncoder()) {
- continue;
- }
-
- str += "Decoder: "+codecInfo.getName()+"\n";
- for (String type : codecInfo.getSupportedTypes()) {
- str += "\t"+type+"\n";
- CodecCapabilities caps = codecInfo.getCapabilitiesForType(type);
-
- for (CodecProfileLevel profile : caps.profileLevels) {
- str += "\t\t"+profile.profile+" "+profile.level+"\n";
- }
- }
- }
- return str;
- }
-
- private static MediaCodecInfo findPreferredDecoder() {
- // This is a different algorithm than the other findXXXDecoder functions,
- // because we want to evaluate the decoders in our list's order
- // rather than MediaCodecList's order
-
- if (!initialized) {
- throw new IllegalStateException("MediaCodecHelper must be initialized before use");
- }
-
- for (String preferredDecoder : preferredDecoders) {
- for (MediaCodecInfo codecInfo : getMediaCodecList()) {
- // Skip encoders
- if (codecInfo.isEncoder()) {
- continue;
- }
-
- // Check for preferred decoders
- if (preferredDecoder.equalsIgnoreCase(codecInfo.getName())) {
- LimeLog.info("Preferred decoder choice is "+codecInfo.getName());
- return codecInfo;
- }
- }
- }
-
- return null;
- }
-
- private static boolean isCodecBlacklisted(MediaCodecInfo codecInfo) {
- // Use the new isSoftwareOnly() function on Android Q
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- if (!SHOULD_BYPASS_SOFTWARE_BLOCK && codecInfo.isSoftwareOnly()) {
- LimeLog.info("Skipping software-only decoder: "+codecInfo.getName());
- return true;
- }
- }
-
- // Check for explicitly blacklisted decoders
- if (isDecoderInList(blacklistedDecoderPrefixes, codecInfo.getName())) {
- LimeLog.info("Skipping blacklisted decoder: "+codecInfo.getName());
- return true;
- }
-
- return false;
- }
-
- public static MediaCodecInfo findFirstDecoder(String mimeType) {
- for (MediaCodecInfo codecInfo : getMediaCodecList()) {
- // Skip encoders
- if (codecInfo.isEncoder()) {
- continue;
- }
-
- // Skip compatibility aliases on Q+
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- if (codecInfo.isAlias()) {
- continue;
- }
- }
-
- // Find a decoder that supports the specified video format
- for (String mime : codecInfo.getSupportedTypes()) {
- if (mime.equalsIgnoreCase(mimeType)) {
- // Skip blacklisted codecs
- if (isCodecBlacklisted(codecInfo)) {
- continue;
- }
-
- LimeLog.info("First decoder choice is "+codecInfo.getName());
- return codecInfo;
- }
- }
- }
-
- return null;
- }
-
- public static MediaCodecInfo findProbableSafeDecoder(String mimeType, int requiredProfile) {
- // First look for a preferred decoder by name
- MediaCodecInfo info = findPreferredDecoder();
- if (info != null) {
- return info;
- }
-
- // Now look for decoders we know are safe
- try {
- // If this function completes, it will determine if the decoder is safe
- return findKnownSafeDecoder(mimeType, requiredProfile);
- } catch (Exception e) {
- // Some buggy devices seem to throw exceptions
- // from getCapabilitiesForType() so we'll just assume
- // they're okay and go with the first one we find
- return findFirstDecoder(mimeType);
- }
- }
-
- // We declare this method as explicitly throwing Exception
- // since some bad decoders can throw IllegalArgumentExceptions unexpectedly
- // and we want to be sure all callers are handling this possibility
- @SuppressWarnings("RedundantThrows")
- private static MediaCodecInfo findKnownSafeDecoder(String mimeType, int requiredProfile) throws Exception {
- // Some devices (Exynos devces, at least) have two sets of decoders.
- // The first set of decoders are C2 which do not support FEATURE_LowLatency,
- // but the second set of OMX decoders do support FEATURE_LowLatency. We want
- // to pick the OMX decoders despite the fact that C2 is listed first.
- // On some Qualcomm devices (like Pixel 4), there are separate low latency decoders
- // (like c2.qti.hevc.decoder.low_latency) that advertise FEATURE_LowLatency while
- // the standard ones (like c2.qti.hevc.decoder) do not. Like Exynos, the decoders
- // with FEATURE_LowLatency support are listed after the standard ones.
- for (int i = 0; i < 2; i++) {
- for (MediaCodecInfo codecInfo : getMediaCodecList()) {
- // Skip encoders
- if (codecInfo.isEncoder()) {
- continue;
- }
-
- // Skip compatibility aliases on Q+
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- if (codecInfo.isAlias()) {
- continue;
- }
- }
-
- // Find a decoder that supports the requested video format
- for (String mime : codecInfo.getSupportedTypes()) {
- if (mime.equalsIgnoreCase(mimeType)) {
- LimeLog.info("Examining decoder capabilities of " + codecInfo.getName() + " (round " + (i + 1) + ")");
-
- // Skip blacklisted codecs
- if (isCodecBlacklisted(codecInfo)) {
- continue;
- }
-
- CodecCapabilities caps = codecInfo.getCapabilitiesForType(mime);
-
- if (i == 0 && !decoderSupportsAndroidRLowLatency(codecInfo, mime)) {
- LimeLog.info("Skipping decoder that lacks FEATURE_LowLatency for round 1");
- continue;
- }
-
- if (requiredProfile != -1) {
- for (CodecProfileLevel profile : caps.profileLevels) {
- if (profile.profile == requiredProfile) {
- LimeLog.info("Decoder " + codecInfo.getName() + " supports required profile");
- return codecInfo;
- }
- }
-
- LimeLog.info("Decoder " + codecInfo.getName() + " does NOT support required profile");
- } else {
- return codecInfo;
- }
- }
- }
- }
- }
-
- return null;
- }
-
- public static String readCpuinfo() throws Exception {
- StringBuilder cpuInfo = new StringBuilder();
- try (final BufferedReader br = new BufferedReader(new FileReader(new File("/proc/cpuinfo")))) {
- for (;;) {
- int ch = br.read();
- if (ch == -1)
- break;
- cpuInfo.append((char)ch);
- }
-
- return cpuInfo.toString();
- }
- }
-
- private static boolean stringContainsIgnoreCase(String string, String substring) {
- return string.toLowerCase(Locale.ENGLISH).contains(substring.toLowerCase(Locale.ENGLISH));
- }
-
- public static boolean isExynos4Device() {
- try {
- // Try reading CPU info too look for
- String cpuInfo = readCpuinfo();
-
- // SMDK4xxx is Exynos 4
- if (stringContainsIgnoreCase(cpuInfo, "SMDK4")) {
- LimeLog.info("Found SMDK4 in /proc/cpuinfo");
- return true;
- }
-
- // If we see "Exynos 4" also we'll count it
- if (stringContainsIgnoreCase(cpuInfo, "Exynos 4")) {
- LimeLog.info("Found Exynos 4 in /proc/cpuinfo");
- return true;
- }
- } catch (Exception e) {
- e.printStackTrace();
- }
-
- try {
- File systemDir = new File("/sys/devices/system");
- File[] files = systemDir.listFiles();
- if (files != null) {
- for (File f : files) {
- if (stringContainsIgnoreCase(f.getName(), "exynos4")) {
- LimeLog.info("Found exynos4 in /sys/devices/system");
- return true;
- }
- }
- }
- } catch (Exception e) {
- e.printStackTrace();
- }
-
- return false;
- }
-}
+package com.limelight.binding.video;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileReader;
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import android.annotation.SuppressLint;
+import android.app.ActivityManager;
+import android.content.Context;
+import android.content.pm.ConfigurationInfo;
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecList;
+import android.media.MediaCodecInfo.CodecCapabilities;
+import android.media.MediaCodecInfo.CodecProfileLevel;
+import android.media.MediaFormat;
+import android.os.Build;
+
+import com.limelight.LimeLog;
+import com.limelight.preferences.PreferenceConfiguration;
+
+public class MediaCodecHelper {
+
+ private static final List preferredDecoders;
+
+ private static final List blacklistedDecoderPrefixes;
+ private static final List spsFixupBitstreamFixupDecoderPrefixes;
+ private static final List blacklistedAdaptivePlaybackPrefixes;
+ private static final List baselineProfileHackPrefixes;
+ private static final List directSubmitPrefixes;
+ private static final List constrainedHighProfilePrefixes;
+ private static final List whitelistedHevcDecoders;
+ private static final List refFrameInvalidationAvcPrefixes;
+ private static final List refFrameInvalidationHevcPrefixes;
+ private static final List useFourSlicesPrefixes;
+ private static final List qualcommDecoderPrefixes;
+ private static final List kirinDecoderPrefixes;
+ private static final List exynosDecoderPrefixes;
+ private static final List amlogicDecoderPrefixes;
+ private static final List knownVendorLowLatencyOptions;
+
+ public static final boolean SHOULD_BYPASS_SOFTWARE_BLOCK =
+ Build.HARDWARE.equals("ranchu") || Build.HARDWARE.equals("cheets") || Build.BRAND.equals("Android-x86");
+
+ private static boolean isLowEndSnapdragon = false;
+ private static boolean isAdreno620 = false;
+ private static boolean initialized = false;
+
+ static {
+ directSubmitPrefixes = new LinkedList<>();
+
+ // These decoders have low enough input buffer latency that they
+ // can be directly invoked from the receive thread
+ directSubmitPrefixes.add("omx.qcom");
+ directSubmitPrefixes.add("omx.sec");
+ directSubmitPrefixes.add("omx.exynos");
+ directSubmitPrefixes.add("omx.intel");
+ directSubmitPrefixes.add("omx.brcm");
+ directSubmitPrefixes.add("omx.TI");
+ directSubmitPrefixes.add("omx.arc");
+ directSubmitPrefixes.add("omx.nvidia");
+
+ // All Codec2 decoders
+ directSubmitPrefixes.add("c2.");
+ }
+
+ static {
+ refFrameInvalidationAvcPrefixes = new LinkedList<>();
+
+ refFrameInvalidationHevcPrefixes = new LinkedList<>();
+ refFrameInvalidationHevcPrefixes.add("omx.exynos");
+ refFrameInvalidationHevcPrefixes.add("c2.exynos");
+
+ // Qualcomm and NVIDIA may be added at runtime
+ }
+
+ static {
+ preferredDecoders = new LinkedList<>();
+ }
+
+ static {
+ blacklistedDecoderPrefixes = new LinkedList<>();
+
+ // Blacklist software decoders that don't support H264 high profile except on systems
+ // that are expected to only have software decoders (like emulators).
+ if (!SHOULD_BYPASS_SOFTWARE_BLOCK) {
+ blacklistedDecoderPrefixes.add("omx.google");
+ blacklistedDecoderPrefixes.add("AVCDecoder");
+
+ // We want to avoid ffmpeg decoders since they're usually software decoders,
+ // but we'll defer to the Android 10 isSoftwareOnly() API on newer devices
+ // to determine if we should use these or not.
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ blacklistedDecoderPrefixes.add("OMX.ffmpeg");
+ }
+ }
+
+ // Force these decoders disabled because:
+ // 1) They are software decoders, so the performance is terrible
+ // 2) They crash with our HEVC stream anyway (at least prior to CSD batching)
+ blacklistedDecoderPrefixes.add("OMX.qcom.video.decoder.hevcswvdec");
+ blacklistedDecoderPrefixes.add("OMX.SEC.hevc.sw.dec");
+ }
+
+ static {
+ // If a decoder qualifies for reference frame invalidation,
+ // these entries will be ignored for those decoders.
+ spsFixupBitstreamFixupDecoderPrefixes = new LinkedList<>();
+ spsFixupBitstreamFixupDecoderPrefixes.add("omx.nvidia");
+ spsFixupBitstreamFixupDecoderPrefixes.add("omx.qcom");
+ spsFixupBitstreamFixupDecoderPrefixes.add("omx.brcm");
+
+ baselineProfileHackPrefixes = new LinkedList<>();
+ baselineProfileHackPrefixes.add("omx.intel");
+
+ blacklistedAdaptivePlaybackPrefixes = new LinkedList<>();
+ // The Intel decoder on Lollipop on Nexus Player would increase latency badly
+ // if adaptive playback was enabled so let's avoid it to be safe.
+ blacklistedAdaptivePlaybackPrefixes.add("omx.intel");
+ // The MediaTek decoder crashes at 1080p when adaptive playback is enabled
+ // on some Android TV devices with HEVC only.
+ blacklistedAdaptivePlaybackPrefixes.add("omx.mtk");
+
+ constrainedHighProfilePrefixes = new LinkedList<>();
+ constrainedHighProfilePrefixes.add("omx.intel");
+ }
+
+ static {
+ whitelistedHevcDecoders = new LinkedList<>();
+
+ // Allow software HEVC decoding in the official AOSP emulator
+ if (Build.HARDWARE.equals("ranchu")) {
+ whitelistedHevcDecoders.add("omx.google");
+ }
+
+ // Exynos seems to be the only HEVC decoder that works reliably
+ whitelistedHevcDecoders.add("omx.exynos");
+
+ // On Darcy (Shield 2017), HEVC runs fine with no fixups required. For some reason,
+ // other X1 implementations require bitstream fixups. However, since numReferenceFrames
+ // has been supported in GFE since late 2017, we'll go ahead and enable HEVC for all
+ // device models.
+ //
+ // NVIDIA does partial HEVC acceleration on the Shield Tablet. I don't know
+ // whether the performance is good enough to use for streaming, but they're
+ // using the same omx.nvidia.h265.decode name as the Shield TV which has a
+ // fully accelerated HEVC pipeline. AFAIK, the only K1 devices with this
+ // partially accelerated HEVC decoder are the Shield Tablet and Xiaomi MiPad,
+ // so I'll check for those here.
+ //
+ // In case there are some that I missed, I will also exclude pre-Oreo OSes since
+ // only Shield ATV got an Oreo update and any newer Tegra devices will not ship
+ // with an old OS like Nougat.
+ if (!Build.DEVICE.equalsIgnoreCase("shieldtablet") &&
+ !Build.DEVICE.equalsIgnoreCase("mocha") &&
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ whitelistedHevcDecoders.add("omx.nvidia");
+ }
+
+ // Plot twist: On newer Sony devices (BRAVIA_ATV2, BRAVIA_ATV3_4K, BRAVIA_UR1_4K) the H.264 decoder crashes
+ // on several configurations (> 60 FPS and 1440p) that work with HEVC, so we'll whitelist those devices for HEVC.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && Build.DEVICE.startsWith("BRAVIA_")) {
+ whitelistedHevcDecoders.add("omx.mtk");
+ }
+
+ // Amlogic requires 1 reference frame for HEVC to avoid hanging. Since it's been years
+ // since GFE added support for maxNumReferenceFrames, we'll just enable all Amlogic SoCs
+ // running Android 9 or later.
+ //
+ // NB: We don't do this on Sabrina (GCWGTV) because H.264 is lower latency when we use
+ // vendor.low-latency.enable. We will still use HEVC if decoderCanMeetPerformancePointWithHevcAndNotAvc()
+ // determines it's the only way to meet the performance requirements.
+ //
+ // With the Android 12 update, Sabrina now uses HEVC (with RFI) based upon FEATURE_LowLatency
+ // support, which provides equivalent latency to H.264 now.
+ //
+ // FIXME: Should we do this for all Amlogic S905X SoCs?
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && !Build.DEVICE.equalsIgnoreCase("sabrina")) {
+ whitelistedHevcDecoders.add("omx.amlogic");
+ }
+
+ // Realtek SoCs are used inside many Android TV devices and can only do 4K60 with HEVC.
+ // We'll enable those HEVC decoders by default and see if anything breaks.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ whitelistedHevcDecoders.add("omx.realtek");
+ }
+
+ // These theoretically have good HEVC decoding capabilities (potentially better than
+ // their AVC decoders), but haven't been tested enough
+ //whitelistedHevcDecoders.add("omx.rk");
+
+ // Let's see if HEVC decoders are finally stable with C2
+ whitelistedHevcDecoders.add("c2.");
+
+ // Based on GPU attributes queried at runtime, the omx.qcom/c2.qti prefix will be added
+ // during initialization to avoid SoCs with broken HEVC decoders.
+ }
+
+ static {
+ useFourSlicesPrefixes = new LinkedList<>();
+
+ // Software decoders will use 4 slices per frame to allow for slice multithreading
+ useFourSlicesPrefixes.add("omx.google");
+ useFourSlicesPrefixes.add("AVCDecoder");
+ useFourSlicesPrefixes.add("omx.ffmpeg");
+ useFourSlicesPrefixes.add("c2.android");
+
+ // Old Qualcomm decoders are detected at runtime
+ }
+
+ static {
+ knownVendorLowLatencyOptions = new LinkedList<>();
+
+ knownVendorLowLatencyOptions.add("vendor.qti-ext-dec-low-latency.enable");
+ knownVendorLowLatencyOptions.add("vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-req");
+ knownVendorLowLatencyOptions.add("vendor.rtc-ext-dec-low-latency.enable");
+ knownVendorLowLatencyOptions.add("vendor.low-latency.enable");
+ }
+
+ static {
+ qualcommDecoderPrefixes = new LinkedList<>();
+
+ qualcommDecoderPrefixes.add("omx.qcom");
+ qualcommDecoderPrefixes.add("c2.qti");
+ }
+
+ static {
+ kirinDecoderPrefixes = new LinkedList<>();
+
+ kirinDecoderPrefixes.add("omx.hisi");
+ kirinDecoderPrefixes.add("c2.hisi"); // Unconfirmed
+ }
+
+ static {
+ exynosDecoderPrefixes = new LinkedList<>();
+
+ exynosDecoderPrefixes.add("omx.exynos");
+ exynosDecoderPrefixes.add("c2.exynos");
+ }
+
+ static {
+ amlogicDecoderPrefixes = new LinkedList<>();
+
+ amlogicDecoderPrefixes.add("omx.amlogic");
+ amlogicDecoderPrefixes.add("c2.amlogic"); // Unconfirmed
+ }
+
+ private static boolean isPowerVR(String glRenderer) {
+ return glRenderer.toLowerCase().contains("powervr");
+ }
+
+ private static String getAdrenoVersionString(String glRenderer) {
+ glRenderer = glRenderer.toLowerCase().trim();
+
+ if (!glRenderer.contains("adreno")) {
+ return null;
+ }
+
+ Pattern modelNumberPattern = Pattern.compile("(.*)([0-9]{3})(.*)");
+
+ Matcher matcher = modelNumberPattern.matcher(glRenderer);
+ if (!matcher.matches()) {
+ return null;
+ }
+
+ String modelNumber = matcher.group(2);
+ LimeLog.info("Found Adreno GPU: "+modelNumber);
+ return modelNumber;
+ }
+
+ private static boolean isLowEndSnapdragonRenderer(String glRenderer) {
+ String modelNumber = getAdrenoVersionString(glRenderer);
+ if (modelNumber == null) {
+ // Not an Adreno GPU
+ return false;
+ }
+
+ // The current logic is to identify low-end SoCs based on a zero in the x0x place.
+ return modelNumber.charAt(1) == '0';
+ }
+
+ private static int getAdrenoRendererModelNumber(String glRenderer) {
+ String modelNumber = getAdrenoVersionString(glRenderer);
+ if (modelNumber == null) {
+ // Not an Adreno GPU
+ return -1;
+ }
+
+ return Integer.parseInt(modelNumber);
+ }
+
+ // This is a workaround for some broken devices that report
+ // only GLES 3.0 even though the GPU is an Adreno 4xx series part.
+ // An example of such a device is the Huawei Honor 5x with the
+ // Snapdragon 616 SoC (Adreno 405).
+ private static boolean isGLES31SnapdragonRenderer(String glRenderer) {
+ // Snapdragon 4xx and higher support GLES 3.1
+ return getAdrenoRendererModelNumber(glRenderer) >= 400;
+ }
+
+ public static void initialize(Context context, String glRenderer) {
+ if (initialized) {
+ return;
+ }
+
+ // Older Sony ATVs (SVP-DTV15) have broken MediaTek codecs (decoder hangs after rendering the first frame).
+ // I know the Fire TV 2 and 3 works, so I'll whitelist Amazon devices which seem to actually be tested.
+ // We still have to check Build.MANUFACTURER to catch Amazon Fire tablets.
+ if (context.getPackageManager().hasSystemFeature("amazon.hardware.fire_tv") ||
+ Build.MANUFACTURER.equalsIgnoreCase("Amazon")) {
+ // HEVC and RFI have been confirmed working on Fire TV 2, Fire TV Stick 2, Fire TV 4K Max,
+ // Fire HD 8 2020, and Fire HD 8 2022 models.
+ //
+ // This is probably a good enough sample to conclude that all MediaTek Fire OS devices
+ // are likely to be okay.
+ whitelistedHevcDecoders.add("omx.mtk");
+ refFrameInvalidationHevcPrefixes.add("omx.mtk");
+ refFrameInvalidationHevcPrefixes.add("c2.mtk");
+
+ // This requires setting vdec-lowlatency on the Fire TV 3, otherwise the decoder
+ // never produces any output frames. See comment above for details on why we only
+ // do this for Fire TV devices.
+ whitelistedHevcDecoders.add("omx.amlogic");
+
+ // Fire TV 3 seems to produce random artifacts on HEVC streams after packet loss.
+ // Enabling RFI turns these artifacts into full decoder output hangs, so let's not enable
+ // that for Fire OS 6 Amlogic devices. We will leave HEVC enabled because that's the only
+ // way these devices can hit 4K. Hopefully this is just a problem with the BSP used in
+ // the Fire OS 6 Amlogic devices, so we will leave this enabled for Fire OS 7+.
+ //
+ // Apart from a few TV models, the main Amlogic-based Fire TV devices are the Fire TV
+ // Cubes and Fire TV 3. This check will exclude the Fire TV 3 and Fire TV Cube 1, but
+ // allow the newer Fire TV Cubes to use HEVC RFI.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ refFrameInvalidationHevcPrefixes.add("omx.amlogic");
+ refFrameInvalidationHevcPrefixes.add("c2.amlogic");
+ }
+ }
+
+ ActivityManager activityManager =
+ (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
+ ConfigurationInfo configInfo = activityManager.getDeviceConfigurationInfo();
+ if (configInfo.reqGlEsVersion != ConfigurationInfo.GL_ES_VERSION_UNDEFINED) {
+ LimeLog.info("OpenGL ES version: "+configInfo.reqGlEsVersion);
+
+ isLowEndSnapdragon = isLowEndSnapdragonRenderer(glRenderer);
+ isAdreno620 = getAdrenoRendererModelNumber(glRenderer) == 620;
+
+ // Tegra K1 and later can do reference frame invalidation properly
+ if (configInfo.reqGlEsVersion >= 0x30000) {
+ LimeLog.info("Added omx.nvidia/c2.nvidia to reference frame invalidation support list");
+ refFrameInvalidationAvcPrefixes.add("omx.nvidia");
+
+ // Exclude HEVC RFI on Pixel C and Tegra devices prior to Android 11. Misbehaving RFI
+ // on these devices can cause hundreds of milliseconds of latency, so it's not worth
+ // using it unless we're absolutely sure that it will not cause increased latency.
+ if (!Build.DEVICE.equalsIgnoreCase("dragon") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ refFrameInvalidationHevcPrefixes.add("omx.nvidia");
+ }
+
+ refFrameInvalidationAvcPrefixes.add("c2.nvidia"); // Unconfirmed
+ refFrameInvalidationHevcPrefixes.add("c2.nvidia"); // Unconfirmed
+
+ LimeLog.info("Added omx.qcom/c2.qti to reference frame invalidation support list");
+ refFrameInvalidationAvcPrefixes.add("omx.qcom");
+ refFrameInvalidationHevcPrefixes.add("omx.qcom");
+ refFrameInvalidationAvcPrefixes.add("c2.qti");
+ refFrameInvalidationHevcPrefixes.add("c2.qti");
+ }
+
+ // Qualcomm's early HEVC decoders break hard on our HEVC stream. The best check to
+ // tell the good from the bad decoders are the generation of Adreno GPU included:
+ // 3xx - bad
+ // 4xx - good
+ //
+ // The "good" GPUs support GLES 3.1, but we can't just check that directly
+ // (see comment on isGLES31SnapdragonRenderer).
+ //
+ if (isGLES31SnapdragonRenderer(glRenderer)) {
+ LimeLog.info("Added omx.qcom/c2.qti to HEVC decoders based on GLES 3.1+ support");
+ whitelistedHevcDecoders.add("omx.qcom");
+ whitelistedHevcDecoders.add("c2.qti");
+ }
+ else {
+ blacklistedDecoderPrefixes.add("OMX.qcom.video.decoder.hevc");
+
+ // These older decoders need 4 slices per frame for best performance
+ useFourSlicesPrefixes.add("omx.qcom");
+ }
+
+ // Older MediaTek SoCs have issues with HEVC rendering but the newer chips with
+ // PowerVR GPUs have good HEVC support.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isPowerVR(glRenderer)) {
+ LimeLog.info("Added omx.mtk to HEVC decoders based on PowerVR GPU");
+ whitelistedHevcDecoders.add("omx.mtk");
+
+ // This SoC (MT8176 in GPD XD+) supports AVC RFI too, but the maxNumReferenceFrames setting
+ // required to make it work adds a huge amount of latency. However, RFI on HEVC causes
+ // decoder hangs on the newer GE8100, GE8300, and GE8320 GPUs, so we limit it to the
+ // Series6XT GPUs where we know it works.
+ if (glRenderer.contains("GX6")) {
+ LimeLog.info("Added omx.mtk/c2.mtk to RFI list for HEVC");
+ refFrameInvalidationHevcPrefixes.add("omx.mtk");
+ refFrameInvalidationHevcPrefixes.add("c2.mtk");
+ }
+ }
+ }
+
+ initialized = true;
+ }
+
+ private static boolean isDecoderInList(List decoderList, String decoderName) {
+ if (!initialized) {
+ throw new IllegalStateException("MediaCodecHelper must be initialized before use");
+ }
+
+ for (String badPrefix : decoderList) {
+ if (decoderName.length() >= badPrefix.length()) {
+ String prefix = decoderName.substring(0, badPrefix.length());
+ if (prefix.equalsIgnoreCase(badPrefix)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private static boolean decoderSupportsAndroidRLowLatency(MediaCodecInfo decoderInfo, String mimeType) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ try {
+ if (decoderInfo.getCapabilitiesForType(mimeType).isFeatureSupported(CodecCapabilities.FEATURE_LowLatency)) {
+ LimeLog.info("Low latency decoding mode supported (FEATURE_LowLatency)");
+ return true;
+ }
+ } catch (Exception e) {
+ // Tolerate buggy codecs
+ e.printStackTrace();
+ }
+ }
+
+ return false;
+ }
+
+ private static boolean decoderSupportsKnownVendorLowLatencyOption(String decoderName) {
+ // It's only possible to probe vendor parameters on Android 12 and above.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ MediaCodec testCodec = null;
+ try {
+ // Unfortunately we have to create an actual codec instance to get supported options.
+ testCodec = MediaCodec.createByCodecName(decoderName);
+
+ // See if any of the vendor parameters match ones we know about
+ for (String supportedOption : testCodec.getSupportedVendorParameters()) {
+ for (String knownLowLatencyOption : knownVendorLowLatencyOptions) {
+ if (supportedOption.equalsIgnoreCase(knownLowLatencyOption)) {
+ LimeLog.info(decoderName + " supports known low latency option: " + supportedOption);
+ return true;
+ }
+ }
+ }
+ } catch (Exception e) {
+ // Tolerate buggy codecs
+ e.printStackTrace();
+ } finally {
+ if (testCodec != null) {
+ testCodec.release();
+ }
+ }
+ }
+ return false;
+ }
+
+ private static boolean decoderSupportsMaxOperatingRate(String decoderName) {
+ // Operate at maximum rate to lower latency as much as possible on
+ // some Qualcomm platforms. We could also set KEY_PRIORITY to 0 (realtime)
+ // but that will actually result in the decoder crashing if it can't satisfy
+ // our (ludicrous) operating rate requirement. This seems to cause reliable
+ // crashes on the Xiaomi Mi 10 lite 5G and Redmi K30i 5G on Android 10, so
+ // we'll disable it on Snapdragon 765G and all non-Qualcomm devices to be safe.
+ //
+ // NB: Even on Android 10, this optimization still provides significant
+ // performance gains on Pixel 2.
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
+ isDecoderInList(qualcommDecoderPrefixes, decoderName) &&
+ !isAdreno620;
+ }
+
+ public static boolean setDecoderLowLatencyOptions(MediaFormat videoFormat, MediaCodecInfo decoderInfo, boolean ultraLowLatency, int tryNumber) {
+ // Options here should be tried in the order of most to least risky. The decoder will use
+ // the first MediaFormat that doesn't fail in configure().
+
+ boolean setNewOption = false;
+
+ if (tryNumber < 1) {
+ // Official Android 11+ low latency option (KEY_LOW_LATENCY).
+ videoFormat.setInteger("low-latency", 1);
+ setNewOption = true;
+
+ // If this decoder officially supports FEATURE_LowLatency, we will just use that alone
+ // for try 0. Otherwise, we'll include it as best effort with other options.
+ if (!ultraLowLatency && decoderSupportsAndroidRLowLatency(decoderInfo, videoFormat.getString(MediaFormat.KEY_MIME))) {
+ return true;
+ }
+
+ // ALONSOJR1980: "low-latency" is not enough, continuing to add specific extensions
+ }
+
+ if (tryNumber < 2 &&
+ (!Build.MANUFACTURER.equalsIgnoreCase("xiaomi") || Build.VERSION.SDK_INT > Build.VERSION_CODES.M)) {
+ // MediaTek decoders don't use vendor-defined keys for low latency mode. Instead, they have a modified
+ // version of AOSP's ACodec.cpp which supports the "vdec-lowlatency" option. This option is passed down
+ // to the decoder as OMX.MTK.index.param.video.LowLatencyDecode.
+ //
+ // This option is also plumbed for Amazon Amlogic-based devices like the Fire TV 3. Not only does it
+ // reduce latency on Amlogic, it fixes the HEVC bug that causes the decoder to not output any frames.
+ // Unfortunately, it does the exact opposite for the Xiaomi MITV4-ANSM0, breaking it in the way that
+ // Fire TV was broken prior to vdec-lowlatency :(
+ //
+ // On Fire TV 3, vdec-lowlatency is translated to OMX.amazon.fireos.index.video.lowLatencyDecode.
+ //
+ // https://github.com/yuan1617/Framwork/blob/master/frameworks/av/media/libstagefright/ACodec.cpp
+ // https://github.com/iykex/vendor_mediatek_proprietary_hardware/blob/master/libomx/video/MtkOmxVdecEx/MtkOmxVdecEx.h
+ videoFormat.setInteger("vdec-lowlatency", 1);
+ setNewOption = true;
+ }
+
+ if (tryNumber < 3) {
+ if (MediaCodecHelper.decoderSupportsMaxOperatingRate(decoderInfo.getName())) {
+ videoFormat.setInteger(MediaFormat.KEY_OPERATING_RATE, Short.MAX_VALUE);
+ setNewOption = true;
+ }
+ else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ videoFormat.setInteger(MediaFormat.KEY_PRIORITY, 0);
+ setNewOption = true;
+ }
+ }
+
+ // MediaCodec supports vendor-defined format keys using the "vendor.." syntax.
+ // These allow access to functionality that is not exposed through documented MediaFormat.KEY_* values.
+ // https://cs.android.com/android/platform/superproject/+/master:hardware/qcom/sdm845/media/mm-video-v4l2/vidc/common/inc/vidc_vendor_extensions.h;l=67
+ //
+ // MediaCodec vendor extension support was introduced in Android 8.0:
+ // https://cs.android.com/android/_/android/platform/frameworks/av/+/01c10f8cdcd58d1e7025f426a72e6e75ba5d7fc2
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ // Try vendor-specific low latency options
+ //
+ // NOTE: Update knownVendorLowLatencyOptions if you modify this code!
+ if (isDecoderInList(qualcommDecoderPrefixes, decoderInfo.getName())) {
+ // Examples of Qualcomm's vendor extensions for Snapdragon 845:
+ // https://cs.android.com/android/platform/superproject/+/master:hardware/qcom/sdm845/media/mm-video-v4l2/vidc/vdec/src/omx_vdec_extensions.hpp
+ // https://cs.android.com/android/_/android/platform/hardware/qcom/sm8150/media/+/0621ceb1c1b19564999db8293574a0e12952ff6c
+ //
+ // We will first try both, then try vendor.qti-ext-dec-low-latency.enable alone if that fails
+ if (tryNumber < 4) {
+ videoFormat.setInteger("vendor.qti-ext-dec-picture-order.enable", 1);
+ setNewOption = true;
+ }
+ if (tryNumber < 5) {
+ videoFormat.setInteger("vendor.qti-ext-dec-low-latency.enable", 1);
+
+ //ALONSOJR1980 - CONFIRMED WORKING: Snapdragon Elite, SD8 gen 3, SD8 gen 2
+ //latency-wise, software fencing is the most important flag for latest Snapdragons
+ videoFormat.setInteger("vendor.qti-ext-output-sw-fence-enable.value", 1); //Snapdragon 8 gen 2
+ videoFormat.setInteger("vendor.qti-ext-output-fence.enable", 1); // Snapdragon 8s Gen 3 and Elite
+ videoFormat.setInteger("vendor.qti-ext-output-fence.fence_type", 1); // Snapdragon 8s Gen 3 and ELite / 0 = none, 1 = sw, 2 = hw, 3 = hybrid. Best option = 1
+ ////////////////////////////////////////////////////////////////////////////////
+
+ setNewOption = true;
+ }
+ }
+ else if (isDecoderInList(kirinDecoderPrefixes, decoderInfo.getName())) {
+ if (tryNumber < 4) {
+ // Kirin low latency options
+ // https://developer.huawei.com/consumer/cn/forum/topic/0202325564295980115
+ videoFormat.setInteger("vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-req", 1);
+ videoFormat.setInteger("vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-rdy", -1);
+ setNewOption = true;
+ }
+ }
+ else if (isDecoderInList(exynosDecoderPrefixes, decoderInfo.getName())) {
+ if (tryNumber < 4) {
+ // Exynos low latency option for H.264 decoder
+ videoFormat.setInteger("vendor.rtc-ext-dec-low-latency.enable", 1);
+ setNewOption = true;
+ }
+ }
+ else if (isDecoderInList(amlogicDecoderPrefixes, decoderInfo.getName())) {
+ if (tryNumber < 4) {
+ // Amlogic low latency vendor extension
+ // https://github.com/codewalkerster/android_vendor_amlogic_common_prebuilt_libstagefrighthw/commit/41fefc4e035c476d58491324a5fe7666bfc2989e
+ videoFormat.setInteger("vendor.low-latency.enable", 1);
+ setNewOption = true;
+ }
+ }
+ }
+
+ return setNewOption;
+ }
+
+ public static boolean decoderSupportsFusedIdrFrame(MediaCodecInfo decoderInfo, String mimeType) {
+ // If adaptive playback is supported, we can submit new CSD together with a keyframe
+ try {
+ if (decoderInfo.getCapabilitiesForType(mimeType).
+ isFeatureSupported(CodecCapabilities.FEATURE_AdaptivePlayback)) {
+ LimeLog.info("Decoder supports fused IDR frames (FEATURE_AdaptivePlayback)");
+ return true;
+ }
+ } catch (Exception e) {
+ // Tolerate buggy codecs
+ e.printStackTrace();
+ }
+
+ return false;
+ }
+
+ public static boolean decoderSupportsAdaptivePlayback(MediaCodecInfo decoderInfo, String mimeType) {
+ if (isDecoderInList(blacklistedAdaptivePlaybackPrefixes, decoderInfo.getName())) {
+ LimeLog.info("Decoder blacklisted for adaptive playback");
+ return false;
+ }
+
+ try {
+ if (decoderInfo.getCapabilitiesForType(mimeType).
+ isFeatureSupported(CodecCapabilities.FEATURE_AdaptivePlayback))
+ {
+ // This will make getCapabilities() return that adaptive playback is supported
+ LimeLog.info("Adaptive playback supported (FEATURE_AdaptivePlayback)");
+ return true;
+ }
+ } catch (Exception e) {
+ // Tolerate buggy codecs
+ e.printStackTrace();
+ }
+
+ return false;
+ }
+
+ public static boolean decoderNeedsConstrainedHighProfile(String decoderName) {
+ return isDecoderInList(constrainedHighProfilePrefixes, decoderName);
+ }
+
+ public static boolean decoderCanDirectSubmit(String decoderName) {
+ return isDecoderInList(directSubmitPrefixes, decoderName) && !isExynos4Device();
+ }
+
+ public static boolean decoderNeedsSpsBitstreamRestrictions(String decoderName) {
+ return isDecoderInList(spsFixupBitstreamFixupDecoderPrefixes, decoderName);
+ }
+
+ public static boolean decoderNeedsBaselineSpsHack(String decoderName) {
+ return isDecoderInList(baselineProfileHackPrefixes, decoderName);
+ }
+
+ public static byte getDecoderOptimalSlicesPerFrame(String decoderName) {
+ if (isDecoderInList(useFourSlicesPrefixes, decoderName)) {
+ // 4 slices per frame reduces decoding latency on older Qualcomm devices
+ return 4;
+ }
+ else {
+ // 1 slice per frame produces the optimal encoding efficiency
+ return 1;
+ }
+ }
+
+ public static boolean decoderSupportsRefFrameInvalidationAvc(String decoderName, int videoHeight) {
+ // Reference frame invalidation is broken on low-end Snapdragon SoCs at 1080p.
+ if (videoHeight > 720 && isLowEndSnapdragon) {
+ return false;
+ }
+
+ // This device seems to crash constantly at 720p, so try disabling
+ // RFI to see if we can get that under control.
+ if (Build.DEVICE.equals("b3") || Build.DEVICE.equals("b5")) {
+ return false;
+ }
+
+ return isDecoderInList(refFrameInvalidationAvcPrefixes, decoderName);
+ }
+
+ public static boolean decoderSupportsRefFrameInvalidationHevc(MediaCodecInfo decoderInfo) {
+ // HEVC decoders seem to universally support RFI, but it can have huge latency penalties
+ // for some decoders due to the number of references frames being > 1. Old Amlogic
+ // decoders are known to have this problem.
+ //
+ // If the decoder supports FEATURE_LowLatency or any vendor low latency option,
+ // we will use that as an indication that it can handle HEVC RFI without excessively
+ // buffering frames.
+ if (decoderSupportsAndroidRLowLatency(decoderInfo, "video/hevc") ||
+ decoderSupportsKnownVendorLowLatencyOption(decoderInfo.getName())) {
+ LimeLog.info("Enabling HEVC RFI based on low latency option support");
+ return true;
+ }
+
+ return isDecoderInList(refFrameInvalidationHevcPrefixes, decoderInfo.getName());
+ }
+
+ public static boolean decoderSupportsRefFrameInvalidationAv1(MediaCodecInfo decoderInfo) {
+ // We'll use the same heuristics as HEVC for now
+ if (decoderSupportsAndroidRLowLatency(decoderInfo, "video/av01") ||
+ decoderSupportsKnownVendorLowLatencyOption(decoderInfo.getName())) {
+ LimeLog.info("Enabling AV1 RFI based on low latency option support");
+ return true;
+ }
+
+ return false;
+ }
+
+ public static boolean decoderIsWhitelistedForHevc(MediaCodecInfo decoderInfo) {
+ //
+ // Software decoders are terrible and we never want to use them.
+ // We want to catch decoders like:
+ // OMX.qcom.video.decoder.hevcswvdec
+ // OMX.SEC.hevc.sw.dec
+ //
+ if (decoderInfo.getName().contains("sw")) {
+ LimeLog.info("Disallowing HEVC on software decoder: " + decoderInfo.getName());
+ return false;
+ }
+ else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && (!decoderInfo.isHardwareAccelerated() || decoderInfo.isSoftwareOnly())) {
+ LimeLog.info("Disallowing HEVC on software decoder: " + decoderInfo.getName());
+ return false;
+ }
+
+ // If this device is media performance class 12 or higher, we will assume any hardware
+ // HEVC decoder present is fast and modern enough for streaming.
+ //
+ // [5.3/H-1-1] MUST NOT drop more than 2 frames in 10 seconds (i.e less than 0.333 percent frame drop) for a 1080p 60 fps video session under load.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ LimeLog.info("Media performance class: " + Build.VERSION.MEDIA_PERFORMANCE_CLASS);
+ if (Build.VERSION.MEDIA_PERFORMANCE_CLASS >= Build.VERSION_CODES.S) {
+ LimeLog.info("Allowing HEVC based on media performance class");
+ return true;
+ }
+ }
+
+ // If the decoder supports FEATURE_LowLatency, we will assume it is fast and modern enough
+ // to be preferable for streaming over H.264 decoders.
+ if (decoderSupportsAndroidRLowLatency(decoderInfo, "video/hevc")) {
+ LimeLog.info("Allowing HEVC based on FEATURE_LowLatency support");
+ return true;
+ }
+
+ // Otherwise, we use our list of known working HEVC decoders
+ return isDecoderInList(whitelistedHevcDecoders, decoderInfo.getName());
+ }
+
+ public static boolean isDecoderWhitelistedForAv1(MediaCodecInfo decoderInfo) {
+ // Google didn't have official support for AV1 (or more importantly, a CTS test) until
+ // Android 10, so don't use any decoder before then.
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ return false;
+ }
+
+ //
+ // Software decoders are terrible and we never want to use them.
+ // We want to catch decoders like:
+ // OMX.qcom.video.decoder.hevcswvdec
+ // OMX.SEC.hevc.sw.dec
+ //
+ if (decoderInfo.getName().contains("sw")) {
+ LimeLog.info("Disallowing AV1 on software decoder: " + decoderInfo.getName());
+ return false;
+ }
+ else if (!decoderInfo.isHardwareAccelerated() || decoderInfo.isSoftwareOnly()) {
+ LimeLog.info("Disallowing AV1 on software decoder: " + decoderInfo.getName());
+ return false;
+ }
+
+ // TODO: Test some AV1 decoders
+ return false;
+ }
+
+ @SuppressWarnings("deprecation")
+ @SuppressLint("NewApi")
+ private static LinkedList getMediaCodecList() {
+ LinkedList infoList = new LinkedList<>();
+
+ MediaCodecList mcl = new MediaCodecList(MediaCodecList.REGULAR_CODECS);
+ Collections.addAll(infoList, mcl.getCodecInfos());
+
+ return infoList;
+ }
+
+ @SuppressWarnings("RedundantThrows")
+ public static String dumpDecoders() throws Exception {
+ String str = "";
+ for (MediaCodecInfo codecInfo : getMediaCodecList()) {
+ // Skip encoders
+ if (codecInfo.isEncoder()) {
+ continue;
+ }
+
+ str += "Decoder: "+codecInfo.getName()+"\n";
+ for (String type : codecInfo.getSupportedTypes()) {
+ str += "\t"+type+"\n";
+ CodecCapabilities caps = codecInfo.getCapabilitiesForType(type);
+
+ for (CodecProfileLevel profile : caps.profileLevels) {
+ str += "\t\t"+profile.profile+" "+profile.level+"\n";
+ }
+ }
+ }
+ return str;
+ }
+
+ private static MediaCodecInfo findPreferredDecoder() {
+ // This is a different algorithm than the other findXXXDecoder functions,
+ // because we want to evaluate the decoders in our list's order
+ // rather than MediaCodecList's order
+
+ if (!initialized) {
+ throw new IllegalStateException("MediaCodecHelper must be initialized before use");
+ }
+
+ for (String preferredDecoder : preferredDecoders) {
+ for (MediaCodecInfo codecInfo : getMediaCodecList()) {
+ // Skip encoders
+ if (codecInfo.isEncoder()) {
+ continue;
+ }
+
+ // Check for preferred decoders
+ if (preferredDecoder.equalsIgnoreCase(codecInfo.getName())) {
+ LimeLog.info("Preferred decoder choice is "+codecInfo.getName());
+ return codecInfo;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private static boolean isCodecBlacklisted(MediaCodecInfo codecInfo) {
+ // Use the new isSoftwareOnly() function on Android Q
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ if (!SHOULD_BYPASS_SOFTWARE_BLOCK && codecInfo.isSoftwareOnly()) {
+ LimeLog.info("Skipping software-only decoder: "+codecInfo.getName());
+ return true;
+ }
+ }
+
+ // Check for explicitly blacklisted decoders
+ if (isDecoderInList(blacklistedDecoderPrefixes, codecInfo.getName())) {
+ LimeLog.info("Skipping blacklisted decoder: "+codecInfo.getName());
+ return true;
+ }
+
+ return false;
+ }
+
+ public static MediaCodecInfo findFirstDecoder(String mimeType) {
+ for (MediaCodecInfo codecInfo : getMediaCodecList()) {
+ // Skip encoders
+ if (codecInfo.isEncoder()) {
+ continue;
+ }
+
+ // Skip compatibility aliases on Q+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ if (codecInfo.isAlias()) {
+ continue;
+ }
+ }
+
+ // Find a decoder that supports the specified video format
+ for (String mime : codecInfo.getSupportedTypes()) {
+ if (mime.equalsIgnoreCase(mimeType)) {
+ // Skip blacklisted codecs
+ if (isCodecBlacklisted(codecInfo)) {
+ continue;
+ }
+
+ LimeLog.info("First decoder choice is "+codecInfo.getName());
+ return codecInfo;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ public static MediaCodecInfo findProbableSafeDecoder(String mimeType, int requiredProfile) {
+ // First look for a preferred decoder by name
+ MediaCodecInfo info = findPreferredDecoder();
+ if (info != null) {
+ return info;
+ }
+
+ // Now look for decoders we know are safe
+ try {
+ // If this function completes, it will determine if the decoder is safe
+ return findKnownSafeDecoder(mimeType, requiredProfile);
+ } catch (Exception e) {
+ // Some buggy devices seem to throw exceptions
+ // from getCapabilitiesForType() so we'll just assume
+ // they're okay and go with the first one we find
+ return findFirstDecoder(mimeType);
+ }
+ }
+
+ // We declare this method as explicitly throwing Exception
+ // since some bad decoders can throw IllegalArgumentExceptions unexpectedly
+ // and we want to be sure all callers are handling this possibility
+ @SuppressWarnings("RedundantThrows")
+ private static MediaCodecInfo findKnownSafeDecoder(String mimeType, int requiredProfile) throws Exception {
+ // Some devices (Exynos devces, at least) have two sets of decoders.
+ // The first set of decoders are C2 which do not support FEATURE_LowLatency,
+ // but the second set of OMX decoders do support FEATURE_LowLatency. We want
+ // to pick the OMX decoders despite the fact that C2 is listed first.
+ // On some Qualcomm devices (like Pixel 4), there are separate low latency decoders
+ // (like c2.qti.hevc.decoder.low_latency) that advertise FEATURE_LowLatency while
+ // the standard ones (like c2.qti.hevc.decoder) do not. Like Exynos, the decoders
+ // with FEATURE_LowLatency support are listed after the standard ones.
+ for (int i = 0; i < 2; i++) {
+ for (MediaCodecInfo codecInfo : getMediaCodecList()) {
+ // Skip encoders
+ if (codecInfo.isEncoder()) {
+ continue;
+ }
+
+ // Skip compatibility aliases on Q+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ if (codecInfo.isAlias()) {
+ continue;
+ }
+ }
+
+ // Find a decoder that supports the requested video format
+ for (String mime : codecInfo.getSupportedTypes()) {
+ if (mime.equalsIgnoreCase(mimeType)) {
+ LimeLog.info("Examining decoder capabilities of " + codecInfo.getName() + " (round " + (i + 1) + ")");
+
+ // Skip blacklisted codecs
+ if (isCodecBlacklisted(codecInfo)) {
+ continue;
+ }
+
+ CodecCapabilities caps = codecInfo.getCapabilitiesForType(mime);
+
+ if (i == 0 && !decoderSupportsAndroidRLowLatency(codecInfo, mime)) {
+ LimeLog.info("Skipping decoder that lacks FEATURE_LowLatency for round 1");
+ continue;
+ }
+
+ if (requiredProfile != -1) {
+ for (CodecProfileLevel profile : caps.profileLevels) {
+ if (profile.profile == requiredProfile) {
+ LimeLog.info("Decoder " + codecInfo.getName() + " supports required profile");
+ return codecInfo;
+ }
+ }
+
+ LimeLog.info("Decoder " + codecInfo.getName() + " does NOT support required profile");
+ } else {
+ return codecInfo;
+ }
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+
+ public static String readCpuinfo() throws Exception {
+ StringBuilder cpuInfo = new StringBuilder();
+ try (final BufferedReader br = new BufferedReader(new FileReader(new File("/proc/cpuinfo")))) {
+ for (;;) {
+ int ch = br.read();
+ if (ch == -1)
+ break;
+ cpuInfo.append((char)ch);
+ }
+
+ return cpuInfo.toString();
+ }
+ }
+
+ private static boolean stringContainsIgnoreCase(String string, String substring) {
+ return string.toLowerCase(Locale.ENGLISH).contains(substring.toLowerCase(Locale.ENGLISH));
+ }
+
+ public static boolean isExynos4Device() {
+ try {
+ // Try reading CPU info too look for
+ String cpuInfo = readCpuinfo();
+
+ // SMDK4xxx is Exynos 4
+ if (stringContainsIgnoreCase(cpuInfo, "SMDK4")) {
+ LimeLog.info("Found SMDK4 in /proc/cpuinfo");
+ return true;
+ }
+
+ // If we see "Exynos 4" also we'll count it
+ if (stringContainsIgnoreCase(cpuInfo, "Exynos 4")) {
+ LimeLog.info("Found Exynos 4 in /proc/cpuinfo");
+ return true;
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+
+ try {
+ File systemDir = new File("/sys/devices/system");
+ File[] files = systemDir.listFiles();
+ if (files != null) {
+ for (File f : files) {
+ if (stringContainsIgnoreCase(f.getName(), "exynos4")) {
+ LimeLog.info("Found exynos4 in /sys/devices/system");
+ return true;
+ }
+ }
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+
+ return false;
+ }
+}
diff --git a/app/src/main/java/com/limelight/binding/video/PerfOverlayListener.java b/app/src/main/java/com/limelight/binding/video/PerfOverlayListener.java
old mode 100644
new mode 100755
index 281f95a046..fce697550d
--- a/app/src/main/java/com/limelight/binding/video/PerfOverlayListener.java
+++ b/app/src/main/java/com/limelight/binding/video/PerfOverlayListener.java
@@ -1,5 +1,5 @@
-package com.limelight.binding.video;
-
-public interface PerfOverlayListener {
- void onPerfUpdate(final String text);
-}
+package com.limelight.binding.video;
+
+public interface PerfOverlayListener {
+ void onPerfUpdate(final String text);
+}
diff --git a/app/src/main/java/com/limelight/binding/video/VideoStats.java b/app/src/main/java/com/limelight/binding/video/VideoStats.java
old mode 100644
new mode 100755
index b65b897ecf..d3022f2c73
--- a/app/src/main/java/com/limelight/binding/video/VideoStats.java
+++ b/app/src/main/java/com/limelight/binding/video/VideoStats.java
@@ -1,93 +1,93 @@
-package com.limelight.binding.video;
-
-import android.os.SystemClock;
-
-class VideoStats {
-
- long decoderTimeMs;
- long totalTimeMs;
- int totalFrames;
- int totalFramesReceived;
- int totalFramesRendered;
- int frameLossEvents;
- int framesLost;
- char minHostProcessingLatency;
- char maxHostProcessingLatency;
- int totalHostProcessingLatency;
- int framesWithHostProcessingLatency;
- long measurementStartTimestamp;
-
- void add(VideoStats other) {
- this.decoderTimeMs += other.decoderTimeMs;
- this.totalTimeMs += other.totalTimeMs;
- this.totalFrames += other.totalFrames;
- this.totalFramesReceived += other.totalFramesReceived;
- this.totalFramesRendered += other.totalFramesRendered;
- this.frameLossEvents += other.frameLossEvents;
- this.framesLost += other.framesLost;
-
- if (this.minHostProcessingLatency == 0) {
- this.minHostProcessingLatency = other.minHostProcessingLatency;
- } else {
- this.minHostProcessingLatency = (char) Math.min(this.minHostProcessingLatency, other.minHostProcessingLatency);
- }
- this.maxHostProcessingLatency = (char) Math.max(this.maxHostProcessingLatency, other.maxHostProcessingLatency);
- this.totalHostProcessingLatency += other.totalHostProcessingLatency;
- this.framesWithHostProcessingLatency += other.framesWithHostProcessingLatency;
-
- if (this.measurementStartTimestamp == 0) {
- this.measurementStartTimestamp = other.measurementStartTimestamp;
- }
-
- assert other.measurementStartTimestamp >= this.measurementStartTimestamp;
- }
-
- void copy(VideoStats other) {
- this.decoderTimeMs = other.decoderTimeMs;
- this.totalTimeMs = other.totalTimeMs;
- this.totalFrames = other.totalFrames;
- this.totalFramesReceived = other.totalFramesReceived;
- this.totalFramesRendered = other.totalFramesRendered;
- this.frameLossEvents = other.frameLossEvents;
- this.framesLost = other.framesLost;
- this.minHostProcessingLatency = other.minHostProcessingLatency;
- this.maxHostProcessingLatency = other.maxHostProcessingLatency;
- this.totalHostProcessingLatency = other.totalHostProcessingLatency;
- this.framesWithHostProcessingLatency = other.framesWithHostProcessingLatency;
- this.measurementStartTimestamp = other.measurementStartTimestamp;
- }
-
- void clear() {
- this.decoderTimeMs = 0;
- this.totalTimeMs = 0;
- this.totalFrames = 0;
- this.totalFramesReceived = 0;
- this.totalFramesRendered = 0;
- this.frameLossEvents = 0;
- this.framesLost = 0;
- this.minHostProcessingLatency = 0;
- this.maxHostProcessingLatency = 0;
- this.totalHostProcessingLatency = 0;
- this.framesWithHostProcessingLatency = 0;
- this.measurementStartTimestamp = 0;
- }
-
- VideoStatsFps getFps() {
- float elapsed = (SystemClock.uptimeMillis() - this.measurementStartTimestamp) / (float) 1000;
-
- VideoStatsFps fps = new VideoStatsFps();
- if (elapsed > 0) {
- fps.totalFps = this.totalFrames / elapsed;
- fps.receivedFps = this.totalFramesReceived / elapsed;
- fps.renderedFps = this.totalFramesRendered / elapsed;
- }
- return fps;
- }
-}
-
-class VideoStatsFps {
-
- float totalFps;
- float receivedFps;
- float renderedFps;
+package com.limelight.binding.video;
+
+import android.os.SystemClock;
+
+class VideoStats {
+
+ long decoderTimeMs;
+ long totalTimeMs;
+ int totalFrames;
+ int totalFramesReceived;
+ int totalFramesRendered;
+ int frameLossEvents;
+ int framesLost;
+ char minHostProcessingLatency;
+ char maxHostProcessingLatency;
+ int totalHostProcessingLatency;
+ int framesWithHostProcessingLatency;
+ long measurementStartTimestamp;
+
+ void add(VideoStats other) {
+ this.decoderTimeMs += other.decoderTimeMs;
+ this.totalTimeMs += other.totalTimeMs;
+ this.totalFrames += other.totalFrames;
+ this.totalFramesReceived += other.totalFramesReceived;
+ this.totalFramesRendered += other.totalFramesRendered;
+ this.frameLossEvents += other.frameLossEvents;
+ this.framesLost += other.framesLost;
+
+ if (this.minHostProcessingLatency == 0) {
+ this.minHostProcessingLatency = other.minHostProcessingLatency;
+ } else {
+ this.minHostProcessingLatency = (char) Math.min(this.minHostProcessingLatency, other.minHostProcessingLatency);
+ }
+ this.maxHostProcessingLatency = (char) Math.max(this.maxHostProcessingLatency, other.maxHostProcessingLatency);
+ this.totalHostProcessingLatency += other.totalHostProcessingLatency;
+ this.framesWithHostProcessingLatency += other.framesWithHostProcessingLatency;
+
+ if (this.measurementStartTimestamp == 0) {
+ this.measurementStartTimestamp = other.measurementStartTimestamp;
+ }
+
+ assert other.measurementStartTimestamp >= this.measurementStartTimestamp;
+ }
+
+ void copy(VideoStats other) {
+ this.decoderTimeMs = other.decoderTimeMs;
+ this.totalTimeMs = other.totalTimeMs;
+ this.totalFrames = other.totalFrames;
+ this.totalFramesReceived = other.totalFramesReceived;
+ this.totalFramesRendered = other.totalFramesRendered;
+ this.frameLossEvents = other.frameLossEvents;
+ this.framesLost = other.framesLost;
+ this.minHostProcessingLatency = other.minHostProcessingLatency;
+ this.maxHostProcessingLatency = other.maxHostProcessingLatency;
+ this.totalHostProcessingLatency = other.totalHostProcessingLatency;
+ this.framesWithHostProcessingLatency = other.framesWithHostProcessingLatency;
+ this.measurementStartTimestamp = other.measurementStartTimestamp;
+ }
+
+ void clear() {
+ this.decoderTimeMs = 0;
+ this.totalTimeMs = 0;
+ this.totalFrames = 0;
+ this.totalFramesReceived = 0;
+ this.totalFramesRendered = 0;
+ this.frameLossEvents = 0;
+ this.framesLost = 0;
+ this.minHostProcessingLatency = 0;
+ this.maxHostProcessingLatency = 0;
+ this.totalHostProcessingLatency = 0;
+ this.framesWithHostProcessingLatency = 0;
+ this.measurementStartTimestamp = 0;
+ }
+
+ VideoStatsFps getFps() {
+ float elapsed = (SystemClock.uptimeMillis() - this.measurementStartTimestamp) / (float) 1000;
+
+ VideoStatsFps fps = new VideoStatsFps();
+ if (elapsed > 0) {
+ fps.totalFps = this.totalFrames / elapsed;
+ fps.receivedFps = this.totalFramesReceived / elapsed;
+ fps.renderedFps = this.totalFramesRendered / elapsed;
+ }
+ return fps;
+ }
+}
+
+class VideoStatsFps {
+
+ float totalFps;
+ float receivedFps;
+ float renderedFps;
}
\ No newline at end of file
diff --git a/app/src/main/java/com/limelight/computers/ComputerDatabaseManager.java b/app/src/main/java/com/limelight/computers/ComputerDatabaseManager.java
old mode 100644
new mode 100755
index 692c433f39..325420c91a
--- a/app/src/main/java/com/limelight/computers/ComputerDatabaseManager.java
+++ b/app/src/main/java/com/limelight/computers/ComputerDatabaseManager.java
@@ -1,235 +1,235 @@
-package com.limelight.computers;
-
-import java.io.ByteArrayInputStream;
-import java.security.cert.CertificateEncodingException;
-import java.security.cert.CertificateException;
-import java.security.cert.CertificateFactory;
-import java.security.cert.X509Certificate;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.Locale;
-
-import com.limelight.LimeLog;
-import com.limelight.nvstream.http.ComputerDetails;
-import com.limelight.nvstream.http.NvHTTP;
-
-import android.content.ContentValues;
-import android.content.Context;
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-import android.database.sqlite.SQLiteException;
-
-import org.json.JSONException;
-import org.json.JSONObject;
-
-public class ComputerDatabaseManager {
- private static final String COMPUTER_DB_NAME = "computers4.db";
- private static final String COMPUTER_TABLE_NAME = "Computers";
- private static final String COMPUTER_UUID_COLUMN_NAME = "UUID";
- private static final String COMPUTER_NAME_COLUMN_NAME = "ComputerName";
- private static final String ADDRESSES_COLUMN_NAME = "Addresses";
- private interface AddressFields {
- String LOCAL = "local";
- String REMOTE = "remote";
- String MANUAL = "manual";
- String IPv6 = "ipv6";
-
- String ADDRESS = "address";
- String PORT = "port";
- }
-
- private static final String MAC_ADDRESS_COLUMN_NAME = "MacAddress";
- private static final String SERVER_CERT_COLUMN_NAME = "ServerCert";
-
- private SQLiteDatabase computerDb;
-
- public ComputerDatabaseManager(Context c) {
- try {
- // Create or open an existing DB
- computerDb = c.openOrCreateDatabase(COMPUTER_DB_NAME, 0, null);
- } catch (SQLiteException e) {
- // Delete the DB and try again
- c.deleteDatabase(COMPUTER_DB_NAME);
- computerDb = c.openOrCreateDatabase(COMPUTER_DB_NAME, 0, null);
- }
- initializeDb(c);
- }
-
- public void close() {
- computerDb.close();
- }
-
- private void initializeDb(Context c) {
- // Create tables if they aren't already there
- computerDb.execSQL(String.format((Locale)null,
- "CREATE TABLE IF NOT EXISTS %s(%s TEXT PRIMARY KEY, %s TEXT NOT NULL, %s TEXT NOT NULL, %s TEXT, %s TEXT)",
- COMPUTER_TABLE_NAME, COMPUTER_UUID_COLUMN_NAME, COMPUTER_NAME_COLUMN_NAME,
- ADDRESSES_COLUMN_NAME, MAC_ADDRESS_COLUMN_NAME, SERVER_CERT_COLUMN_NAME));
-
- // Move all computers from the old DB (if any) to the new one
- List oldComputers = LegacyDatabaseReader.migrateAllComputers(c);
- for (ComputerDetails computer : oldComputers) {
- updateComputer(computer);
- }
- oldComputers = LegacyDatabaseReader2.migrateAllComputers(c);
- for (ComputerDetails computer : oldComputers) {
- updateComputer(computer);
- }
- oldComputers = LegacyDatabaseReader3.migrateAllComputers(c);
- for (ComputerDetails computer : oldComputers) {
- updateComputer(computer);
- }
- }
-
- public void deleteComputer(ComputerDetails details) {
- computerDb.delete(COMPUTER_TABLE_NAME, COMPUTER_UUID_COLUMN_NAME+"=?", new String[]{details.uuid});
- }
-
- public static JSONObject tupleToJson(ComputerDetails.AddressTuple tuple) throws JSONException {
- if (tuple == null) {
- return null;
- }
-
- JSONObject json = new JSONObject();
- json.put(AddressFields.ADDRESS, tuple.address);
- json.put(AddressFields.PORT, tuple.port);
-
- return json;
- }
-
- public static ComputerDetails.AddressTuple tupleFromJson(JSONObject json, String name) throws JSONException {
- if (!json.has(name)) {
- return null;
- }
-
- JSONObject address = json.getJSONObject(name);
- return new ComputerDetails.AddressTuple(
- address.getString(AddressFields.ADDRESS), address.getInt(AddressFields.PORT));
- }
-
- public boolean updateComputer(ComputerDetails details) {
- ContentValues values = new ContentValues();
- values.put(COMPUTER_UUID_COLUMN_NAME, details.uuid);
- values.put(COMPUTER_NAME_COLUMN_NAME, details.name);
-
- try {
- JSONObject addresses = new JSONObject();
- addresses.put(AddressFields.LOCAL, tupleToJson(details.localAddress));
- addresses.put(AddressFields.REMOTE, tupleToJson(details.remoteAddress));
- addresses.put(AddressFields.MANUAL, tupleToJson(details.manualAddress));
- addresses.put(AddressFields.IPv6, tupleToJson(details.ipv6Address));
- values.put(ADDRESSES_COLUMN_NAME, addresses.toString());
- } catch (JSONException e) {
- throw new RuntimeException(e);
- }
-
- values.put(MAC_ADDRESS_COLUMN_NAME, details.macAddress);
- try {
- if (details.serverCert != null) {
- values.put(SERVER_CERT_COLUMN_NAME, details.serverCert.getEncoded());
- }
- else {
- values.put(SERVER_CERT_COLUMN_NAME, (byte[])null);
- }
- } catch (CertificateEncodingException e) {
- values.put(SERVER_CERT_COLUMN_NAME, (byte[])null);
- e.printStackTrace();
- }
- return -1 != computerDb.insertWithOnConflict(COMPUTER_TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_REPLACE);
- }
-
- private ComputerDetails getComputerFromCursor(Cursor c) {
- ComputerDetails details = new ComputerDetails();
-
- details.uuid = c.getString(0);
- details.name = c.getString(1);
- try {
- JSONObject addresses = new JSONObject(c.getString(2));
- details.localAddress = tupleFromJson(addresses, AddressFields.LOCAL);
- details.remoteAddress = tupleFromJson(addresses, AddressFields.REMOTE);
- details.manualAddress = tupleFromJson(addresses, AddressFields.MANUAL);
- details.ipv6Address = tupleFromJson(addresses, AddressFields.IPv6);
- } catch (JSONException e) {
- throw new RuntimeException(e);
- }
-
- // External port is persisted in the remote address field
- if (details.remoteAddress != null) {
- details.externalPort = details.remoteAddress.port;
- }
- else {
- details.externalPort = NvHTTP.DEFAULT_HTTP_PORT;
- }
-
- details.macAddress = c.getString(3);
-
- try {
- byte[] derCertData = c.getBlob(4);
-
- if (derCertData != null) {
- details.serverCert = (X509Certificate) CertificateFactory.getInstance("X.509")
- .generateCertificate(new ByteArrayInputStream(derCertData));
- }
- } catch (CertificateException e) {
- e.printStackTrace();
- }
-
- // This signifies we don't have dynamic state (like pair state)
- details.state = ComputerDetails.State.UNKNOWN;
-
- return details;
- }
-
- public List getAllComputers() {
- try (final Cursor c = computerDb.rawQuery("SELECT * FROM "+COMPUTER_TABLE_NAME, null)) {
- LinkedList computerList = new LinkedList<>();
- while (c.moveToNext()) {
- computerList.add(getComputerFromCursor(c));
- }
- return computerList;
- }
- }
-
- /**
- * Get a computer by name
- * NOTE: It is perfectly valid for multiple computers to have the same name,
- * this function will only return the first one it finds.
- * Consider using getComputerByUUID instead.
- * @param name The name of the computer
- * @see ComputerDatabaseManager#getComputerByUUID(String) for alternative.
- * @return The computer details, or null if no computer with that name exists
- */
- public ComputerDetails getComputerByName(String name) {
- try (final Cursor c = computerDb.query(
- COMPUTER_TABLE_NAME, null, COMPUTER_NAME_COLUMN_NAME+"=?",
- new String[]{ name }, null, null, null)
- ) {
- if (!c.moveToFirst()) {
- // No matching computer
- return null;
- }
-
- return getComputerFromCursor(c);
- }
- }
-
- /**
- * Get a computer by UUID
- * @param uuid The UUID of the computer
- * @see ComputerDatabaseManager#getComputerByName(String) for alternative.
- * @return The computer details, or null if no computer with that UUID exists
- */
- public ComputerDetails getComputerByUUID(String uuid) {
- try (final Cursor c = computerDb.query(
- COMPUTER_TABLE_NAME, null, COMPUTER_UUID_COLUMN_NAME+"=?",
- new String[]{ uuid }, null, null, null)
- ) {
- if (!c.moveToFirst()) {
- // No matching computer
- return null;
- }
-
- return getComputerFromCursor(c);
- }
- }
-}
+package com.limelight.computers;
+
+import java.io.ByteArrayInputStream;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Locale;
+
+import com.limelight.LimeLog;
+import com.limelight.nvstream.http.ComputerDetails;
+import com.limelight.nvstream.http.NvHTTP;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteException;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+public class ComputerDatabaseManager {
+ private static final String COMPUTER_DB_NAME = "computers4.db";
+ private static final String COMPUTER_TABLE_NAME = "Computers";
+ private static final String COMPUTER_UUID_COLUMN_NAME = "UUID";
+ private static final String COMPUTER_NAME_COLUMN_NAME = "ComputerName";
+ private static final String ADDRESSES_COLUMN_NAME = "Addresses";
+ private interface AddressFields {
+ String LOCAL = "local";
+ String REMOTE = "remote";
+ String MANUAL = "manual";
+ String IPv6 = "ipv6";
+
+ String ADDRESS = "address";
+ String PORT = "port";
+ }
+
+ private static final String MAC_ADDRESS_COLUMN_NAME = "MacAddress";
+ private static final String SERVER_CERT_COLUMN_NAME = "ServerCert";
+
+ private SQLiteDatabase computerDb;
+
+ public ComputerDatabaseManager(Context c) {
+ try {
+ // Create or open an existing DB
+ computerDb = c.openOrCreateDatabase(COMPUTER_DB_NAME, 0, null);
+ } catch (SQLiteException e) {
+ // Delete the DB and try again
+ c.deleteDatabase(COMPUTER_DB_NAME);
+ computerDb = c.openOrCreateDatabase(COMPUTER_DB_NAME, 0, null);
+ }
+ initializeDb(c);
+ }
+
+ public void close() {
+ computerDb.close();
+ }
+
+ private void initializeDb(Context c) {
+ // Create tables if they aren't already there
+ computerDb.execSQL(String.format((Locale)null,
+ "CREATE TABLE IF NOT EXISTS %s(%s TEXT PRIMARY KEY, %s TEXT NOT NULL, %s TEXT NOT NULL, %s TEXT, %s TEXT)",
+ COMPUTER_TABLE_NAME, COMPUTER_UUID_COLUMN_NAME, COMPUTER_NAME_COLUMN_NAME,
+ ADDRESSES_COLUMN_NAME, MAC_ADDRESS_COLUMN_NAME, SERVER_CERT_COLUMN_NAME));
+
+ // Move all computers from the old DB (if any) to the new one
+ List oldComputers = LegacyDatabaseReader.migrateAllComputers(c);
+ for (ComputerDetails computer : oldComputers) {
+ updateComputer(computer);
+ }
+ oldComputers = LegacyDatabaseReader2.migrateAllComputers(c);
+ for (ComputerDetails computer : oldComputers) {
+ updateComputer(computer);
+ }
+ oldComputers = LegacyDatabaseReader3.migrateAllComputers(c);
+ for (ComputerDetails computer : oldComputers) {
+ updateComputer(computer);
+ }
+ }
+
+ public void deleteComputer(ComputerDetails details) {
+ computerDb.delete(COMPUTER_TABLE_NAME, COMPUTER_UUID_COLUMN_NAME+"=?", new String[]{details.uuid});
+ }
+
+ public static JSONObject tupleToJson(ComputerDetails.AddressTuple tuple) throws JSONException {
+ if (tuple == null) {
+ return null;
+ }
+
+ JSONObject json = new JSONObject();
+ json.put(AddressFields.ADDRESS, tuple.address);
+ json.put(AddressFields.PORT, tuple.port);
+
+ return json;
+ }
+
+ public static ComputerDetails.AddressTuple tupleFromJson(JSONObject json, String name) throws JSONException {
+ if (!json.has(name)) {
+ return null;
+ }
+
+ JSONObject address = json.getJSONObject(name);
+ return new ComputerDetails.AddressTuple(
+ address.getString(AddressFields.ADDRESS), address.getInt(AddressFields.PORT));
+ }
+
+ public boolean updateComputer(ComputerDetails details) {
+ ContentValues values = new ContentValues();
+ values.put(COMPUTER_UUID_COLUMN_NAME, details.uuid);
+ values.put(COMPUTER_NAME_COLUMN_NAME, details.name);
+
+ try {
+ JSONObject addresses = new JSONObject();
+ addresses.put(AddressFields.LOCAL, tupleToJson(details.localAddress));
+ addresses.put(AddressFields.REMOTE, tupleToJson(details.remoteAddress));
+ addresses.put(AddressFields.MANUAL, tupleToJson(details.manualAddress));
+ addresses.put(AddressFields.IPv6, tupleToJson(details.ipv6Address));
+ values.put(ADDRESSES_COLUMN_NAME, addresses.toString());
+ } catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+
+ values.put(MAC_ADDRESS_COLUMN_NAME, details.macAddress);
+ try {
+ if (details.serverCert != null) {
+ values.put(SERVER_CERT_COLUMN_NAME, details.serverCert.getEncoded());
+ }
+ else {
+ values.put(SERVER_CERT_COLUMN_NAME, (byte[])null);
+ }
+ } catch (CertificateEncodingException e) {
+ values.put(SERVER_CERT_COLUMN_NAME, (byte[])null);
+ e.printStackTrace();
+ }
+ return -1 != computerDb.insertWithOnConflict(COMPUTER_TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_REPLACE);
+ }
+
+ private ComputerDetails getComputerFromCursor(Cursor c) {
+ ComputerDetails details = new ComputerDetails();
+
+ details.uuid = c.getString(0);
+ details.name = c.getString(1);
+ try {
+ JSONObject addresses = new JSONObject(c.getString(2));
+ details.localAddress = tupleFromJson(addresses, AddressFields.LOCAL);
+ details.remoteAddress = tupleFromJson(addresses, AddressFields.REMOTE);
+ details.manualAddress = tupleFromJson(addresses, AddressFields.MANUAL);
+ details.ipv6Address = tupleFromJson(addresses, AddressFields.IPv6);
+ } catch (JSONException e) {
+ throw new RuntimeException(e);
+ }
+
+ // External port is persisted in the remote address field
+ if (details.remoteAddress != null) {
+ details.externalPort = details.remoteAddress.port;
+ }
+ else {
+ details.externalPort = NvHTTP.DEFAULT_HTTP_PORT;
+ }
+
+ details.macAddress = c.getString(3);
+
+ try {
+ byte[] derCertData = c.getBlob(4);
+
+ if (derCertData != null) {
+ details.serverCert = (X509Certificate) CertificateFactory.getInstance("X.509")
+ .generateCertificate(new ByteArrayInputStream(derCertData));
+ }
+ } catch (CertificateException e) {
+ e.printStackTrace();
+ }
+
+ // This signifies we don't have dynamic state (like pair state)
+ details.state = ComputerDetails.State.UNKNOWN;
+
+ return details;
+ }
+
+ public List getAllComputers() {
+ try (final Cursor c = computerDb.rawQuery("SELECT * FROM "+COMPUTER_TABLE_NAME, null)) {
+ LinkedList computerList = new LinkedList<>();
+ while (c.moveToNext()) {
+ computerList.add(getComputerFromCursor(c));
+ }
+ return computerList;
+ }
+ }
+
+ /**
+ * Get a computer by name
+ * NOTE: It is perfectly valid for multiple computers to have the same name,
+ * this function will only return the first one it finds.
+ * Consider using getComputerByUUID instead.
+ * @param name The name of the computer
+ * @see ComputerDatabaseManager#getComputerByUUID(String) for alternative.
+ * @return The computer details, or null if no computer with that name exists
+ */
+ public ComputerDetails getComputerByName(String name) {
+ try (final Cursor c = computerDb.query(
+ COMPUTER_TABLE_NAME, null, COMPUTER_NAME_COLUMN_NAME+"=?",
+ new String[]{ name }, null, null, null)
+ ) {
+ if (!c.moveToFirst()) {
+ // No matching computer
+ return null;
+ }
+
+ return getComputerFromCursor(c);
+ }
+ }
+
+ /**
+ * Get a computer by UUID
+ * @param uuid The UUID of the computer
+ * @see ComputerDatabaseManager#getComputerByName(String) for alternative.
+ * @return The computer details, or null if no computer with that UUID exists
+ */
+ public ComputerDetails getComputerByUUID(String uuid) {
+ try (final Cursor c = computerDb.query(
+ COMPUTER_TABLE_NAME, null, COMPUTER_UUID_COLUMN_NAME+"=?",
+ new String[]{ uuid }, null, null, null)
+ ) {
+ if (!c.moveToFirst()) {
+ // No matching computer
+ return null;
+ }
+
+ return getComputerFromCursor(c);
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/computers/ComputerManagerListener.java b/app/src/main/java/com/limelight/computers/ComputerManagerListener.java
old mode 100644
new mode 100755
index 263f9fb39b..f23dcc467a
--- a/app/src/main/java/com/limelight/computers/ComputerManagerListener.java
+++ b/app/src/main/java/com/limelight/computers/ComputerManagerListener.java
@@ -1,7 +1,7 @@
-package com.limelight.computers;
-
-import com.limelight.nvstream.http.ComputerDetails;
-
-public interface ComputerManagerListener {
- void notifyComputerUpdated(ComputerDetails details);
-}
+package com.limelight.computers;
+
+import com.limelight.nvstream.http.ComputerDetails;
+
+public interface ComputerManagerListener {
+ void notifyComputerUpdated(ComputerDetails details);
+}
diff --git a/app/src/main/java/com/limelight/computers/ComputerManagerService.java b/app/src/main/java/com/limelight/computers/ComputerManagerService.java
old mode 100644
new mode 100755
index 990a8953f2..d2c39026bd
--- a/app/src/main/java/com/limelight/computers/ComputerManagerService.java
+++ b/app/src/main/java/com/limelight/computers/ComputerManagerService.java
@@ -1,969 +1,969 @@
-package com.limelight.computers;
-
-import java.io.IOException;
-import java.io.OutputStream;
-import java.io.StringReader;
-import java.net.Inet4Address;
-import java.net.InetAddress;
-import java.net.UnknownHostException;
-import java.util.HashSet;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.concurrent.locks.Lock;
-import java.util.concurrent.locks.ReentrantLock;
-
-import com.limelight.LimeLog;
-import com.limelight.binding.PlatformBinding;
-import com.limelight.discovery.DiscoveryService;
-import com.limelight.nvstream.NvConnection;
-import com.limelight.nvstream.http.ComputerDetails;
-import com.limelight.nvstream.http.NvApp;
-import com.limelight.nvstream.http.NvHTTP;
-import com.limelight.nvstream.http.PairingManager;
-import com.limelight.nvstream.mdns.MdnsComputer;
-import com.limelight.nvstream.mdns.MdnsDiscoveryListener;
-import com.limelight.utils.CacheHelper;
-import com.limelight.utils.NetHelper;
-import com.limelight.utils.ServerHelper;
-
-import android.app.Service;
-import android.content.ComponentName;
-import android.content.Context;
-import android.content.Intent;
-import android.content.ServiceConnection;
-import android.net.ConnectivityManager;
-import android.net.Network;
-import android.net.NetworkCapabilities;
-import android.os.Binder;
-import android.os.Build;
-import android.os.IBinder;
-import android.os.SystemClock;
-
-import org.xmlpull.v1.XmlPullParserException;
-
-public class ComputerManagerService extends Service {
- private static final int SERVERINFO_POLLING_PERIOD_MS = 1500;
- private static final int APPLIST_POLLING_PERIOD_MS = 30000;
- private static final int APPLIST_FAILED_POLLING_RETRY_MS = 2000;
- private static final int MDNS_QUERY_PERIOD_MS = 1000;
- private static final int OFFLINE_POLL_TRIES = 3;
- private static final int INITIAL_POLL_TRIES = 2;
- private static final int EMPTY_LIST_THRESHOLD = 3;
- private static final int POLL_DATA_TTL_MS = 30000;
-
- private final ComputerManagerBinder binder = new ComputerManagerBinder();
-
- private ComputerDatabaseManager dbManager;
- private final AtomicInteger dbRefCount = new AtomicInteger(0);
-
- private IdentityManager idManager;
- private final LinkedList pollingTuples = new LinkedList<>();
- private ComputerManagerListener listener = null;
- private final AtomicInteger activePolls = new AtomicInteger(0);
- private boolean pollingActive = false;
- private final Lock defaultNetworkLock = new ReentrantLock();
-
- private ConnectivityManager.NetworkCallback networkCallback;
-
- private DiscoveryService.DiscoveryBinder discoveryBinder;
- private final ServiceConnection discoveryServiceConnection = new ServiceConnection() {
- public void onServiceConnected(ComponentName className, IBinder binder) {
- synchronized (discoveryServiceConnection) {
- DiscoveryService.DiscoveryBinder privateBinder = ((DiscoveryService.DiscoveryBinder)binder);
-
- // Set us as the event listener
- privateBinder.setListener(createDiscoveryListener());
-
- // Signal a possible waiter that we're all setup
- discoveryBinder = privateBinder;
- discoveryServiceConnection.notifyAll();
- }
- }
-
- public void onServiceDisconnected(ComponentName className) {
- discoveryBinder = null;
- }
- };
-
- // Returns true if the details object was modified
- private boolean runPoll(ComputerDetails details, boolean newPc, int offlineCount) throws InterruptedException {
- if (!getLocalDatabaseReference()) {
- return false;
- }
-
- final int pollTriesBeforeOffline = details.state == ComputerDetails.State.UNKNOWN ?
- INITIAL_POLL_TRIES : OFFLINE_POLL_TRIES;
-
- activePolls.incrementAndGet();
-
- // Poll the machine
- try {
- if (!pollComputer(details)) {
- if (!newPc && offlineCount < pollTriesBeforeOffline) {
- // Return without calling the listener
- releaseLocalDatabaseReference();
- return false;
- }
-
- details.state = ComputerDetails.State.OFFLINE;
- }
- } catch (InterruptedException e) {
- releaseLocalDatabaseReference();
- throw e;
- } finally {
- activePolls.decrementAndGet();
- }
-
- // If it's online, update our persistent state
- if (details.state == ComputerDetails.State.ONLINE) {
- ComputerDetails existingComputer = dbManager.getComputerByUUID(details.uuid);
-
- // Check if it's in the database because it could have been
- // removed after this was issued
- if (!newPc && existingComputer == null) {
- // It's gone
- releaseLocalDatabaseReference();
- return false;
- }
-
- // If we already have an entry for this computer in the DB, we must
- // combine the existing data with this new data (which may be partially available
- // due to detecting the PC via mDNS) without the saved external address. If we
- // write to the DB without doing this first, we can overwrite our existing data.
- if (existingComputer != null) {
- existingComputer.update(details);
- dbManager.updateComputer(existingComputer);
- }
- else {
- try {
- // If the active address is a site-local address (RFC 1918),
- // then use STUN to populate the external address field if
- // it's not set already.
- if (details.remoteAddress == null) {
- InetAddress addr = InetAddress.getByName(details.activeAddress.address);
- if (addr.isSiteLocalAddress()) {
- populateExternalAddress(details);
- }
- }
- } catch (UnknownHostException ignored) {}
-
- dbManager.updateComputer(details);
- }
- }
-
- // Don't call the listener if this is a failed lookup of a new PC
- if ((!newPc || details.state == ComputerDetails.State.ONLINE) && listener != null) {
- listener.notifyComputerUpdated(details);
- }
-
- releaseLocalDatabaseReference();
- return true;
- }
-
- private Thread createPollingThread(final PollingTuple tuple) {
- Thread t = new Thread() {
- @Override
- public void run() {
-
- int offlineCount = 0;
- while (!isInterrupted() && pollingActive && tuple.thread == this) {
- try {
- // Only allow one request to the machine at a time
- synchronized (tuple.networkLock) {
- // Check if this poll has modified the details
- if (!runPoll(tuple.computer, false, offlineCount)) {
- LimeLog.warning(tuple.computer.name + " is offline (try " + offlineCount + ")");
- offlineCount++;
- } else {
- tuple.lastSuccessfulPollMs = SystemClock.elapsedRealtime();
- offlineCount = 0;
- }
- }
-
- // Wait until the next polling interval
- Thread.sleep(SERVERINFO_POLLING_PERIOD_MS);
- } catch (InterruptedException e) {
- break;
- }
- }
- }
- };
- t.setName("Polling thread for " + tuple.computer.name);
- return t;
- }
-
- public class ComputerManagerBinder extends Binder {
- public void startPolling(ComputerManagerListener listener) {
- // Polling is active
- pollingActive = true;
-
- // Set the listener
- ComputerManagerService.this.listener = listener;
-
- // Start mDNS autodiscovery too
- discoveryBinder.startDiscovery(MDNS_QUERY_PERIOD_MS);
-
- synchronized (pollingTuples) {
- for (PollingTuple tuple : pollingTuples) {
- // Enforce the poll data TTL
- if (SystemClock.elapsedRealtime() - tuple.lastSuccessfulPollMs > POLL_DATA_TTL_MS) {
- LimeLog.info("Timing out polled state for "+tuple.computer.name);
- tuple.computer.state = ComputerDetails.State.UNKNOWN;
- }
-
- // Report this computer initially
- listener.notifyComputerUpdated(tuple.computer);
-
- // This polling thread might already be there
- if (tuple.thread == null) {
- tuple.thread = createPollingThread(tuple);
- tuple.thread.start();
- }
- }
- }
- }
-
- public void waitForReady() {
- synchronized (discoveryServiceConnection) {
- try {
- while (discoveryBinder == null) {
- // Wait for the bind notification
- discoveryServiceConnection.wait(1000);
- }
- } catch (InterruptedException e) {
- e.printStackTrace();
-
- // InterruptedException clears the thread's interrupt status. Since we can't
- // handle that here, we will re-interrupt the thread to set the interrupt
- // status back to true.
- Thread.currentThread().interrupt();
- }
- }
- }
-
- public void waitForPollingStopped() {
- while (activePolls.get() != 0) {
- try {
- Thread.sleep(250);
- } catch (InterruptedException e) {
- e.printStackTrace();
-
- // InterruptedException clears the thread's interrupt status. Since we can't
- // handle that here, we will re-interrupt the thread to set the interrupt
- // status back to true.
- Thread.currentThread().interrupt();
- }
- }
- }
-
- public boolean addComputerBlocking(ComputerDetails fakeDetails) throws InterruptedException {
- return ComputerManagerService.this.addComputerBlocking(fakeDetails);
- }
-
- public void removeComputer(ComputerDetails computer) {
- ComputerManagerService.this.removeComputer(computer);
- }
-
- public void stopPolling() {
- // Just call the unbind handler to cleanup
- ComputerManagerService.this.onUnbind(null);
- }
-
- public ApplistPoller createAppListPoller(ComputerDetails computer) {
- return new ApplistPoller(computer);
- }
-
- public String getUniqueId() {
- return idManager.getUniqueId();
- }
-
- public ComputerDetails getComputer(String uuid) {
- synchronized (pollingTuples) {
- for (PollingTuple tuple : pollingTuples) {
- if (uuid.equals(tuple.computer.uuid)) {
- return tuple.computer;
- }
- }
- }
-
- return null;
- }
-
- public void invalidateStateForComputer(String uuid) {
- synchronized (pollingTuples) {
- for (PollingTuple tuple : pollingTuples) {
- if (uuid.equals(tuple.computer.uuid)) {
- // We need the network lock to prevent a concurrent poll
- // from wiping this change out
- synchronized (tuple.networkLock) {
- tuple.computer.state = ComputerDetails.State.UNKNOWN;
- }
- }
- }
- }
- }
- }
-
- @Override
- public boolean onUnbind(Intent intent) {
- if (discoveryBinder != null) {
- // Stop mDNS autodiscovery
- discoveryBinder.stopDiscovery();
- }
-
- // Stop polling
- pollingActive = false;
- synchronized (pollingTuples) {
- for (PollingTuple tuple : pollingTuples) {
- if (tuple.thread != null) {
- // Interrupt and remove the thread
- tuple.thread.interrupt();
- tuple.thread = null;
- }
- }
- }
-
- // Remove the listener
- listener = null;
-
- return false;
- }
-
- private void populateExternalAddress(ComputerDetails details) {
- boolean boundToNetwork = false;
- boolean activeNetworkIsVpn = NetHelper.isActiveNetworkVpn(this);
- ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
-
- // Check if we're currently connected to a VPN which may send our
- // STUN request from an unexpected interface
- if (activeNetworkIsVpn) {
- // Acquire the default network lock since we could be changing global process state
- defaultNetworkLock.lock();
-
- // On Lollipop or later, we can bind our process to the underlying interface
- // to ensure our STUN request goes out on that interface or not at all (which is
- // preferable to getting a VPN endpoint address back).
- Network[] networks = connMgr.getAllNetworks();
- for (Network net : networks) {
- NetworkCapabilities netCaps = connMgr.getNetworkCapabilities(net);
- if (netCaps != null) {
- if (!netCaps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
- !netCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
- // This network looks like an underlying multicast-capable transport,
- // so let's guess that it's probably where our mDNS response came from.
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- if (connMgr.bindProcessToNetwork(net)) {
- boundToNetwork = true;
- break;
- }
- } else if (ConnectivityManager.setProcessDefaultNetwork(net)) {
- boundToNetwork = true;
- break;
- }
- }
- }
- }
-
- // Perform the STUN request if we're not on a VPN or if we bound to a network
- if (!activeNetworkIsVpn || boundToNetwork) {
- String stunResolvedAddress = NvConnection.findExternalAddressForMdns("stun.moonlight-stream.org", 3478);
- if (stunResolvedAddress != null) {
- // We don't know for sure what the external port is, so we will have to guess.
- // When we contact the PC (if we haven't already), it will update the port.
- details.remoteAddress = new ComputerDetails.AddressTuple(stunResolvedAddress, details.guessExternalPort());
- }
- }
-
- // Unbind from the network
- if (boundToNetwork) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- connMgr.bindProcessToNetwork(null);
- } else {
- ConnectivityManager.setProcessDefaultNetwork(null);
- }
- }
-
- // Unlock the network state
- if (activeNetworkIsVpn) {
- defaultNetworkLock.unlock();
- }
- }
- }
-
- private MdnsDiscoveryListener createDiscoveryListener() {
- return new MdnsDiscoveryListener() {
- @Override
- public void notifyComputerAdded(MdnsComputer computer) {
- ComputerDetails details = new ComputerDetails();
-
- // Populate the computer template with mDNS info
- if (computer.getLocalAddress() != null) {
- details.localAddress = new ComputerDetails.AddressTuple(computer.getLocalAddress().getHostAddress(), computer.getPort());
-
- // Since we're on the same network, we can use STUN to find
- // our WAN address, which is also very likely the WAN address
- // of the PC. We can use this later to connect remotely.
- if (computer.getLocalAddress() instanceof Inet4Address) {
- populateExternalAddress(details);
- }
- }
- if (computer.getIpv6Address() != null) {
- details.ipv6Address = new ComputerDetails.AddressTuple(computer.getIpv6Address().getHostAddress(), computer.getPort());
- }
-
- try {
- // Kick off a blocking serverinfo poll on this machine
- if (!addComputerBlocking(details)) {
- LimeLog.warning("Auto-discovered PC failed to respond: "+details);
- }
- } catch (InterruptedException e) {
- e.printStackTrace();
-
- // InterruptedException clears the thread's interrupt status. Since we can't
- // handle that here, we will re-interrupt the thread to set the interrupt
- // status back to true.
- Thread.currentThread().interrupt();
- }
- }
-
- @Override
- public void notifyDiscoveryFailure(Exception e) {
- LimeLog.severe("mDNS discovery failed");
- e.printStackTrace();
- }
- };
- }
-
- private void addTuple(ComputerDetails details) {
- synchronized (pollingTuples) {
- for (PollingTuple tuple : pollingTuples) {
- // Check if this is the same computer
- if (tuple.computer.uuid.equals(details.uuid)) {
- // Update the saved computer with potentially new details
- tuple.computer.update(details);
-
- // Start a polling thread if polling is active
- if (pollingActive && tuple.thread == null) {
- tuple.thread = createPollingThread(tuple);
- tuple.thread.start();
- }
-
- // Found an entry so we're done
- return;
- }
- }
-
- // If we got here, we didn't find an entry
- PollingTuple tuple = new PollingTuple(details, null);
- if (pollingActive) {
- tuple.thread = createPollingThread(tuple);
- }
- pollingTuples.add(tuple);
- if (tuple.thread != null) {
- tuple.thread.start();
- }
- }
- }
-
- public boolean addComputerBlocking(ComputerDetails fakeDetails) throws InterruptedException {
- // Block while we try to fill the details
-
- // We cannot use runPoll() here because it will attempt to persist the state of the machine
- // in the database, which would be bad because we don't have our pinned cert loaded yet.
- if (pollComputer(fakeDetails)) {
- // See if we have record of this PC to pull its pinned cert
- synchronized (pollingTuples) {
- for (PollingTuple tuple : pollingTuples) {
- if (tuple.computer.uuid.equals(fakeDetails.uuid)) {
- fakeDetails.serverCert = tuple.computer.serverCert;
- break;
- }
- }
- }
-
- // Poll again, possibly with the pinned cert, to get accurate pairing information.
- // This will insert the host into the database too.
- runPoll(fakeDetails, true, 0);
- }
-
- // If the machine is reachable, it was successful
- if (fakeDetails.state == ComputerDetails.State.ONLINE) {
- LimeLog.info("New PC ("+fakeDetails.name+") is UUID "+fakeDetails.uuid);
-
- // Start a polling thread for this machine
- addTuple(fakeDetails);
- return true;
- }
- else {
- return false;
- }
- }
-
- public void removeComputer(ComputerDetails computer) {
- if (!getLocalDatabaseReference()) {
- return;
- }
-
- // Remove it from the database
- dbManager.deleteComputer(computer);
-
- synchronized (pollingTuples) {
- // Remove the computer from the computer list
- for (PollingTuple tuple : pollingTuples) {
- if (tuple.computer.uuid.equals(computer.uuid)) {
- if (tuple.thread != null) {
- // Interrupt the thread on this entry
- tuple.thread.interrupt();
- tuple.thread = null;
- }
- pollingTuples.remove(tuple);
- break;
- }
- }
- }
-
- releaseLocalDatabaseReference();
- }
-
- private boolean getLocalDatabaseReference() {
- if (dbRefCount.get() == 0) {
- return false;
- }
-
- dbRefCount.incrementAndGet();
- return true;
- }
-
- private void releaseLocalDatabaseReference() {
- if (dbRefCount.decrementAndGet() == 0) {
- dbManager.close();
- }
- }
-
- private ComputerDetails tryPollIp(ComputerDetails details, ComputerDetails.AddressTuple address) {
- try {
- // If the current address's port number matches the active address's port number, we can also assume
- // the HTTPS port will also match. This assumption is currently safe because Sunshine sets all ports
- // as offsets from the base HTTP port and doesn't allow custom HttpsPort responses for WAN vs LAN.
- boolean portMatchesActiveAddress = details.state == ComputerDetails.State.ONLINE &&
- details.activeAddress != null && address.port == details.activeAddress.port;
-
- NvHTTP http = new NvHTTP(address, portMatchesActiveAddress ? details.httpsPort : 0, idManager.getUniqueId(), details.serverCert,
- PlatformBinding.getCryptoProvider(ComputerManagerService.this));
-
- // If this PC is currently online at this address, extend the timeouts to allow more time for the PC to respond.
- boolean isLikelyOnline = details.state == ComputerDetails.State.ONLINE && address.equals(details.activeAddress);
-
- ComputerDetails newDetails = http.getComputerDetails(isLikelyOnline);
-
- // Check if this is the PC we expected
- if (newDetails.uuid == null) {
- LimeLog.severe("Polling returned no UUID!");
- return null;
- }
- // details.uuid can be null on initial PC add
- else if (details.uuid != null && !details.uuid.equals(newDetails.uuid)) {
- // We got the wrong PC!
- LimeLog.info("Polling returned the wrong PC!");
- return null;
- }
-
- return newDetails;
- } catch (XmlPullParserException e) {
- e.printStackTrace();
- return null;
- } catch (IOException e) {
- return null;
- }
- }
-
- private static class ParallelPollTuple {
- public ComputerDetails.AddressTuple address;
- public ComputerDetails existingDetails;
-
- public boolean complete;
- public Thread pollingThread;
- public ComputerDetails returnedDetails;
-
- public ParallelPollTuple(ComputerDetails.AddressTuple address, ComputerDetails existingDetails) {
- this.address = address;
- this.existingDetails = existingDetails;
- }
-
- public void interrupt() {
- if (pollingThread != null) {
- pollingThread.interrupt();
- }
- }
- }
-
- private void startParallelPollThread(ParallelPollTuple tuple, HashSet uniqueAddresses) {
- // Don't bother starting a polling thread for an address that doesn't exist
- // or if the address has already been polled with an earlier tuple
- if (tuple.address == null || !uniqueAddresses.add(tuple.address)) {
- tuple.complete = true;
- tuple.returnedDetails = null;
- return;
- }
-
- tuple.pollingThread = new Thread() {
- @Override
- public void run() {
- ComputerDetails details = tryPollIp(tuple.existingDetails, tuple.address);
-
- synchronized (tuple) {
- tuple.complete = true; // Done
- tuple.returnedDetails = details; // Polling result
-
- tuple.notify();
- }
- }
- };
- tuple.pollingThread.setName("Parallel Poll - "+tuple.address+" - "+tuple.existingDetails.name);
- tuple.pollingThread.start();
- }
-
- private ComputerDetails parallelPollPc(ComputerDetails details) throws InterruptedException {
- ParallelPollTuple localInfo = new ParallelPollTuple(details.localAddress, details);
- ParallelPollTuple manualInfo = new ParallelPollTuple(details.manualAddress, details);
- ParallelPollTuple remoteInfo = new ParallelPollTuple(details.remoteAddress, details);
- ParallelPollTuple ipv6Info = new ParallelPollTuple(details.ipv6Address, details);
-
- // These must be started in order of precedence for the deduplication algorithm
- // to result in the correct behavior.
- HashSet uniqueAddresses = new HashSet<>();
- startParallelPollThread(localInfo, uniqueAddresses);
- startParallelPollThread(manualInfo, uniqueAddresses);
- startParallelPollThread(remoteInfo, uniqueAddresses);
- startParallelPollThread(ipv6Info, uniqueAddresses);
-
- try {
- // Check local first
- synchronized (localInfo) {
- while (!localInfo.complete) {
- localInfo.wait();
- }
-
- if (localInfo.returnedDetails != null) {
- localInfo.returnedDetails.activeAddress = localInfo.address;
- return localInfo.returnedDetails;
- }
- }
-
- // Now manual
- synchronized (manualInfo) {
- while (!manualInfo.complete) {
- manualInfo.wait();
- }
-
- if (manualInfo.returnedDetails != null) {
- manualInfo.returnedDetails.activeAddress = manualInfo.address;
- return manualInfo.returnedDetails;
- }
- }
-
- // Now remote IPv4
- synchronized (remoteInfo) {
- while (!remoteInfo.complete) {
- remoteInfo.wait();
- }
-
- if (remoteInfo.returnedDetails != null) {
- remoteInfo.returnedDetails.activeAddress = remoteInfo.address;
- return remoteInfo.returnedDetails;
- }
- }
-
- // Now global IPv6
- synchronized (ipv6Info) {
- while (!ipv6Info.complete) {
- ipv6Info.wait();
- }
-
- if (ipv6Info.returnedDetails != null) {
- ipv6Info.returnedDetails.activeAddress = ipv6Info.address;
- return ipv6Info.returnedDetails;
- }
- }
- } finally {
- // Stop any further polling if we've found a working address or we've been
- // interrupted by an attempt to stop polling.
- localInfo.interrupt();
- manualInfo.interrupt();
- remoteInfo.interrupt();
- ipv6Info.interrupt();
- }
-
- return null;
- }
-
- private boolean pollComputer(ComputerDetails details) throws InterruptedException {
- // Poll all addresses in parallel to speed up the process
- LimeLog.info("Starting parallel poll for "+details.name+" ("+details.localAddress +", "+details.remoteAddress +", "+details.manualAddress+", "+details.ipv6Address+")");
- ComputerDetails polledDetails = parallelPollPc(details);
- LimeLog.info("Parallel poll for "+details.name+" returned address: "+details.activeAddress);
-
- if (polledDetails != null) {
- details.update(polledDetails);
- return true;
- }
- else {
- return false;
- }
- }
-
- @Override
- public void onCreate() {
- // Bind to the discovery service
- bindService(new Intent(this, DiscoveryService.class),
- discoveryServiceConnection, Service.BIND_AUTO_CREATE);
-
- // Lookup or generate this device's UID
- idManager = new IdentityManager(this);
-
- // Initialize the DB
- dbManager = new ComputerDatabaseManager(this);
- dbRefCount.set(1);
-
- // Grab known machines into our computer list
- if (!getLocalDatabaseReference()) {
- return;
- }
-
- for (ComputerDetails computer : dbManager.getAllComputers()) {
- // Add tuples for each computer
- addTuple(computer);
- }
-
- releaseLocalDatabaseReference();
-
- // Monitor for network changes to invalidate our PC state
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- networkCallback = new ConnectivityManager.NetworkCallback() {
- @Override
- public void onAvailable(Network network) {
- LimeLog.info("Resetting PC state for new available network");
- synchronized (pollingTuples) {
- for (PollingTuple tuple : pollingTuples) {
- tuple.computer.state = ComputerDetails.State.UNKNOWN;
- if (listener != null) {
- listener.notifyComputerUpdated(tuple.computer);
- }
- }
- }
- }
-
- @Override
- public void onLost(Network network) {
- LimeLog.info("Offlining PCs due to network loss");
- synchronized (pollingTuples) {
- for (PollingTuple tuple : pollingTuples) {
- tuple.computer.state = ComputerDetails.State.OFFLINE;
- if (listener != null) {
- listener.notifyComputerUpdated(tuple.computer);
- }
- }
- }
- }
- };
-
- ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
- connMgr.registerDefaultNetworkCallback(networkCallback);
- }
- }
-
- @Override
- public void onDestroy() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
- connMgr.unregisterNetworkCallback(networkCallback);
- }
-
- if (discoveryBinder != null) {
- // Unbind from the discovery service
- unbindService(discoveryServiceConnection);
- }
-
- // FIXME: Should await termination here but we have timeout issues in HttpURLConnection
-
- // Remove the initial DB reference
- releaseLocalDatabaseReference();
- }
-
- @Override
- public IBinder onBind(Intent intent) {
- return binder;
- }
-
- public class ApplistPoller {
- private Thread thread;
- private final ComputerDetails computer;
- private final Object pollEvent = new Object();
- private boolean receivedAppList = false;
-
- public ApplistPoller(ComputerDetails computer) {
- this.computer = computer;
- }
-
- public void pollNow() {
- synchronized (pollEvent) {
- pollEvent.notify();
- }
- }
-
- private boolean waitPollingDelay() {
- try {
- synchronized (pollEvent) {
- if (receivedAppList) {
- // If we've already reported an app list successfully,
- // wait the full polling period
- pollEvent.wait(APPLIST_POLLING_PERIOD_MS);
- }
- else {
- // If we've failed to get an app list so far, retry much earlier
- pollEvent.wait(APPLIST_FAILED_POLLING_RETRY_MS);
- }
- }
- } catch (InterruptedException e) {
- return false;
- }
-
- return thread != null && !thread.isInterrupted();
- }
-
- private PollingTuple getPollingTuple(ComputerDetails details) {
- synchronized (pollingTuples) {
- for (PollingTuple tuple : pollingTuples) {
- if (details.uuid.equals(tuple.computer.uuid)) {
- return tuple;
- }
- }
- }
-
- return null;
- }
-
- public void start() {
- thread = new Thread() {
- @Override
- public void run() {
- int emptyAppListResponses = 0;
- do {
- // Can't poll if it's not online or paired
- if (computer.state != ComputerDetails.State.ONLINE ||
- computer.pairState != PairingManager.PairState.PAIRED) {
- if (listener != null) {
- listener.notifyComputerUpdated(computer);
- }
- continue;
- }
-
- // Can't poll if there's no UUID yet
- if (computer.uuid == null) {
- continue;
- }
-
- PollingTuple tuple = getPollingTuple(computer);
-
- try {
- NvHTTP http = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer), computer.httpsPort, idManager.getUniqueId(),
- computer.serverCert, PlatformBinding.getCryptoProvider(ComputerManagerService.this));
-
- String appList;
- if (tuple != null) {
- // If we're polling this machine too, grab the network lock
- // while doing the app list request to prevent other requests
- // from being issued in the meantime.
- synchronized (tuple.networkLock) {
- appList = http.getAppListRaw();
- }
- }
- else {
- // No polling is happening now, so we just call it directly
- appList = http.getAppListRaw();
- }
-
- List list = NvHTTP.getAppListByReader(new StringReader(appList));
- if (list.isEmpty()) {
- LimeLog.warning("Empty app list received from "+computer.uuid);
-
- // The app list might actually be empty, so if we get an empty response a few times
- // in a row, we'll go ahead and believe it.
- emptyAppListResponses++;
- }
- if (!appList.isEmpty() &&
- (!list.isEmpty() || emptyAppListResponses >= EMPTY_LIST_THRESHOLD)) {
- // Open the cache file
- try (final OutputStream cacheOut = CacheHelper.openCacheFileForOutput(
- getCacheDir(), "applist", computer.uuid)
- ) {
- CacheHelper.writeStringToOutputStream(cacheOut, appList);
- } catch (IOException e) {
- e.printStackTrace();
- }
-
- // Reset empty count if it wasn't empty this time
- if (!list.isEmpty()) {
- emptyAppListResponses = 0;
- }
-
- // Update the computer
- computer.rawAppList = appList;
- receivedAppList = true;
-
- // Notify that the app list has been updated
- // and ensure that the thread is still active
- if (listener != null && thread != null) {
- listener.notifyComputerUpdated(computer);
- }
- }
- else if (appList.isEmpty()) {
- LimeLog.warning("Null app list received from "+computer.uuid);
- }
- } catch (IOException e) {
- e.printStackTrace();
- } catch (XmlPullParserException e) {
- e.printStackTrace();
- }
- } while (waitPollingDelay());
- }
- };
- thread.setName("App list polling thread for " + computer.name);
- thread.start();
- }
-
- public void stop() {
- if (thread != null) {
- thread.interrupt();
-
- // Don't join here because we might be blocked on network I/O
-
- thread = null;
- }
- }
- }
-}
-
-class PollingTuple {
- public Thread thread;
- public final ComputerDetails computer;
- public final Object networkLock;
- public long lastSuccessfulPollMs;
-
- public PollingTuple(ComputerDetails computer, Thread thread) {
- this.computer = computer;
- this.thread = thread;
- this.networkLock = new Object();
- }
-}
-
-class ReachabilityTuple {
- public final String reachableAddress;
- public final ComputerDetails computer;
-
- public ReachabilityTuple(ComputerDetails computer, String reachableAddress) {
- this.computer = computer;
- this.reachableAddress = reachableAddress;
- }
+package com.limelight.computers;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.StringReader;
+import java.net.Inet4Address;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+import com.limelight.LimeLog;
+import com.limelight.binding.PlatformBinding;
+import com.limelight.discovery.DiscoveryService;
+import com.limelight.nvstream.NvConnection;
+import com.limelight.nvstream.http.ComputerDetails;
+import com.limelight.nvstream.http.NvApp;
+import com.limelight.nvstream.http.NvHTTP;
+import com.limelight.nvstream.http.PairingManager;
+import com.limelight.nvstream.mdns.MdnsComputer;
+import com.limelight.nvstream.mdns.MdnsDiscoveryListener;
+import com.limelight.utils.CacheHelper;
+import com.limelight.utils.NetHelper;
+import com.limelight.utils.ServerHelper;
+
+import android.app.Service;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.net.ConnectivityManager;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.os.Binder;
+import android.os.Build;
+import android.os.IBinder;
+import android.os.SystemClock;
+
+import org.xmlpull.v1.XmlPullParserException;
+
+public class ComputerManagerService extends Service {
+ private static final int SERVERINFO_POLLING_PERIOD_MS = 1500;
+ private static final int APPLIST_POLLING_PERIOD_MS = 30000;
+ private static final int APPLIST_FAILED_POLLING_RETRY_MS = 2000;
+ private static final int MDNS_QUERY_PERIOD_MS = 1000;
+ private static final int OFFLINE_POLL_TRIES = 3;
+ private static final int INITIAL_POLL_TRIES = 2;
+ private static final int EMPTY_LIST_THRESHOLD = 3;
+ private static final int POLL_DATA_TTL_MS = 30000;
+
+ private final ComputerManagerBinder binder = new ComputerManagerBinder();
+
+ private ComputerDatabaseManager dbManager;
+ private final AtomicInteger dbRefCount = new AtomicInteger(0);
+
+ private IdentityManager idManager;
+ private final LinkedList pollingTuples = new LinkedList<>();
+ private ComputerManagerListener listener = null;
+ private final AtomicInteger activePolls = new AtomicInteger(0);
+ private boolean pollingActive = false;
+ private final Lock defaultNetworkLock = new ReentrantLock();
+
+ private ConnectivityManager.NetworkCallback networkCallback;
+
+ private DiscoveryService.DiscoveryBinder discoveryBinder;
+ private final ServiceConnection discoveryServiceConnection = new ServiceConnection() {
+ public void onServiceConnected(ComponentName className, IBinder binder) {
+ synchronized (discoveryServiceConnection) {
+ DiscoveryService.DiscoveryBinder privateBinder = ((DiscoveryService.DiscoveryBinder)binder);
+
+ // Set us as the event listener
+ privateBinder.setListener(createDiscoveryListener());
+
+ // Signal a possible waiter that we're all setup
+ discoveryBinder = privateBinder;
+ discoveryServiceConnection.notifyAll();
+ }
+ }
+
+ public void onServiceDisconnected(ComponentName className) {
+ discoveryBinder = null;
+ }
+ };
+
+ // Returns true if the details object was modified
+ private boolean runPoll(ComputerDetails details, boolean newPc, int offlineCount) throws InterruptedException {
+ if (!getLocalDatabaseReference()) {
+ return false;
+ }
+
+ final int pollTriesBeforeOffline = details.state == ComputerDetails.State.UNKNOWN ?
+ INITIAL_POLL_TRIES : OFFLINE_POLL_TRIES;
+
+ activePolls.incrementAndGet();
+
+ // Poll the machine
+ try {
+ if (!pollComputer(details)) {
+ if (!newPc && offlineCount < pollTriesBeforeOffline) {
+ // Return without calling the listener
+ releaseLocalDatabaseReference();
+ return false;
+ }
+
+ details.state = ComputerDetails.State.OFFLINE;
+ }
+ } catch (InterruptedException e) {
+ releaseLocalDatabaseReference();
+ throw e;
+ } finally {
+ activePolls.decrementAndGet();
+ }
+
+ // If it's online, update our persistent state
+ if (details.state == ComputerDetails.State.ONLINE) {
+ ComputerDetails existingComputer = dbManager.getComputerByUUID(details.uuid);
+
+ // Check if it's in the database because it could have been
+ // removed after this was issued
+ if (!newPc && existingComputer == null) {
+ // It's gone
+ releaseLocalDatabaseReference();
+ return false;
+ }
+
+ // If we already have an entry for this computer in the DB, we must
+ // combine the existing data with this new data (which may be partially available
+ // due to detecting the PC via mDNS) without the saved external address. If we
+ // write to the DB without doing this first, we can overwrite our existing data.
+ if (existingComputer != null) {
+ existingComputer.update(details);
+ dbManager.updateComputer(existingComputer);
+ }
+ else {
+ try {
+ // If the active address is a site-local address (RFC 1918),
+ // then use STUN to populate the external address field if
+ // it's not set already.
+ if (details.remoteAddress == null) {
+ InetAddress addr = InetAddress.getByName(details.activeAddress.address);
+ if (addr.isSiteLocalAddress()) {
+ populateExternalAddress(details);
+ }
+ }
+ } catch (UnknownHostException ignored) {}
+
+ dbManager.updateComputer(details);
+ }
+ }
+
+ // Don't call the listener if this is a failed lookup of a new PC
+ if ((!newPc || details.state == ComputerDetails.State.ONLINE) && listener != null) {
+ listener.notifyComputerUpdated(details);
+ }
+
+ releaseLocalDatabaseReference();
+ return true;
+ }
+
+ private Thread createPollingThread(final PollingTuple tuple) {
+ Thread t = new Thread() {
+ @Override
+ public void run() {
+
+ int offlineCount = 0;
+ while (!isInterrupted() && pollingActive && tuple.thread == this) {
+ try {
+ // Only allow one request to the machine at a time
+ synchronized (tuple.networkLock) {
+ // Check if this poll has modified the details
+ if (!runPoll(tuple.computer, false, offlineCount)) {
+ LimeLog.warning(tuple.computer.name + " is offline (try " + offlineCount + ")");
+ offlineCount++;
+ } else {
+ tuple.lastSuccessfulPollMs = SystemClock.elapsedRealtime();
+ offlineCount = 0;
+ }
+ }
+
+ // Wait until the next polling interval
+ Thread.sleep(SERVERINFO_POLLING_PERIOD_MS);
+ } catch (InterruptedException e) {
+ break;
+ }
+ }
+ }
+ };
+ t.setName("Polling thread for " + tuple.computer.name);
+ return t;
+ }
+
+ public class ComputerManagerBinder extends Binder {
+ public void startPolling(ComputerManagerListener listener) {
+ // Polling is active
+ pollingActive = true;
+
+ // Set the listener
+ ComputerManagerService.this.listener = listener;
+
+ // Start mDNS autodiscovery too
+ discoveryBinder.startDiscovery(MDNS_QUERY_PERIOD_MS);
+
+ synchronized (pollingTuples) {
+ for (PollingTuple tuple : pollingTuples) {
+ // Enforce the poll data TTL
+ if (SystemClock.elapsedRealtime() - tuple.lastSuccessfulPollMs > POLL_DATA_TTL_MS) {
+ LimeLog.info("Timing out polled state for "+tuple.computer.name);
+ tuple.computer.state = ComputerDetails.State.UNKNOWN;
+ }
+
+ // Report this computer initially
+ listener.notifyComputerUpdated(tuple.computer);
+
+ // This polling thread might already be there
+ if (tuple.thread == null) {
+ tuple.thread = createPollingThread(tuple);
+ tuple.thread.start();
+ }
+ }
+ }
+ }
+
+ public void waitForReady() {
+ synchronized (discoveryServiceConnection) {
+ try {
+ while (discoveryBinder == null) {
+ // Wait for the bind notification
+ discoveryServiceConnection.wait(1000);
+ }
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+
+ // InterruptedException clears the thread's interrupt status. Since we can't
+ // handle that here, we will re-interrupt the thread to set the interrupt
+ // status back to true.
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ public void waitForPollingStopped() {
+ while (activePolls.get() != 0) {
+ try {
+ Thread.sleep(250);
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+
+ // InterruptedException clears the thread's interrupt status. Since we can't
+ // handle that here, we will re-interrupt the thread to set the interrupt
+ // status back to true.
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ public boolean addComputerBlocking(ComputerDetails fakeDetails) throws InterruptedException {
+ return ComputerManagerService.this.addComputerBlocking(fakeDetails);
+ }
+
+ public void removeComputer(ComputerDetails computer) {
+ ComputerManagerService.this.removeComputer(computer);
+ }
+
+ public void stopPolling() {
+ // Just call the unbind handler to cleanup
+ ComputerManagerService.this.onUnbind(null);
+ }
+
+ public ApplistPoller createAppListPoller(ComputerDetails computer) {
+ return new ApplistPoller(computer);
+ }
+
+ public String getUniqueId() {
+ return idManager.getUniqueId();
+ }
+
+ public ComputerDetails getComputer(String uuid) {
+ synchronized (pollingTuples) {
+ for (PollingTuple tuple : pollingTuples) {
+ if (uuid.equals(tuple.computer.uuid)) {
+ return tuple.computer;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ public void invalidateStateForComputer(String uuid) {
+ synchronized (pollingTuples) {
+ for (PollingTuple tuple : pollingTuples) {
+ if (uuid.equals(tuple.computer.uuid)) {
+ // We need the network lock to prevent a concurrent poll
+ // from wiping this change out
+ synchronized (tuple.networkLock) {
+ tuple.computer.state = ComputerDetails.State.UNKNOWN;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @Override
+ public boolean onUnbind(Intent intent) {
+ if (discoveryBinder != null) {
+ // Stop mDNS autodiscovery
+ discoveryBinder.stopDiscovery();
+ }
+
+ // Stop polling
+ pollingActive = false;
+ synchronized (pollingTuples) {
+ for (PollingTuple tuple : pollingTuples) {
+ if (tuple.thread != null) {
+ // Interrupt and remove the thread
+ tuple.thread.interrupt();
+ tuple.thread = null;
+ }
+ }
+ }
+
+ // Remove the listener
+ listener = null;
+
+ return false;
+ }
+
+ private void populateExternalAddress(ComputerDetails details) {
+ boolean boundToNetwork = false;
+ boolean activeNetworkIsVpn = NetHelper.isActiveNetworkVpn(this);
+ ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
+
+ // Check if we're currently connected to a VPN which may send our
+ // STUN request from an unexpected interface
+ if (activeNetworkIsVpn) {
+ // Acquire the default network lock since we could be changing global process state
+ defaultNetworkLock.lock();
+
+ // On Lollipop or later, we can bind our process to the underlying interface
+ // to ensure our STUN request goes out on that interface or not at all (which is
+ // preferable to getting a VPN endpoint address back).
+ Network[] networks = connMgr.getAllNetworks();
+ for (Network net : networks) {
+ NetworkCapabilities netCaps = connMgr.getNetworkCapabilities(net);
+ if (netCaps != null) {
+ if (!netCaps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
+ !netCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
+ // This network looks like an underlying multicast-capable transport,
+ // so let's guess that it's probably where our mDNS response came from.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ if (connMgr.bindProcessToNetwork(net)) {
+ boundToNetwork = true;
+ break;
+ }
+ } else if (ConnectivityManager.setProcessDefaultNetwork(net)) {
+ boundToNetwork = true;
+ break;
+ }
+ }
+ }
+ }
+
+ // Perform the STUN request if we're not on a VPN or if we bound to a network
+ if (!activeNetworkIsVpn || boundToNetwork) {
+ String stunResolvedAddress = NvConnection.findExternalAddressForMdns("stun.moonlight-stream.org", 3478);
+ if (stunResolvedAddress != null) {
+ // We don't know for sure what the external port is, so we will have to guess.
+ // When we contact the PC (if we haven't already), it will update the port.
+ details.remoteAddress = new ComputerDetails.AddressTuple(stunResolvedAddress, details.guessExternalPort());
+ }
+ }
+
+ // Unbind from the network
+ if (boundToNetwork) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ connMgr.bindProcessToNetwork(null);
+ } else {
+ ConnectivityManager.setProcessDefaultNetwork(null);
+ }
+ }
+
+ // Unlock the network state
+ if (activeNetworkIsVpn) {
+ defaultNetworkLock.unlock();
+ }
+ }
+ }
+
+ private MdnsDiscoveryListener createDiscoveryListener() {
+ return new MdnsDiscoveryListener() {
+ @Override
+ public void notifyComputerAdded(MdnsComputer computer) {
+ ComputerDetails details = new ComputerDetails();
+
+ // Populate the computer template with mDNS info
+ if (computer.getLocalAddress() != null) {
+ details.localAddress = new ComputerDetails.AddressTuple(computer.getLocalAddress().getHostAddress(), computer.getPort());
+
+ // Since we're on the same network, we can use STUN to find
+ // our WAN address, which is also very likely the WAN address
+ // of the PC. We can use this later to connect remotely.
+ if (computer.getLocalAddress() instanceof Inet4Address) {
+ populateExternalAddress(details);
+ }
+ }
+ if (computer.getIpv6Address() != null) {
+ details.ipv6Address = new ComputerDetails.AddressTuple(computer.getIpv6Address().getHostAddress(), computer.getPort());
+ }
+
+ try {
+ // Kick off a blocking serverinfo poll on this machine
+ if (!addComputerBlocking(details)) {
+ LimeLog.warning("Auto-discovered PC failed to respond: "+details);
+ }
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+
+ // InterruptedException clears the thread's interrupt status. Since we can't
+ // handle that here, we will re-interrupt the thread to set the interrupt
+ // status back to true.
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ @Override
+ public void notifyDiscoveryFailure(Exception e) {
+ LimeLog.severe("mDNS discovery failed");
+ e.printStackTrace();
+ }
+ };
+ }
+
+ private void addTuple(ComputerDetails details) {
+ synchronized (pollingTuples) {
+ for (PollingTuple tuple : pollingTuples) {
+ // Check if this is the same computer
+ if (tuple.computer.uuid.equals(details.uuid)) {
+ // Update the saved computer with potentially new details
+ tuple.computer.update(details);
+
+ // Start a polling thread if polling is active
+ if (pollingActive && tuple.thread == null) {
+ tuple.thread = createPollingThread(tuple);
+ tuple.thread.start();
+ }
+
+ // Found an entry so we're done
+ return;
+ }
+ }
+
+ // If we got here, we didn't find an entry
+ PollingTuple tuple = new PollingTuple(details, null);
+ if (pollingActive) {
+ tuple.thread = createPollingThread(tuple);
+ }
+ pollingTuples.add(tuple);
+ if (tuple.thread != null) {
+ tuple.thread.start();
+ }
+ }
+ }
+
+ public boolean addComputerBlocking(ComputerDetails fakeDetails) throws InterruptedException {
+ // Block while we try to fill the details
+
+ // We cannot use runPoll() here because it will attempt to persist the state of the machine
+ // in the database, which would be bad because we don't have our pinned cert loaded yet.
+ if (pollComputer(fakeDetails)) {
+ // See if we have record of this PC to pull its pinned cert
+ synchronized (pollingTuples) {
+ for (PollingTuple tuple : pollingTuples) {
+ if (tuple.computer.uuid.equals(fakeDetails.uuid)) {
+ fakeDetails.serverCert = tuple.computer.serverCert;
+ break;
+ }
+ }
+ }
+
+ // Poll again, possibly with the pinned cert, to get accurate pairing information.
+ // This will insert the host into the database too.
+ runPoll(fakeDetails, true, 0);
+ }
+
+ // If the machine is reachable, it was successful
+ if (fakeDetails.state == ComputerDetails.State.ONLINE) {
+ LimeLog.info("New PC ("+fakeDetails.name+") is UUID "+fakeDetails.uuid);
+
+ // Start a polling thread for this machine
+ addTuple(fakeDetails);
+ return true;
+ }
+ else {
+ return false;
+ }
+ }
+
+ public void removeComputer(ComputerDetails computer) {
+ if (!getLocalDatabaseReference()) {
+ return;
+ }
+
+ // Remove it from the database
+ dbManager.deleteComputer(computer);
+
+ synchronized (pollingTuples) {
+ // Remove the computer from the computer list
+ for (PollingTuple tuple : pollingTuples) {
+ if (tuple.computer.uuid.equals(computer.uuid)) {
+ if (tuple.thread != null) {
+ // Interrupt the thread on this entry
+ tuple.thread.interrupt();
+ tuple.thread = null;
+ }
+ pollingTuples.remove(tuple);
+ break;
+ }
+ }
+ }
+
+ releaseLocalDatabaseReference();
+ }
+
+ private boolean getLocalDatabaseReference() {
+ if (dbRefCount.get() == 0) {
+ return false;
+ }
+
+ dbRefCount.incrementAndGet();
+ return true;
+ }
+
+ private void releaseLocalDatabaseReference() {
+ if (dbRefCount.decrementAndGet() == 0) {
+ dbManager.close();
+ }
+ }
+
+ private ComputerDetails tryPollIp(ComputerDetails details, ComputerDetails.AddressTuple address) {
+ try {
+ // If the current address's port number matches the active address's port number, we can also assume
+ // the HTTPS port will also match. This assumption is currently safe because Sunshine sets all ports
+ // as offsets from the base HTTP port and doesn't allow custom HttpsPort responses for WAN vs LAN.
+ boolean portMatchesActiveAddress = details.state == ComputerDetails.State.ONLINE &&
+ details.activeAddress != null && address.port == details.activeAddress.port;
+
+ NvHTTP http = new NvHTTP(address, portMatchesActiveAddress ? details.httpsPort : 0, idManager.getUniqueId(), details.serverCert,
+ PlatformBinding.getCryptoProvider(ComputerManagerService.this));
+
+ // If this PC is currently online at this address, extend the timeouts to allow more time for the PC to respond.
+ boolean isLikelyOnline = details.state == ComputerDetails.State.ONLINE && address.equals(details.activeAddress);
+
+ ComputerDetails newDetails = http.getComputerDetails(isLikelyOnline);
+
+ // Check if this is the PC we expected
+ if (newDetails.uuid == null) {
+ LimeLog.severe("Polling returned no UUID!");
+ return null;
+ }
+ // details.uuid can be null on initial PC add
+ else if (details.uuid != null && !details.uuid.equals(newDetails.uuid)) {
+ // We got the wrong PC!
+ LimeLog.info("Polling returned the wrong PC!");
+ return null;
+ }
+
+ return newDetails;
+ } catch (XmlPullParserException e) {
+ e.printStackTrace();
+ return null;
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ private static class ParallelPollTuple {
+ public ComputerDetails.AddressTuple address;
+ public ComputerDetails existingDetails;
+
+ public boolean complete;
+ public Thread pollingThread;
+ public ComputerDetails returnedDetails;
+
+ public ParallelPollTuple(ComputerDetails.AddressTuple address, ComputerDetails existingDetails) {
+ this.address = address;
+ this.existingDetails = existingDetails;
+ }
+
+ public void interrupt() {
+ if (pollingThread != null) {
+ pollingThread.interrupt();
+ }
+ }
+ }
+
+ private void startParallelPollThread(ParallelPollTuple tuple, HashSet uniqueAddresses) {
+ // Don't bother starting a polling thread for an address that doesn't exist
+ // or if the address has already been polled with an earlier tuple
+ if (tuple.address == null || !uniqueAddresses.add(tuple.address)) {
+ tuple.complete = true;
+ tuple.returnedDetails = null;
+ return;
+ }
+
+ tuple.pollingThread = new Thread() {
+ @Override
+ public void run() {
+ ComputerDetails details = tryPollIp(tuple.existingDetails, tuple.address);
+
+ synchronized (tuple) {
+ tuple.complete = true; // Done
+ tuple.returnedDetails = details; // Polling result
+
+ tuple.notify();
+ }
+ }
+ };
+ tuple.pollingThread.setName("Parallel Poll - "+tuple.address+" - "+tuple.existingDetails.name);
+ tuple.pollingThread.start();
+ }
+
+ private ComputerDetails parallelPollPc(ComputerDetails details) throws InterruptedException {
+ ParallelPollTuple localInfo = new ParallelPollTuple(details.localAddress, details);
+ ParallelPollTuple manualInfo = new ParallelPollTuple(details.manualAddress, details);
+ ParallelPollTuple remoteInfo = new ParallelPollTuple(details.remoteAddress, details);
+ ParallelPollTuple ipv6Info = new ParallelPollTuple(details.ipv6Address, details);
+
+ // These must be started in order of precedence for the deduplication algorithm
+ // to result in the correct behavior.
+ HashSet uniqueAddresses = new HashSet<>();
+ startParallelPollThread(localInfo, uniqueAddresses);
+ startParallelPollThread(manualInfo, uniqueAddresses);
+ startParallelPollThread(remoteInfo, uniqueAddresses);
+ startParallelPollThread(ipv6Info, uniqueAddresses);
+
+ try {
+ // Check local first
+ synchronized (localInfo) {
+ while (!localInfo.complete) {
+ localInfo.wait();
+ }
+
+ if (localInfo.returnedDetails != null) {
+ localInfo.returnedDetails.activeAddress = localInfo.address;
+ return localInfo.returnedDetails;
+ }
+ }
+
+ // Now manual
+ synchronized (manualInfo) {
+ while (!manualInfo.complete) {
+ manualInfo.wait();
+ }
+
+ if (manualInfo.returnedDetails != null) {
+ manualInfo.returnedDetails.activeAddress = manualInfo.address;
+ return manualInfo.returnedDetails;
+ }
+ }
+
+ // Now remote IPv4
+ synchronized (remoteInfo) {
+ while (!remoteInfo.complete) {
+ remoteInfo.wait();
+ }
+
+ if (remoteInfo.returnedDetails != null) {
+ remoteInfo.returnedDetails.activeAddress = remoteInfo.address;
+ return remoteInfo.returnedDetails;
+ }
+ }
+
+ // Now global IPv6
+ synchronized (ipv6Info) {
+ while (!ipv6Info.complete) {
+ ipv6Info.wait();
+ }
+
+ if (ipv6Info.returnedDetails != null) {
+ ipv6Info.returnedDetails.activeAddress = ipv6Info.address;
+ return ipv6Info.returnedDetails;
+ }
+ }
+ } finally {
+ // Stop any further polling if we've found a working address or we've been
+ // interrupted by an attempt to stop polling.
+ localInfo.interrupt();
+ manualInfo.interrupt();
+ remoteInfo.interrupt();
+ ipv6Info.interrupt();
+ }
+
+ return null;
+ }
+
+ private boolean pollComputer(ComputerDetails details) throws InterruptedException {
+ // Poll all addresses in parallel to speed up the process
+ LimeLog.info("Starting parallel poll for "+details.name+" ("+details.localAddress +", "+details.remoteAddress +", "+details.manualAddress+", "+details.ipv6Address+")");
+ ComputerDetails polledDetails = parallelPollPc(details);
+ LimeLog.info("Parallel poll for "+details.name+" returned address: "+details.activeAddress);
+
+ if (polledDetails != null) {
+ details.update(polledDetails);
+ return true;
+ }
+ else {
+ return false;
+ }
+ }
+
+ @Override
+ public void onCreate() {
+ // Bind to the discovery service
+ bindService(new Intent(this, DiscoveryService.class),
+ discoveryServiceConnection, Service.BIND_AUTO_CREATE);
+
+ // Lookup or generate this device's UID
+ idManager = new IdentityManager(this);
+
+ // Initialize the DB
+ dbManager = new ComputerDatabaseManager(this);
+ dbRefCount.set(1);
+
+ // Grab known machines into our computer list
+ if (!getLocalDatabaseReference()) {
+ return;
+ }
+
+ for (ComputerDetails computer : dbManager.getAllComputers()) {
+ // Add tuples for each computer
+ addTuple(computer);
+ }
+
+ releaseLocalDatabaseReference();
+
+ // Monitor for network changes to invalidate our PC state
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ networkCallback = new ConnectivityManager.NetworkCallback() {
+ @Override
+ public void onAvailable(Network network) {
+ LimeLog.info("Resetting PC state for new available network");
+ synchronized (pollingTuples) {
+ for (PollingTuple tuple : pollingTuples) {
+ tuple.computer.state = ComputerDetails.State.UNKNOWN;
+ if (listener != null) {
+ listener.notifyComputerUpdated(tuple.computer);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void onLost(Network network) {
+ LimeLog.info("Offlining PCs due to network loss");
+ synchronized (pollingTuples) {
+ for (PollingTuple tuple : pollingTuples) {
+ tuple.computer.state = ComputerDetails.State.OFFLINE;
+ if (listener != null) {
+ listener.notifyComputerUpdated(tuple.computer);
+ }
+ }
+ }
+ }
+ };
+
+ ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
+ connMgr.registerDefaultNetworkCallback(networkCallback);
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
+ connMgr.unregisterNetworkCallback(networkCallback);
+ }
+
+ if (discoveryBinder != null) {
+ // Unbind from the discovery service
+ unbindService(discoveryServiceConnection);
+ }
+
+ // FIXME: Should await termination here but we have timeout issues in HttpURLConnection
+
+ // Remove the initial DB reference
+ releaseLocalDatabaseReference();
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return binder;
+ }
+
+ public class ApplistPoller {
+ private Thread thread;
+ private final ComputerDetails computer;
+ private final Object pollEvent = new Object();
+ private boolean receivedAppList = false;
+
+ public ApplistPoller(ComputerDetails computer) {
+ this.computer = computer;
+ }
+
+ public void pollNow() {
+ synchronized (pollEvent) {
+ pollEvent.notify();
+ }
+ }
+
+ private boolean waitPollingDelay() {
+ try {
+ synchronized (pollEvent) {
+ if (receivedAppList) {
+ // If we've already reported an app list successfully,
+ // wait the full polling period
+ pollEvent.wait(APPLIST_POLLING_PERIOD_MS);
+ }
+ else {
+ // If we've failed to get an app list so far, retry much earlier
+ pollEvent.wait(APPLIST_FAILED_POLLING_RETRY_MS);
+ }
+ }
+ } catch (InterruptedException e) {
+ return false;
+ }
+
+ return thread != null && !thread.isInterrupted();
+ }
+
+ private PollingTuple getPollingTuple(ComputerDetails details) {
+ synchronized (pollingTuples) {
+ for (PollingTuple tuple : pollingTuples) {
+ if (details.uuid.equals(tuple.computer.uuid)) {
+ return tuple;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ public void start() {
+ thread = new Thread() {
+ @Override
+ public void run() {
+ int emptyAppListResponses = 0;
+ do {
+ // Can't poll if it's not online or paired
+ if (computer.state != ComputerDetails.State.ONLINE ||
+ computer.pairState != PairingManager.PairState.PAIRED) {
+ if (listener != null) {
+ listener.notifyComputerUpdated(computer);
+ }
+ continue;
+ }
+
+ // Can't poll if there's no UUID yet
+ if (computer.uuid == null) {
+ continue;
+ }
+
+ PollingTuple tuple = getPollingTuple(computer);
+
+ try {
+ NvHTTP http = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(computer), computer.httpsPort, idManager.getUniqueId(),
+ computer.serverCert, PlatformBinding.getCryptoProvider(ComputerManagerService.this));
+
+ String appList;
+ if (tuple != null) {
+ // If we're polling this machine too, grab the network lock
+ // while doing the app list request to prevent other requests
+ // from being issued in the meantime.
+ synchronized (tuple.networkLock) {
+ appList = http.getAppListRaw();
+ }
+ }
+ else {
+ // No polling is happening now, so we just call it directly
+ appList = http.getAppListRaw();
+ }
+
+ List list = NvHTTP.getAppListByReader(new StringReader(appList));
+ if (list.isEmpty()) {
+ LimeLog.warning("Empty app list received from "+computer.uuid);
+
+ // The app list might actually be empty, so if we get an empty response a few times
+ // in a row, we'll go ahead and believe it.
+ emptyAppListResponses++;
+ }
+ if (!appList.isEmpty() &&
+ (!list.isEmpty() || emptyAppListResponses >= EMPTY_LIST_THRESHOLD)) {
+ // Open the cache file
+ try (final OutputStream cacheOut = CacheHelper.openCacheFileForOutput(
+ getCacheDir(), "applist", computer.uuid)
+ ) {
+ CacheHelper.writeStringToOutputStream(cacheOut, appList);
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+
+ // Reset empty count if it wasn't empty this time
+ if (!list.isEmpty()) {
+ emptyAppListResponses = 0;
+ }
+
+ // Update the computer
+ computer.rawAppList = appList;
+ receivedAppList = true;
+
+ // Notify that the app list has been updated
+ // and ensure that the thread is still active
+ if (listener != null && thread != null) {
+ listener.notifyComputerUpdated(computer);
+ }
+ }
+ else if (appList.isEmpty()) {
+ LimeLog.warning("Null app list received from "+computer.uuid);
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ } catch (XmlPullParserException e) {
+ e.printStackTrace();
+ }
+ } while (waitPollingDelay());
+ }
+ };
+ thread.setName("App list polling thread for " + computer.name);
+ thread.start();
+ }
+
+ public void stop() {
+ if (thread != null) {
+ thread.interrupt();
+
+ // Don't join here because we might be blocked on network I/O
+
+ thread = null;
+ }
+ }
+ }
+}
+
+class PollingTuple {
+ public Thread thread;
+ public final ComputerDetails computer;
+ public final Object networkLock;
+ public long lastSuccessfulPollMs;
+
+ public PollingTuple(ComputerDetails computer, Thread thread) {
+ this.computer = computer;
+ this.thread = thread;
+ this.networkLock = new Object();
+ }
+}
+
+class ReachabilityTuple {
+ public final String reachableAddress;
+ public final ComputerDetails computer;
+
+ public ReachabilityTuple(ComputerDetails computer, String reachableAddress) {
+ this.computer = computer;
+ this.reachableAddress = reachableAddress;
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/limelight/computers/IdentityManager.java b/app/src/main/java/com/limelight/computers/IdentityManager.java
old mode 100644
new mode 100755
index d0befc8bb3..cd0800ab04
--- a/app/src/main/java/com/limelight/computers/IdentityManager.java
+++ b/app/src/main/java/com/limelight/computers/IdentityManager.java
@@ -1,73 +1,73 @@
-package com.limelight.computers;
-
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import java.io.OutputStreamWriter;
-import java.util.Locale;
-import java.util.Random;
-
-import com.limelight.LimeLog;
-
-import android.content.Context;
-
-public class IdentityManager {
- private static final String UNIQUE_ID_FILE_NAME = "uniqueid";
- private static final int UID_SIZE_IN_BYTES = 8;
-
- private String uniqueId;
-
- public IdentityManager(Context c) {
- uniqueId = loadUniqueId(c);
- if (uniqueId == null) {
- uniqueId = generateNewUniqueId(c);
- }
-
- LimeLog.info("UID is now: "+uniqueId);
- }
-
- public String getUniqueId() {
- return uniqueId;
- }
-
- private static String loadUniqueId(Context c) {
- // 2 Hex digits per byte
- char[] uid = new char[UID_SIZE_IN_BYTES * 2];
- LimeLog.info("Reading UID from disk");
- try (final InputStreamReader reader =
- new InputStreamReader(c.openFileInput(UNIQUE_ID_FILE_NAME))
- ) {
- if (reader.read(uid) != UID_SIZE_IN_BYTES * 2) {
- LimeLog.severe("UID file data is truncated");
- return null;
- }
- return new String(uid);
- } catch (FileNotFoundException e) {
- LimeLog.info("No UID file found");
- return null;
- } catch (IOException e) {
- LimeLog.severe("Error while reading UID file");
- e.printStackTrace();
- return null;
- }
- }
-
- private static String generateNewUniqueId(Context c) {
- // Generate a new UID hex string
- LimeLog.info("Generating new UID");
- String uidStr = String.format((Locale)null, "%016x", new Random().nextLong());
-
- try (final OutputStreamWriter writer =
- new OutputStreamWriter(c.openFileOutput(UNIQUE_ID_FILE_NAME, 0))
- ) {
- writer.write(uidStr);
- LimeLog.info("UID written to disk");
- } catch (IOException e) {
- LimeLog.severe("Error while writing UID file");
- e.printStackTrace();
- }
-
- // We can return a UID even if I/O fails
- return uidStr;
- }
-}
+package com.limelight.computers;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.util.Locale;
+import java.util.Random;
+
+import com.limelight.LimeLog;
+
+import android.content.Context;
+
+public class IdentityManager {
+ private static final String UNIQUE_ID_FILE_NAME = "uniqueid";
+ private static final int UID_SIZE_IN_BYTES = 8;
+
+ private String uniqueId;
+
+ public IdentityManager(Context c) {
+ uniqueId = loadUniqueId(c);
+ if (uniqueId == null) {
+ uniqueId = generateNewUniqueId(c);
+ }
+
+ LimeLog.info("UID is now: "+uniqueId);
+ }
+
+ public String getUniqueId() {
+ return uniqueId;
+ }
+
+ private static String loadUniqueId(Context c) {
+ // 2 Hex digits per byte
+ char[] uid = new char[UID_SIZE_IN_BYTES * 2];
+ LimeLog.info("Reading UID from disk");
+ try (final InputStreamReader reader =
+ new InputStreamReader(c.openFileInput(UNIQUE_ID_FILE_NAME))
+ ) {
+ if (reader.read(uid) != UID_SIZE_IN_BYTES * 2) {
+ LimeLog.severe("UID file data is truncated");
+ return null;
+ }
+ return new String(uid);
+ } catch (FileNotFoundException e) {
+ LimeLog.info("No UID file found");
+ return null;
+ } catch (IOException e) {
+ LimeLog.severe("Error while reading UID file");
+ e.printStackTrace();
+ return null;
+ }
+ }
+
+ private static String generateNewUniqueId(Context c) {
+ // Generate a new UID hex string
+ LimeLog.info("Generating new UID");
+ String uidStr = String.format((Locale)null, "%016x", new Random().nextLong());
+
+ try (final OutputStreamWriter writer =
+ new OutputStreamWriter(c.openFileOutput(UNIQUE_ID_FILE_NAME, 0))
+ ) {
+ writer.write(uidStr);
+ LimeLog.info("UID written to disk");
+ } catch (IOException e) {
+ LimeLog.severe("Error while writing UID file");
+ e.printStackTrace();
+ }
+
+ // We can return a UID even if I/O fails
+ return uidStr;
+ }
+}
diff --git a/app/src/main/java/com/limelight/computers/LegacyDatabaseReader.java b/app/src/main/java/com/limelight/computers/LegacyDatabaseReader.java
old mode 100644
new mode 100755
index 27a7f1a35a..61a0fd408f
--- a/app/src/main/java/com/limelight/computers/LegacyDatabaseReader.java
+++ b/app/src/main/java/com/limelight/computers/LegacyDatabaseReader.java
@@ -1,103 +1,103 @@
-package com.limelight.computers;
-
-import android.content.Context;
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-import android.database.sqlite.SQLiteException;
-
-import com.limelight.LimeLog;
-import com.limelight.nvstream.http.ComputerDetails;
-import com.limelight.nvstream.http.NvHTTP;
-
-import java.net.InetAddress;
-import java.net.UnknownHostException;
-import java.util.LinkedList;
-import java.util.List;
-
-public class LegacyDatabaseReader {
- private static final String COMPUTER_DB_NAME = "computers.db";
- private static final String COMPUTER_TABLE_NAME = "Computers";
-
- private static final String ADDRESS_PREFIX = "ADDRESS_PREFIX__";
-
- private static ComputerDetails getComputerFromCursor(Cursor c) {
- ComputerDetails details = new ComputerDetails();
-
- details.name = c.getString(0);
- details.uuid = c.getString(1);
-
- // An earlier schema defined addresses as byte blobs. We'll
- // gracefully migrate those to strings so we can store DNS names
- // too. To disambiguate, we'll need to prefix them with a string
- // greater than the allowable IP address length.
- try {
- details.localAddress = new ComputerDetails.AddressTuple(InetAddress.getByAddress(c.getBlob(2)).getHostAddress(), NvHTTP.DEFAULT_HTTP_PORT);
- LimeLog.warning("DB: Legacy local address for " + details.name);
- } catch (UnknownHostException e) {
- // This is probably a hostname/address with the prefix string
- String stringData = c.getString(2);
- if (stringData.startsWith(ADDRESS_PREFIX)) {
- details.localAddress = new ComputerDetails.AddressTuple(c.getString(2).substring(ADDRESS_PREFIX.length()), NvHTTP.DEFAULT_HTTP_PORT);
- } else {
- LimeLog.severe("DB: Corrupted local address for " + details.name);
- }
- }
-
- try {
- details.remoteAddress = new ComputerDetails.AddressTuple(InetAddress.getByAddress(c.getBlob(3)).getHostAddress(), NvHTTP.DEFAULT_HTTP_PORT);
- LimeLog.warning("DB: Legacy remote address for " + details.name);
- } catch (UnknownHostException e) {
- // This is probably a hostname/address with the prefix string
- String stringData = c.getString(3);
- if (stringData.startsWith(ADDRESS_PREFIX)) {
- details.remoteAddress = new ComputerDetails.AddressTuple(c.getString(3).substring(ADDRESS_PREFIX.length()), NvHTTP.DEFAULT_HTTP_PORT);
- } else {
- LimeLog.severe("DB: Corrupted remote address for " + details.name);
- }
- }
-
- // On older versions of Moonlight, this is typically where manual addresses got stored,
- // so let's initialize it just to be safe.
- details.manualAddress = details.remoteAddress;
-
- details.macAddress = c.getString(4);
-
- // This signifies we don't have dynamic state (like pair state)
- details.state = ComputerDetails.State.UNKNOWN;
-
- return details;
- }
-
- private static List getAllComputers(SQLiteDatabase db) {
- try (final Cursor c = db.rawQuery("SELECT * FROM " + COMPUTER_TABLE_NAME, null)) {
- LinkedList computerList = new LinkedList<>();
- while (c.moveToNext()) {
- ComputerDetails details = getComputerFromCursor(c);
-
- // If a critical field is corrupt or missing, skip the database entry
- if (details.uuid == null) {
- continue;
- }
-
- computerList.add(details);
- }
-
- return computerList;
- }
- }
-
- public static List migrateAllComputers(Context c) {
- try (final SQLiteDatabase computerDb = SQLiteDatabase.openDatabase(
- c.getDatabasePath(COMPUTER_DB_NAME).getPath(),
- null, SQLiteDatabase.OPEN_READONLY)
- ) {
- // Open the existing database
- return getAllComputers(computerDb);
- } catch (SQLiteException e) {
- return new LinkedList();
- } finally {
- // Close and delete the old DB
- c.deleteDatabase(COMPUTER_DB_NAME);
- }
- }
+package com.limelight.computers;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteException;
+
+import com.limelight.LimeLog;
+import com.limelight.nvstream.http.ComputerDetails;
+import com.limelight.nvstream.http.NvHTTP;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.LinkedList;
+import java.util.List;
+
+public class LegacyDatabaseReader {
+ private static final String COMPUTER_DB_NAME = "computers.db";
+ private static final String COMPUTER_TABLE_NAME = "Computers";
+
+ private static final String ADDRESS_PREFIX = "ADDRESS_PREFIX__";
+
+ private static ComputerDetails getComputerFromCursor(Cursor c) {
+ ComputerDetails details = new ComputerDetails();
+
+ details.name = c.getString(0);
+ details.uuid = c.getString(1);
+
+ // An earlier schema defined addresses as byte blobs. We'll
+ // gracefully migrate those to strings so we can store DNS names
+ // too. To disambiguate, we'll need to prefix them with a string
+ // greater than the allowable IP address length.
+ try {
+ details.localAddress = new ComputerDetails.AddressTuple(InetAddress.getByAddress(c.getBlob(2)).getHostAddress(), NvHTTP.DEFAULT_HTTP_PORT);
+ LimeLog.warning("DB: Legacy local address for " + details.name);
+ } catch (UnknownHostException e) {
+ // This is probably a hostname/address with the prefix string
+ String stringData = c.getString(2);
+ if (stringData.startsWith(ADDRESS_PREFIX)) {
+ details.localAddress = new ComputerDetails.AddressTuple(c.getString(2).substring(ADDRESS_PREFIX.length()), NvHTTP.DEFAULT_HTTP_PORT);
+ } else {
+ LimeLog.severe("DB: Corrupted local address for " + details.name);
+ }
+ }
+
+ try {
+ details.remoteAddress = new ComputerDetails.AddressTuple(InetAddress.getByAddress(c.getBlob(3)).getHostAddress(), NvHTTP.DEFAULT_HTTP_PORT);
+ LimeLog.warning("DB: Legacy remote address for " + details.name);
+ } catch (UnknownHostException e) {
+ // This is probably a hostname/address with the prefix string
+ String stringData = c.getString(3);
+ if (stringData.startsWith(ADDRESS_PREFIX)) {
+ details.remoteAddress = new ComputerDetails.AddressTuple(c.getString(3).substring(ADDRESS_PREFIX.length()), NvHTTP.DEFAULT_HTTP_PORT);
+ } else {
+ LimeLog.severe("DB: Corrupted remote address for " + details.name);
+ }
+ }
+
+ // On older versions of Moonlight, this is typically where manual addresses got stored,
+ // so let's initialize it just to be safe.
+ details.manualAddress = details.remoteAddress;
+
+ details.macAddress = c.getString(4);
+
+ // This signifies we don't have dynamic state (like pair state)
+ details.state = ComputerDetails.State.UNKNOWN;
+
+ return details;
+ }
+
+ private static List getAllComputers(SQLiteDatabase db) {
+ try (final Cursor c = db.rawQuery("SELECT * FROM " + COMPUTER_TABLE_NAME, null)) {
+ LinkedList computerList = new LinkedList<>();
+ while (c.moveToNext()) {
+ ComputerDetails details = getComputerFromCursor(c);
+
+ // If a critical field is corrupt or missing, skip the database entry
+ if (details.uuid == null) {
+ continue;
+ }
+
+ computerList.add(details);
+ }
+
+ return computerList;
+ }
+ }
+
+ public static List migrateAllComputers(Context c) {
+ try (final SQLiteDatabase computerDb = SQLiteDatabase.openDatabase(
+ c.getDatabasePath(COMPUTER_DB_NAME).getPath(),
+ null, SQLiteDatabase.OPEN_READONLY)
+ ) {
+ // Open the existing database
+ return getAllComputers(computerDb);
+ } catch (SQLiteException e) {
+ return new LinkedList();
+ } finally {
+ // Close and delete the old DB
+ c.deleteDatabase(COMPUTER_DB_NAME);
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/limelight/computers/LegacyDatabaseReader2.java b/app/src/main/java/com/limelight/computers/LegacyDatabaseReader2.java
old mode 100644
new mode 100755
index 55ad59a4a8..7791bda2b1
--- a/app/src/main/java/com/limelight/computers/LegacyDatabaseReader2.java
+++ b/app/src/main/java/com/limelight/computers/LegacyDatabaseReader2.java
@@ -1,84 +1,84 @@
-package com.limelight.computers;
-
-import android.content.Context;
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-import android.database.sqlite.SQLiteException;
-
-import com.limelight.nvstream.http.ComputerDetails;
-import com.limelight.nvstream.http.NvHTTP;
-
-import java.io.ByteArrayInputStream;
-import java.security.cert.CertificateException;
-import java.security.cert.CertificateFactory;
-import java.security.cert.X509Certificate;
-import java.util.LinkedList;
-import java.util.List;
-
-public class LegacyDatabaseReader2 {
- private static final String COMPUTER_DB_NAME = "computers2.db";
- private static final String COMPUTER_TABLE_NAME = "Computers";
-
- private static ComputerDetails getComputerFromCursor(Cursor c) {
- ComputerDetails details = new ComputerDetails();
-
- details.uuid = c.getString(0);
- details.name = c.getString(1);
- details.localAddress = new ComputerDetails.AddressTuple(c.getString(2), NvHTTP.DEFAULT_HTTP_PORT);
- details.remoteAddress = new ComputerDetails.AddressTuple(c.getString(3), NvHTTP.DEFAULT_HTTP_PORT);
- details.manualAddress = new ComputerDetails.AddressTuple(c.getString(4), NvHTTP.DEFAULT_HTTP_PORT);
- details.macAddress = c.getString(5);
-
- // This column wasn't always present in the old schema
- if (c.getColumnCount() >= 7) {
- try {
- byte[] derCertData = c.getBlob(6);
-
- if (derCertData != null) {
- details.serverCert = (X509Certificate) CertificateFactory.getInstance("X.509")
- .generateCertificate(new ByteArrayInputStream(derCertData));
- }
- } catch (CertificateException e) {
- e.printStackTrace();
- }
- }
-
- // This signifies we don't have dynamic state (like pair state)
- details.state = ComputerDetails.State.UNKNOWN;
-
- return details;
- }
-
- public static List getAllComputers(SQLiteDatabase computerDb) {
- try (final Cursor c = computerDb.rawQuery("SELECT * FROM "+COMPUTER_TABLE_NAME, null)) {
- LinkedList computerList = new LinkedList<>();
- while (c.moveToNext()) {
- ComputerDetails details = getComputerFromCursor(c);
-
- // If a critical field is corrupt or missing, skip the database entry
- if (details.uuid == null) {
- continue;
- }
-
- computerList.add(details);
- }
-
- return computerList;
- }
- }
-
- public static List migrateAllComputers(Context c) {
- try (final SQLiteDatabase computerDb = SQLiteDatabase.openDatabase(
- c.getDatabasePath(COMPUTER_DB_NAME).getPath(),
- null, SQLiteDatabase.OPEN_READONLY)
- ) {
- // Open the existing database
- return getAllComputers(computerDb);
- } catch (SQLiteException e) {
- return new LinkedList();
- } finally {
- // Close and delete the old DB
- c.deleteDatabase(COMPUTER_DB_NAME);
- }
- }
+package com.limelight.computers;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteException;
+
+import com.limelight.nvstream.http.ComputerDetails;
+import com.limelight.nvstream.http.NvHTTP;
+
+import java.io.ByteArrayInputStream;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.util.LinkedList;
+import java.util.List;
+
+public class LegacyDatabaseReader2 {
+ private static final String COMPUTER_DB_NAME = "computers2.db";
+ private static final String COMPUTER_TABLE_NAME = "Computers";
+
+ private static ComputerDetails getComputerFromCursor(Cursor c) {
+ ComputerDetails details = new ComputerDetails();
+
+ details.uuid = c.getString(0);
+ details.name = c.getString(1);
+ details.localAddress = new ComputerDetails.AddressTuple(c.getString(2), NvHTTP.DEFAULT_HTTP_PORT);
+ details.remoteAddress = new ComputerDetails.AddressTuple(c.getString(3), NvHTTP.DEFAULT_HTTP_PORT);
+ details.manualAddress = new ComputerDetails.AddressTuple(c.getString(4), NvHTTP.DEFAULT_HTTP_PORT);
+ details.macAddress = c.getString(5);
+
+ // This column wasn't always present in the old schema
+ if (c.getColumnCount() >= 7) {
+ try {
+ byte[] derCertData = c.getBlob(6);
+
+ if (derCertData != null) {
+ details.serverCert = (X509Certificate) CertificateFactory.getInstance("X.509")
+ .generateCertificate(new ByteArrayInputStream(derCertData));
+ }
+ } catch (CertificateException e) {
+ e.printStackTrace();
+ }
+ }
+
+ // This signifies we don't have dynamic state (like pair state)
+ details.state = ComputerDetails.State.UNKNOWN;
+
+ return details;
+ }
+
+ public static List getAllComputers(SQLiteDatabase computerDb) {
+ try (final Cursor c = computerDb.rawQuery("SELECT * FROM "+COMPUTER_TABLE_NAME, null)) {
+ LinkedList computerList = new LinkedList<>();
+ while (c.moveToNext()) {
+ ComputerDetails details = getComputerFromCursor(c);
+
+ // If a critical field is corrupt or missing, skip the database entry
+ if (details.uuid == null) {
+ continue;
+ }
+
+ computerList.add(details);
+ }
+
+ return computerList;
+ }
+ }
+
+ public static List migrateAllComputers(Context c) {
+ try (final SQLiteDatabase computerDb = SQLiteDatabase.openDatabase(
+ c.getDatabasePath(COMPUTER_DB_NAME).getPath(),
+ null, SQLiteDatabase.OPEN_READONLY)
+ ) {
+ // Open the existing database
+ return getAllComputers(computerDb);
+ } catch (SQLiteException e) {
+ return new LinkedList();
+ } finally {
+ // Close and delete the old DB
+ c.deleteDatabase(COMPUTER_DB_NAME);
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/limelight/computers/LegacyDatabaseReader3.java b/app/src/main/java/com/limelight/computers/LegacyDatabaseReader3.java
old mode 100644
new mode 100755
index aca6d1e1c7..cdf779ef0b
--- a/app/src/main/java/com/limelight/computers/LegacyDatabaseReader3.java
+++ b/app/src/main/java/com/limelight/computers/LegacyDatabaseReader3.java
@@ -1,123 +1,123 @@
-package com.limelight.computers;
-
-import android.content.Context;
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-import android.database.sqlite.SQLiteException;
-
-import com.limelight.nvstream.http.ComputerDetails;
-import com.limelight.nvstream.http.NvHTTP;
-
-import java.io.ByteArrayInputStream;
-import java.security.cert.CertificateException;
-import java.security.cert.CertificateFactory;
-import java.security.cert.X509Certificate;
-import java.util.LinkedList;
-import java.util.List;
-
-public class LegacyDatabaseReader3 {
- private static final String COMPUTER_DB_NAME = "computers3.db";
- private static final String COMPUTER_TABLE_NAME = "Computers";
-
- private static final char ADDRESS_DELIMITER = ';';
- private static final char PORT_DELIMITER = '_';
-
- private static String readNonEmptyString(String input) {
- if (input.isEmpty()) {
- return null;
- }
-
- return input;
- }
-
- private static ComputerDetails.AddressTuple splitAddressToTuple(String input) {
- if (input == null) {
- return null;
- }
-
- String[] parts = input.split(""+PORT_DELIMITER, -1);
- if (parts.length == 1) {
- return new ComputerDetails.AddressTuple(parts[0], NvHTTP.DEFAULT_HTTP_PORT);
- }
- else {
- return new ComputerDetails.AddressTuple(parts[0], Integer.parseInt(parts[1]));
- }
- }
-
- private static String splitTupleToAddress(ComputerDetails.AddressTuple tuple) {
- return tuple.address+PORT_DELIMITER+tuple.port;
- }
-
- private static ComputerDetails getComputerFromCursor(Cursor c) {
- ComputerDetails details = new ComputerDetails();
-
- details.uuid = c.getString(0);
- details.name = c.getString(1);
-
- String[] addresses = c.getString(2).split(""+ADDRESS_DELIMITER, -1);
-
- details.localAddress = splitAddressToTuple(readNonEmptyString(addresses[0]));
- details.remoteAddress = splitAddressToTuple(readNonEmptyString(addresses[1]));
- details.manualAddress = splitAddressToTuple(readNonEmptyString(addresses[2]));
- details.ipv6Address = splitAddressToTuple(readNonEmptyString(addresses[3]));
-
- // External port is persisted in the remote address field
- if (details.remoteAddress != null) {
- details.externalPort = details.remoteAddress.port;
- }
- else {
- details.externalPort = NvHTTP.DEFAULT_HTTP_PORT;
- }
-
- details.macAddress = c.getString(3);
-
- try {
- byte[] derCertData = c.getBlob(4);
-
- if (derCertData != null) {
- details.serverCert = (X509Certificate) CertificateFactory.getInstance("X.509")
- .generateCertificate(new ByteArrayInputStream(derCertData));
- }
- } catch (CertificateException e) {
- e.printStackTrace();
- }
-
- // This signifies we don't have dynamic state (like pair state)
- details.state = ComputerDetails.State.UNKNOWN;
-
- return details;
- }
-
- public static List getAllComputers(SQLiteDatabase computerDb) {
- try (final Cursor c = computerDb.rawQuery("SELECT * FROM "+COMPUTER_TABLE_NAME, null)) {
- LinkedList computerList = new LinkedList<>();
- while (c.moveToNext()) {
- ComputerDetails details = getComputerFromCursor(c);
-
- // If a critical field is corrupt or missing, skip the database entry
- if (details.uuid == null) {
- continue;
- }
-
- computerList.add(details);
- }
-
- return computerList;
- }
- }
-
- public static List migrateAllComputers(Context c) {
- try (final SQLiteDatabase computerDb = SQLiteDatabase.openDatabase(
- c.getDatabasePath(COMPUTER_DB_NAME).getPath(),
- null, SQLiteDatabase.OPEN_READONLY)
- ) {
- // Open the existing database
- return getAllComputers(computerDb);
- } catch (SQLiteException e) {
- return new LinkedList();
- } finally {
- // Close and delete the old DB
- c.deleteDatabase(COMPUTER_DB_NAME);
- }
- }
-}
+package com.limelight.computers;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteException;
+
+import com.limelight.nvstream.http.ComputerDetails;
+import com.limelight.nvstream.http.NvHTTP;
+
+import java.io.ByteArrayInputStream;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.util.LinkedList;
+import java.util.List;
+
+public class LegacyDatabaseReader3 {
+ private static final String COMPUTER_DB_NAME = "computers3.db";
+ private static final String COMPUTER_TABLE_NAME = "Computers";
+
+ private static final char ADDRESS_DELIMITER = ';';
+ private static final char PORT_DELIMITER = '_';
+
+ private static String readNonEmptyString(String input) {
+ if (input.isEmpty()) {
+ return null;
+ }
+
+ return input;
+ }
+
+ private static ComputerDetails.AddressTuple splitAddressToTuple(String input) {
+ if (input == null) {
+ return null;
+ }
+
+ String[] parts = input.split(""+PORT_DELIMITER, -1);
+ if (parts.length == 1) {
+ return new ComputerDetails.AddressTuple(parts[0], NvHTTP.DEFAULT_HTTP_PORT);
+ }
+ else {
+ return new ComputerDetails.AddressTuple(parts[0], Integer.parseInt(parts[1]));
+ }
+ }
+
+ private static String splitTupleToAddress(ComputerDetails.AddressTuple tuple) {
+ return tuple.address+PORT_DELIMITER+tuple.port;
+ }
+
+ private static ComputerDetails getComputerFromCursor(Cursor c) {
+ ComputerDetails details = new ComputerDetails();
+
+ details.uuid = c.getString(0);
+ details.name = c.getString(1);
+
+ String[] addresses = c.getString(2).split(""+ADDRESS_DELIMITER, -1);
+
+ details.localAddress = splitAddressToTuple(readNonEmptyString(addresses[0]));
+ details.remoteAddress = splitAddressToTuple(readNonEmptyString(addresses[1]));
+ details.manualAddress = splitAddressToTuple(readNonEmptyString(addresses[2]));
+ details.ipv6Address = splitAddressToTuple(readNonEmptyString(addresses[3]));
+
+ // External port is persisted in the remote address field
+ if (details.remoteAddress != null) {
+ details.externalPort = details.remoteAddress.port;
+ }
+ else {
+ details.externalPort = NvHTTP.DEFAULT_HTTP_PORT;
+ }
+
+ details.macAddress = c.getString(3);
+
+ try {
+ byte[] derCertData = c.getBlob(4);
+
+ if (derCertData != null) {
+ details.serverCert = (X509Certificate) CertificateFactory.getInstance("X.509")
+ .generateCertificate(new ByteArrayInputStream(derCertData));
+ }
+ } catch (CertificateException e) {
+ e.printStackTrace();
+ }
+
+ // This signifies we don't have dynamic state (like pair state)
+ details.state = ComputerDetails.State.UNKNOWN;
+
+ return details;
+ }
+
+ public static List getAllComputers(SQLiteDatabase computerDb) {
+ try (final Cursor c = computerDb.rawQuery("SELECT * FROM "+COMPUTER_TABLE_NAME, null)) {
+ LinkedList computerList = new LinkedList<>();
+ while (c.moveToNext()) {
+ ComputerDetails details = getComputerFromCursor(c);
+
+ // If a critical field is corrupt or missing, skip the database entry
+ if (details.uuid == null) {
+ continue;
+ }
+
+ computerList.add(details);
+ }
+
+ return computerList;
+ }
+ }
+
+ public static List migrateAllComputers(Context c) {
+ try (final SQLiteDatabase computerDb = SQLiteDatabase.openDatabase(
+ c.getDatabasePath(COMPUTER_DB_NAME).getPath(),
+ null, SQLiteDatabase.OPEN_READONLY)
+ ) {
+ // Open the existing database
+ return getAllComputers(computerDb);
+ } catch (SQLiteException e) {
+ return new LinkedList();
+ } finally {
+ // Close and delete the old DB
+ c.deleteDatabase(COMPUTER_DB_NAME);
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/discovery/DiscoveryService.java b/app/src/main/java/com/limelight/discovery/DiscoveryService.java
old mode 100644
new mode 100755
index f8c855d679..4a9fcd5e86
--- a/app/src/main/java/com/limelight/discovery/DiscoveryService.java
+++ b/app/src/main/java/com/limelight/discovery/DiscoveryService.java
@@ -1,90 +1,90 @@
-package com.limelight.discovery;
-
-import java.util.List;
-
-import com.limelight.nvstream.mdns.MdnsComputer;
-import com.limelight.nvstream.mdns.JmDNSDiscoveryAgent;
-import com.limelight.nvstream.mdns.MdnsDiscoveryAgent;
-import com.limelight.nvstream.mdns.MdnsDiscoveryListener;
-import com.limelight.nvstream.mdns.NsdManagerDiscoveryAgent;
-
-import android.app.Service;
-import android.content.Intent;
-import android.os.Binder;
-import android.os.Build;
-import android.os.IBinder;
-
-public class DiscoveryService extends Service {
-
- private MdnsDiscoveryAgent discoveryAgent;
- private MdnsDiscoveryListener boundListener;
-
- public class DiscoveryBinder extends Binder {
- public void setListener(MdnsDiscoveryListener listener) {
- boundListener = listener;
- }
-
- public void startDiscovery(int queryIntervalMs) {
- discoveryAgent.startDiscovery(queryIntervalMs);
- }
-
- public void stopDiscovery() {
- discoveryAgent.stopDiscovery();
- }
-
- public List getComputerSet() {
- return discoveryAgent.getComputerSet();
- }
- }
-
- @Override
- public void onCreate() {
- MdnsDiscoveryListener listener = new MdnsDiscoveryListener() {
- @Override
- public void notifyComputerAdded(MdnsComputer computer) {
- if (boundListener != null) {
- boundListener.notifyComputerAdded(computer);
- }
- }
-
- @Override
- public void notifyDiscoveryFailure(Exception e) {
- if (boundListener != null) {
- boundListener.notifyDiscoveryFailure(e);
- }
- }
- };
-
- // Prior to Android 14, NsdManager doesn't provide all the capabilities needed for parity
- // with jmDNS (specifically handling multiple addresses for a single service). There are
- // also documented reliability bugs early in the Android 4.x series shortly after it was
- // introduced. The benefit of using NsdManager over jmDNS is that it works correctly in
- // environments where mDNS proxying is required, like ChromeOS, WSA, and the emulator.
- //
- // As such, we use the jmDNS-based MdnsDiscoveryAgent prior to Android 14 and NsdManager
- // on Android 14 and above.
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
- discoveryAgent = new JmDNSDiscoveryAgent(getApplicationContext(), listener);
- }
- else {
- discoveryAgent = new NsdManagerDiscoveryAgent(getApplicationContext(), listener);
- }
- }
-
- private final DiscoveryBinder binder = new DiscoveryBinder();
-
- @Override
- public IBinder onBind(Intent intent) {
- return binder;
- }
-
- @Override
- public boolean onUnbind(Intent intent) {
- // Stop any discovery session
- discoveryAgent.stopDiscovery();
-
- // Unbind the listener
- boundListener = null;
- return false;
- }
-}
+package com.limelight.discovery;
+
+import java.util.List;
+
+import com.limelight.nvstream.mdns.MdnsComputer;
+import com.limelight.nvstream.mdns.JmDNSDiscoveryAgent;
+import com.limelight.nvstream.mdns.MdnsDiscoveryAgent;
+import com.limelight.nvstream.mdns.MdnsDiscoveryListener;
+import com.limelight.nvstream.mdns.NsdManagerDiscoveryAgent;
+
+import android.app.Service;
+import android.content.Intent;
+import android.os.Binder;
+import android.os.Build;
+import android.os.IBinder;
+
+public class DiscoveryService extends Service {
+
+ private MdnsDiscoveryAgent discoveryAgent;
+ private MdnsDiscoveryListener boundListener;
+
+ public class DiscoveryBinder extends Binder {
+ public void setListener(MdnsDiscoveryListener listener) {
+ boundListener = listener;
+ }
+
+ public void startDiscovery(int queryIntervalMs) {
+ discoveryAgent.startDiscovery(queryIntervalMs);
+ }
+
+ public void stopDiscovery() {
+ discoveryAgent.stopDiscovery();
+ }
+
+ public List getComputerSet() {
+ return discoveryAgent.getComputerSet();
+ }
+ }
+
+ @Override
+ public void onCreate() {
+ MdnsDiscoveryListener listener = new MdnsDiscoveryListener() {
+ @Override
+ public void notifyComputerAdded(MdnsComputer computer) {
+ if (boundListener != null) {
+ boundListener.notifyComputerAdded(computer);
+ }
+ }
+
+ @Override
+ public void notifyDiscoveryFailure(Exception e) {
+ if (boundListener != null) {
+ boundListener.notifyDiscoveryFailure(e);
+ }
+ }
+ };
+
+ // Prior to Android 14, NsdManager doesn't provide all the capabilities needed for parity
+ // with jmDNS (specifically handling multiple addresses for a single service). There are
+ // also documented reliability bugs early in the Android 4.x series shortly after it was
+ // introduced. The benefit of using NsdManager over jmDNS is that it works correctly in
+ // environments where mDNS proxying is required, like ChromeOS, WSA, and the emulator.
+ //
+ // As such, we use the jmDNS-based MdnsDiscoveryAgent prior to Android 14 and NsdManager
+ // on Android 14 and above.
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ discoveryAgent = new JmDNSDiscoveryAgent(getApplicationContext(), listener);
+ }
+ else {
+ discoveryAgent = new NsdManagerDiscoveryAgent(getApplicationContext(), listener);
+ }
+ }
+
+ private final DiscoveryBinder binder = new DiscoveryBinder();
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return binder;
+ }
+
+ @Override
+ public boolean onUnbind(Intent intent) {
+ // Stop any discovery session
+ discoveryAgent.stopDiscovery();
+
+ // Unbind the listener
+ boundListener = null;
+ return false;
+ }
+}
diff --git a/app/src/main/java/com/limelight/grid/AppGridAdapter.java b/app/src/main/java/com/limelight/grid/AppGridAdapter.java
old mode 100644
new mode 100755
index de594bba74..039c63bdf3
--- a/app/src/main/java/com/limelight/grid/AppGridAdapter.java
+++ b/app/src/main/java/com/limelight/grid/AppGridAdapter.java
@@ -1,184 +1,187 @@
-package com.limelight.grid;
-
-import android.content.Context;
-import android.graphics.BitmapFactory;
-import android.view.View;
-import android.widget.ImageView;
-import android.widget.ProgressBar;
-import android.widget.TextView;
-
-import com.limelight.AppView;
-import com.limelight.LimeLog;
-import com.limelight.R;
-import com.limelight.grid.assets.CachedAppAssetLoader;
-import com.limelight.grid.assets.DiskAssetLoader;
-import com.limelight.grid.assets.MemoryAssetLoader;
-import com.limelight.grid.assets.NetworkAssetLoader;
-import com.limelight.nvstream.http.ComputerDetails;
-import com.limelight.preferences.PreferenceConfiguration;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-@SuppressWarnings("unchecked")
-public class AppGridAdapter extends GenericGridAdapter {
- private static final int ART_WIDTH_PX = 300;
- private static final int SMALL_WIDTH_DP = 100;
- private static final int LARGE_WIDTH_DP = 150;
-
- private final ComputerDetails computer;
- private final String uniqueId;
- private final boolean showHiddenApps;
-
- private CachedAppAssetLoader loader;
- private Set hiddenAppIds = new HashSet<>();
- private ArrayList allApps = new ArrayList<>();
-
- public AppGridAdapter(Context context, PreferenceConfiguration prefs, ComputerDetails computer, String uniqueId, boolean showHiddenApps) {
- super(context, getLayoutIdForPreferences(prefs));
-
- this.computer = computer;
- this.uniqueId = uniqueId;
- this.showHiddenApps = showHiddenApps;
-
- updateLayoutWithPreferences(context, prefs);
- }
-
- public void updateHiddenApps(Set newHiddenAppIds, boolean hideImmediately) {
- this.hiddenAppIds.clear();
- this.hiddenAppIds.addAll(newHiddenAppIds);
-
- if (hideImmediately) {
- // Reconstruct the itemList with the new hidden app set
- itemList.clear();
- for (AppView.AppObject app : allApps) {
- app.isHidden = hiddenAppIds.contains(app.app.getAppId());
-
- if (!app.isHidden || showHiddenApps) {
- itemList.add(app);
- }
- }
- }
- else {
- // Just update the isHidden state to show the correct UI indication
- for (AppView.AppObject app : allApps) {
- app.isHidden = hiddenAppIds.contains(app.app.getAppId());
- }
- }
-
- notifyDataSetChanged();
- }
-
- private static int getLayoutIdForPreferences(PreferenceConfiguration prefs) {
- if (prefs.smallIconMode) {
- return R.layout.app_grid_item_small;
- }
- else {
- return R.layout.app_grid_item;
- }
- }
-
- public void updateLayoutWithPreferences(Context context, PreferenceConfiguration prefs) {
- int dpi = context.getResources().getDisplayMetrics().densityDpi;
- int dp;
-
- if (prefs.smallIconMode) {
- dp = SMALL_WIDTH_DP;
- }
- else {
- dp = LARGE_WIDTH_DP;
- }
-
- double scalingDivisor = ART_WIDTH_PX / (dp * (dpi / 160.0));
- if (scalingDivisor < 1.0) {
- // We don't want to make them bigger before draw-time
- scalingDivisor = 1.0;
- }
- LimeLog.info("Art scaling divisor: " + scalingDivisor);
-
- if (loader != null) {
- // Cancel operations on the old loader
- cancelQueuedOperations();
- }
-
- this.loader = new CachedAppAssetLoader(computer, scalingDivisor,
- new NetworkAssetLoader(context, uniqueId),
- new MemoryAssetLoader(),
- new DiskAssetLoader(context),
- BitmapFactory.decodeResource(context.getResources(), R.drawable.no_app_image));
-
- // This will trigger the view to reload with the new layout
- setLayoutId(getLayoutIdForPreferences(prefs));
- }
-
- public void cancelQueuedOperations() {
- loader.cancelForegroundLoads();
- loader.cancelBackgroundLoads();
- loader.freeCacheMemory();
- }
-
- private static void sortList(List list) {
- Collections.sort(list, new Comparator() {
- @Override
- public int compare(AppView.AppObject lhs, AppView.AppObject rhs) {
- return lhs.app.getAppName().toLowerCase().compareTo(rhs.app.getAppName().toLowerCase());
- }
- });
- }
-
- public void addApp(AppView.AppObject app) {
- // Update hidden state
- app.isHidden = hiddenAppIds.contains(app.app.getAppId());
-
- // Always add the app to the all apps list
- allApps.add(app);
- sortList(allApps);
-
- // Add the app to the adapter data if it's not hidden
- if (showHiddenApps || !app.isHidden) {
- // Queue a request to fetch this bitmap into cache
- loader.queueCacheLoad(app.app);
-
- // Add the app to our sorted list
- itemList.add(app);
- sortList(itemList);
- }
- }
-
- public void removeApp(AppView.AppObject app) {
- itemList.remove(app);
- allApps.remove(app);
- }
-
- @Override
- public void clear() {
- super.clear();
- allApps.clear();
- }
-
- @Override
- public void populateView(View parentView, ImageView imgView, ProgressBar prgView, TextView txtView, ImageView overlayView, AppView.AppObject obj) {
- // Let the cached asset loader handle it
- loader.populateImageView(obj.app, imgView, txtView);
-
- if (obj.isRunning) {
- // Show the play button overlay
- overlayView.setImageResource(R.drawable.ic_play);
- overlayView.setVisibility(View.VISIBLE);
- }
- else {
- overlayView.setVisibility(View.GONE);
- }
-
- if (obj.isHidden) {
- parentView.setAlpha(0.40f);
- }
- else {
- parentView.setAlpha(1.0f);
- }
- }
-}
+package com.limelight.grid;
+
+import android.content.Context;
+import android.graphics.BitmapFactory;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import com.limelight.AppView;
+import com.limelight.LimeLog;
+import com.limelight.R;
+import com.limelight.grid.assets.CachedAppAssetLoader;
+import com.limelight.grid.assets.DiskAssetLoader;
+import com.limelight.grid.assets.MemoryAssetLoader;
+import com.limelight.grid.assets.NetworkAssetLoader;
+import com.limelight.nvstream.http.ComputerDetails;
+import com.limelight.preferences.PreferenceConfiguration;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+@SuppressWarnings("unchecked")
+public class AppGridAdapter extends GenericGridAdapter {
+ private static final int ART_WIDTH_PX = 300;
+ private static final int SMALL_WIDTH_DP = 110;
+ private static final int LARGE_WIDTH_DP = 170;
+
+ private final ComputerDetails computer;
+ private final String uniqueId;
+ private final boolean showHiddenApps;
+
+ private CachedAppAssetLoader loader;
+ private Set hiddenAppIds = new HashSet<>();
+ private ArrayList allApps = new ArrayList<>();
+
+ public AppGridAdapter(Context context, PreferenceConfiguration prefs, ComputerDetails computer, String uniqueId, boolean showHiddenApps) {
+ super(context, getLayoutIdForPreferences(prefs));
+
+ this.computer = computer;
+ this.uniqueId = uniqueId;
+ this.showHiddenApps = showHiddenApps;
+
+ updateLayoutWithPreferences(context, prefs);
+ }
+
+ public void updateHiddenApps(Set newHiddenAppIds, boolean hideImmediately) {
+ this.hiddenAppIds.clear();
+ this.hiddenAppIds.addAll(newHiddenAppIds);
+
+ if (hideImmediately) {
+ // Reconstruct the itemList with the new hidden app set
+ itemList.clear();
+ for (AppView.AppObject app : allApps) {
+ app.isHidden = hiddenAppIds.contains(app.app.getAppId());
+
+ if (!app.isHidden || showHiddenApps) {
+ itemList.add(app);
+ }
+ }
+ }
+ else {
+ // Just update the isHidden state to show the correct UI indication
+ for (AppView.AppObject app : allApps) {
+ app.isHidden = hiddenAppIds.contains(app.app.getAppId());
+ }
+ }
+
+ notifyDataSetChanged();
+ }
+
+ private static int getLayoutIdForPreferences(PreferenceConfiguration prefs) {
+ if (prefs.smallIconMode) {
+ return R.layout.app_grid_item_small;
+ }
+ else {
+ return R.layout.app_grid_item;
+ }
+ }
+
+ public void updateLayoutWithPreferences(Context context, PreferenceConfiguration prefs) {
+ int dpi = context.getResources().getDisplayMetrics().densityDpi;
+ int dp;
+
+ if (prefs.smallIconMode) {
+ dp = SMALL_WIDTH_DP;
+ }
+ else {
+ dp = LARGE_WIDTH_DP;
+ }
+
+ double scalingDivisor = ART_WIDTH_PX / (dp * (dpi / 160.0));
+ if (scalingDivisor < 1.0) {
+ // We don't want to make them bigger before draw-time
+ scalingDivisor = 1.0;
+ }
+ LimeLog.info("Art scaling divisor: " + scalingDivisor);
+
+ if (loader != null) {
+ // Cancel operations on the old loader
+ cancelQueuedOperations();
+ }
+
+ this.loader = new CachedAppAssetLoader(computer, scalingDivisor,
+ new NetworkAssetLoader(context, uniqueId),
+ new MemoryAssetLoader(),
+ new DiskAssetLoader(context),
+ BitmapFactory.decodeResource(context.getResources(), R.drawable.no_app_image));
+
+ // This will trigger the view to reload with the new layout
+ setLayoutId(getLayoutIdForPreferences(prefs));
+ }
+
+ public void cancelQueuedOperations() {
+ loader.cancelForegroundLoads();
+ loader.cancelBackgroundLoads();
+ loader.freeCacheMemory();
+ }
+
+ private static void sortList(List list) {
+ Collections.sort(list, new Comparator() {
+ @Override
+ public int compare(AppView.AppObject lhs, AppView.AppObject rhs) {
+ return lhs.app.getAppName().toLowerCase().compareTo(rhs.app.getAppName().toLowerCase());
+ }
+ });
+ }
+
+ public void addApp(AppView.AppObject app) {
+ // Update hidden state
+ app.isHidden = hiddenAppIds.contains(app.app.getAppId());
+
+ // Always add the app to the all apps list
+ allApps.add(app);
+ sortList(allApps);
+
+ // Add the app to the adapter data if it's not hidden
+ if (showHiddenApps || !app.isHidden) {
+ // Queue a request to fetch this bitmap into cache
+ loader.queueCacheLoad(app.app);
+
+ // Add the app to our sorted list
+ itemList.add(app);
+ sortList(itemList);
+ }
+ }
+
+ public void removeApp(AppView.AppObject app) {
+ itemList.remove(app);
+ allApps.remove(app);
+ }
+
+ @Override
+ public void clear() {
+ super.clear();
+ allApps.clear();
+ }
+
+ @Override
+ public void populateView(View parentView, ImageView imgView, RelativeLayout gridMask, ProgressBar prgView, TextView txtView, ImageView overlayView, AppView.AppObject obj) {
+ // Let the cached asset loader handle it
+ loader.populateImageView(obj.app, imgView, txtView);
+
+ if (obj.isRunning) {
+ // Show the play button overlay
+ overlayView.setImageResource(R.drawable.ic_play);
+ overlayView.setVisibility(View.VISIBLE);
+ gridMask.setBackgroundColor(0x66000000);
+ }
+ else {
+ overlayView.setVisibility(View.GONE);
+ gridMask.setBackgroundColor(0x00000000);
+ }
+
+ if (obj.isHidden) {
+ parentView.setAlpha(0.40f);
+ }
+ else {
+ parentView.setAlpha(1.0f);
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/grid/GenericGridAdapter.java b/app/src/main/java/com/limelight/grid/GenericGridAdapter.java
old mode 100644
new mode 100755
index cec3de5b7c..b9b8961c9e
--- a/app/src/main/java/com/limelight/grid/GenericGridAdapter.java
+++ b/app/src/main/java/com/limelight/grid/GenericGridAdapter.java
@@ -1,74 +1,76 @@
-package com.limelight.grid;
-
-import android.content.Context;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.BaseAdapter;
-import android.widget.ImageView;
-import android.widget.ProgressBar;
-import android.widget.TextView;
-
-import com.limelight.R;
-
-import java.util.ArrayList;
-
-public abstract class GenericGridAdapter extends BaseAdapter {
- protected final Context context;
- private int layoutId;
- final ArrayList itemList = new ArrayList<>();
- private final LayoutInflater inflater;
-
- GenericGridAdapter(Context context, int layoutId) {
- this.context = context;
- this.layoutId = layoutId;
-
- this.inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
- }
-
- void setLayoutId(int layoutId) {
- if (layoutId != this.layoutId) {
- this.layoutId = layoutId;
-
- // Force the view to be redrawn with the new layout
- notifyDataSetInvalidated();
- }
- }
-
- public void clear() {
- itemList.clear();
- }
-
- @Override
- public int getCount() {
- return itemList.size();
- }
-
- @Override
- public Object getItem(int i) {
- return itemList.get(i);
- }
-
- @Override
- public long getItemId(int i) {
- return i;
- }
-
- public abstract void populateView(View parentView, ImageView imgView, ProgressBar prgView, TextView txtView, ImageView overlayView, T obj);
-
- @Override
- public View getView(int i, View convertView, ViewGroup viewGroup) {
- if (convertView == null) {
- convertView = inflater.inflate(layoutId, viewGroup, false);
- }
-
- ImageView imgView = convertView.findViewById(R.id.grid_image);
- ImageView overlayView = convertView.findViewById(R.id.grid_overlay);
- TextView txtView = convertView.findViewById(R.id.grid_text);
- ProgressBar prgView = convertView.findViewById(R.id.grid_spinner);
-
- populateView(convertView, imgView, prgView, txtView, overlayView, itemList.get(i));
-
- return convertView;
- }
-}
+package com.limelight.grid;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import com.limelight.R;
+
+import java.util.ArrayList;
+
+public abstract class GenericGridAdapter extends BaseAdapter {
+ protected final Context context;
+ private int layoutId;
+ final ArrayList itemList = new ArrayList<>();
+ private final LayoutInflater inflater;
+
+ GenericGridAdapter(Context context, int layoutId) {
+ this.context = context;
+ this.layoutId = layoutId;
+
+ this.inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ }
+
+ void setLayoutId(int layoutId) {
+ if (layoutId != this.layoutId) {
+ this.layoutId = layoutId;
+
+ // Force the view to be redrawn with the new layout
+ notifyDataSetInvalidated();
+ }
+ }
+
+ public void clear() {
+ itemList.clear();
+ }
+
+ @Override
+ public int getCount() {
+ return itemList.size();
+ }
+
+ @Override
+ public Object getItem(int i) {
+ return itemList.get(i);
+ }
+
+ @Override
+ public long getItemId(int i) {
+ return i;
+ }
+
+ public abstract void populateView(View parentView, ImageView imgView, RelativeLayout gridMask, ProgressBar prgView, TextView txtView, ImageView overlayView, T obj);
+
+ @Override
+ public View getView(int i, View convertView, ViewGroup viewGroup) {
+ if (convertView == null) {
+ convertView = inflater.inflate(layoutId, viewGroup, false);
+ }
+
+ ImageView imgView = convertView.findViewById(R.id.grid_image);
+ RelativeLayout gridMask = convertView.findViewById(R.id.grid_mask);
+ ImageView overlayView = convertView.findViewById(R.id.grid_overlay);
+ TextView txtView = convertView.findViewById(R.id.grid_text);
+ ProgressBar prgView = convertView.findViewById(R.id.grid_spinner);
+
+ populateView(convertView, imgView, gridMask, prgView, txtView, overlayView, itemList.get(i));
+
+ return convertView;
+ }
+}
diff --git a/app/src/main/java/com/limelight/grid/PcGridAdapter.java b/app/src/main/java/com/limelight/grid/PcGridAdapter.java
old mode 100644
new mode 100755
index 91e313f383..b5d538dca0
--- a/app/src/main/java/com/limelight/grid/PcGridAdapter.java
+++ b/app/src/main/java/com/limelight/grid/PcGridAdapter.java
@@ -1,93 +1,94 @@
-package com.limelight.grid;
-
-import android.content.Context;
-import android.view.View;
-import android.widget.ImageView;
-import android.widget.ProgressBar;
-import android.widget.TextView;
-
-import com.limelight.PcView;
-import com.limelight.R;
-import com.limelight.nvstream.http.ComputerDetails;
-import com.limelight.nvstream.http.PairingManager;
-import com.limelight.preferences.PreferenceConfiguration;
-
-import java.util.Collections;
-import java.util.Comparator;
-
-public class PcGridAdapter extends GenericGridAdapter {
-
- public PcGridAdapter(Context context, PreferenceConfiguration prefs) {
- super(context, getLayoutIdForPreferences(prefs));
- }
-
- private static int getLayoutIdForPreferences(PreferenceConfiguration prefs) {
- return R.layout.pc_grid_item;
- }
-
- public void updateLayoutWithPreferences(Context context, PreferenceConfiguration prefs) {
- // This will trigger the view to reload with the new layout
- setLayoutId(getLayoutIdForPreferences(prefs));
- }
-
- public void addComputer(PcView.ComputerObject computer) {
- itemList.add(computer);
- sortList();
- }
-
- private void sortList() {
- Collections.sort(itemList, new Comparator() {
- @Override
- public int compare(PcView.ComputerObject lhs, PcView.ComputerObject rhs) {
- return lhs.details.name.toLowerCase().compareTo(rhs.details.name.toLowerCase());
- }
- });
- }
-
- public boolean removeComputer(PcView.ComputerObject computer) {
- return itemList.remove(computer);
- }
-
- @Override
- public void populateView(View parentView, ImageView imgView, ProgressBar prgView, TextView txtView, ImageView overlayView, PcView.ComputerObject obj) {
- imgView.setImageResource(R.drawable.ic_computer);
- if (obj.details.state == ComputerDetails.State.ONLINE) {
- imgView.setAlpha(1.0f);
- }
- else {
- imgView.setAlpha(0.4f);
- }
-
- if (obj.details.state == ComputerDetails.State.UNKNOWN) {
- prgView.setVisibility(View.VISIBLE);
- }
- else {
- prgView.setVisibility(View.INVISIBLE);
- }
-
- txtView.setText(obj.details.name);
- if (obj.details.state == ComputerDetails.State.ONLINE) {
- txtView.setAlpha(1.0f);
- }
- else {
- txtView.setAlpha(0.4f);
- }
-
- if (obj.details.state == ComputerDetails.State.OFFLINE) {
- overlayView.setImageResource(R.drawable.ic_pc_offline);
- overlayView.setAlpha(0.4f);
- overlayView.setVisibility(View.VISIBLE);
- }
- // We must check if the status is exactly online and unpaired
- // to avoid colliding with the loading spinner when status is unknown
- else if (obj.details.state == ComputerDetails.State.ONLINE &&
- obj.details.pairState == PairingManager.PairState.NOT_PAIRED) {
- overlayView.setImageResource(R.drawable.ic_lock);
- overlayView.setAlpha(1.0f);
- overlayView.setVisibility(View.VISIBLE);
- }
- else {
- overlayView.setVisibility(View.GONE);
- }
- }
-}
+package com.limelight.grid;
+
+import android.content.Context;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import com.limelight.PcView;
+import com.limelight.R;
+import com.limelight.nvstream.http.ComputerDetails;
+import com.limelight.nvstream.http.PairingManager;
+import com.limelight.preferences.PreferenceConfiguration;
+
+import java.util.Collections;
+import java.util.Comparator;
+
+public class PcGridAdapter extends GenericGridAdapter {
+
+ public PcGridAdapter(Context context, PreferenceConfiguration prefs) {
+ super(context, getLayoutIdForPreferences(prefs));
+ }
+
+ private static int getLayoutIdForPreferences(PreferenceConfiguration prefs) {
+ return R.layout.pc_grid_item;
+ }
+
+ public void updateLayoutWithPreferences(Context context, PreferenceConfiguration prefs) {
+ // This will trigger the view to reload with the new layout
+ setLayoutId(getLayoutIdForPreferences(prefs));
+ }
+
+ public void addComputer(PcView.ComputerObject computer) {
+ itemList.add(computer);
+ sortList();
+ }
+
+ private void sortList() {
+ Collections.sort(itemList, new Comparator() {
+ @Override
+ public int compare(PcView.ComputerObject lhs, PcView.ComputerObject rhs) {
+ return lhs.details.name.toLowerCase().compareTo(rhs.details.name.toLowerCase());
+ }
+ });
+ }
+
+ public boolean removeComputer(PcView.ComputerObject computer) {
+ return itemList.remove(computer);
+ }
+
+ @Override
+ public void populateView(View parentView, ImageView imgView, RelativeLayout gridMask, ProgressBar prgView, TextView txtView, ImageView overlayView, PcView.ComputerObject obj) {
+ imgView.setImageResource(R.drawable.ic_computer);
+ if (obj.details.state == ComputerDetails.State.ONLINE) {
+ imgView.setAlpha(1.0f);
+ }
+ else {
+ imgView.setAlpha(0.4f);
+ }
+
+ if (obj.details.state == ComputerDetails.State.UNKNOWN) {
+ prgView.setVisibility(View.VISIBLE);
+ }
+ else {
+ prgView.setVisibility(View.INVISIBLE);
+ }
+
+ txtView.setText(obj.details.name);
+ if (obj.details.state == ComputerDetails.State.ONLINE) {
+ txtView.setAlpha(1.0f);
+ }
+ else {
+ txtView.setAlpha(0.4f);
+ }
+
+ if (obj.details.state == ComputerDetails.State.OFFLINE) {
+ overlayView.setImageResource(R.drawable.ic_pc_offline);
+ overlayView.setAlpha(0.4f);
+ overlayView.setVisibility(View.VISIBLE);
+ }
+ // We must check if the status is exactly online and unpaired
+ // to avoid colliding with the loading spinner when status is unknown
+ else if (obj.details.state == ComputerDetails.State.ONLINE &&
+ obj.details.pairState == PairingManager.PairState.NOT_PAIRED) {
+ overlayView.setImageResource(R.drawable.ic_lock);
+ overlayView.setAlpha(1.0f);
+ overlayView.setVisibility(View.VISIBLE);
+ }
+ else {
+ overlayView.setVisibility(View.GONE);
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/grid/assets/CachedAppAssetLoader.java b/app/src/main/java/com/limelight/grid/assets/CachedAppAssetLoader.java
old mode 100644
new mode 100755
index 83253b241a..5576abbe62
--- a/app/src/main/java/com/limelight/grid/assets/CachedAppAssetLoader.java
+++ b/app/src/main/java/com/limelight/grid/assets/CachedAppAssetLoader.java
@@ -1,396 +1,396 @@
-package com.limelight.grid.assets;
-
-import android.content.res.Resources;
-import android.graphics.Bitmap;
-import android.graphics.drawable.BitmapDrawable;
-import android.graphics.drawable.Drawable;
-import android.os.AsyncTask;
-import android.view.View;
-import android.view.animation.Animation;
-import android.view.animation.AnimationUtils;
-import android.widget.ImageView;
-import android.widget.TextView;
-
-import com.limelight.R;
-import com.limelight.nvstream.http.ComputerDetails;
-import com.limelight.nvstream.http.NvApp;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.lang.ref.WeakReference;
-import java.util.concurrent.LinkedBlockingQueue;
-import java.util.concurrent.ThreadPoolExecutor;
-import java.util.concurrent.TimeUnit;
-
-public class CachedAppAssetLoader {
- private static final int MAX_CONCURRENT_DISK_LOADS = 3;
- private static final int MAX_CONCURRENT_NETWORK_LOADS = 3;
- private static final int MAX_CONCURRENT_CACHE_LOADS = 1;
-
- private static final int MAX_PENDING_CACHE_LOADS = 100;
- private static final int MAX_PENDING_NETWORK_LOADS = 40;
- private static final int MAX_PENDING_DISK_LOADS = 40;
-
- private final ThreadPoolExecutor cacheExecutor = new ThreadPoolExecutor(
- MAX_CONCURRENT_CACHE_LOADS, MAX_CONCURRENT_CACHE_LOADS,
- Long.MAX_VALUE, TimeUnit.DAYS,
- new LinkedBlockingQueue(MAX_PENDING_CACHE_LOADS),
- new ThreadPoolExecutor.DiscardOldestPolicy());
-
- private final ThreadPoolExecutor foregroundExecutor = new ThreadPoolExecutor(
- MAX_CONCURRENT_DISK_LOADS, MAX_CONCURRENT_DISK_LOADS,
- Long.MAX_VALUE, TimeUnit.DAYS,
- new LinkedBlockingQueue(MAX_PENDING_DISK_LOADS),
- new ThreadPoolExecutor.DiscardOldestPolicy());
-
- private final ThreadPoolExecutor networkExecutor = new ThreadPoolExecutor(
- MAX_CONCURRENT_NETWORK_LOADS, MAX_CONCURRENT_NETWORK_LOADS,
- Long.MAX_VALUE, TimeUnit.DAYS,
- new LinkedBlockingQueue(MAX_PENDING_NETWORK_LOADS),
- new ThreadPoolExecutor.DiscardOldestPolicy());
-
- private final ComputerDetails computer;
- private final double scalingDivider;
- private final NetworkAssetLoader networkLoader;
- private final MemoryAssetLoader memoryLoader;
- private final DiskAssetLoader diskLoader;
- private final Bitmap placeholderBitmap;
- private final Bitmap noAppImageBitmap;
-
- public CachedAppAssetLoader(ComputerDetails computer, double scalingDivider,
- NetworkAssetLoader networkLoader, MemoryAssetLoader memoryLoader,
- DiskAssetLoader diskLoader, Bitmap noAppImageBitmap) {
- this.computer = computer;
- this.scalingDivider = scalingDivider;
- this.networkLoader = networkLoader;
- this.memoryLoader = memoryLoader;
- this.diskLoader = diskLoader;
- this.noAppImageBitmap = noAppImageBitmap;
- this.placeholderBitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888);
- }
-
- public void cancelBackgroundLoads() {
- Runnable r;
- while ((r = cacheExecutor.getQueue().poll()) != null) {
- cacheExecutor.remove(r);
- }
- }
-
- public void cancelForegroundLoads() {
- Runnable r;
-
- while ((r = foregroundExecutor.getQueue().poll()) != null) {
- foregroundExecutor.remove(r);
- }
-
- while ((r = networkExecutor.getQueue().poll()) != null) {
- networkExecutor.remove(r);
- }
- }
-
- public void freeCacheMemory() {
- memoryLoader.clearCache();
- }
-
- private ScaledBitmap doNetworkAssetLoad(LoaderTuple tuple, LoaderTask task) {
- // Try 3 times
- for (int i = 0; i < 3; i++) {
- // Check again whether we've been cancelled or the image view is gone
- if (task != null && (task.isCancelled() || task.imageViewRef.get() == null)) {
- return null;
- }
-
- InputStream in = networkLoader.getBitmapStream(tuple);
- if (in != null) {
- // Write the stream straight to disk
- diskLoader.populateCacheWithStream(tuple, in);
-
- // Close the network input stream
- try {
- in.close();
- } catch (IOException ignored) {}
-
- // If there's a task associated with this load, we should return the bitmap
- if (task != null) {
- // If the cached bitmap is valid, return it. Otherwise, we'll try the load again
- ScaledBitmap bmp = diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider);
- if (bmp != null) {
- return bmp;
- }
- }
- else {
- // Otherwise it's a background load and we return nothing
- return null;
- }
- }
-
- // Wait 1 second with a bit of fuzz
- try {
- Thread.sleep((int) (1000 + (Math.random() * 500)));
- } catch (InterruptedException e) {
- e.printStackTrace();
-
- // InterruptedException clears the thread's interrupt status. Since we can't
- // handle that here, we will re-interrupt the thread to set the interrupt
- // status back to true.
- Thread.currentThread().interrupt();
-
- return null;
- }
- }
-
- return null;
- }
-
- private class LoaderTask extends AsyncTask {
- private final WeakReference imageViewRef;
- private final WeakReference textViewRef;
- private final boolean diskOnly;
-
- private LoaderTuple tuple;
-
- public LoaderTask(ImageView imageView, TextView textView, boolean diskOnly) {
- this.imageViewRef = new WeakReference<>(imageView);
- this.textViewRef = new WeakReference<>(textView);
- this.diskOnly = diskOnly;
- }
-
- @Override
- protected ScaledBitmap doInBackground(LoaderTuple... params) {
- tuple = params[0];
-
- // Check whether it has been cancelled or the views are gone
- if (isCancelled() || imageViewRef.get() == null || textViewRef.get() == null) {
- return null;
- }
-
- ScaledBitmap bmp = diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider);
- if (bmp == null) {
- if (!diskOnly) {
- // Try to load the asset from the network
- bmp = doNetworkAssetLoad(tuple, this);
- } else {
- // Report progress to display the placeholder and spin
- // off the network-capable task
- publishProgress();
- }
- }
-
- // Cache the bitmap
- if (bmp != null) {
- memoryLoader.populateCache(tuple, bmp);
- }
-
- return bmp;
- }
-
- @Override
- protected void onProgressUpdate(Void... nothing) {
- // Do nothing if cancelled
- if (isCancelled()) {
- return;
- }
-
- // If the current loader task for this view isn't us, do nothing
- final ImageView imageView = imageViewRef.get();
- final TextView textView = textViewRef.get();
- if (getLoaderTask(imageView) == this) {
- // Set off another loader task on the network executor. This time our AsyncDrawable
- // will use the app image placeholder bitmap, rather than an empty bitmap.
- LoaderTask task = new LoaderTask(imageView, textView, false);
- AsyncDrawable asyncDrawable = new AsyncDrawable(imageView.getResources(), noAppImageBitmap, task);
- imageView.setImageDrawable(asyncDrawable);
- imageView.startAnimation(AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadein));
- imageView.setVisibility(View.VISIBLE);
- textView.setVisibility(View.VISIBLE);
- task.executeOnExecutor(networkExecutor, tuple);
- }
- }
-
- @Override
- protected void onPostExecute(final ScaledBitmap bitmap) {
- // Do nothing if cancelled
- if (isCancelled()) {
- return;
- }
-
- final ImageView imageView = imageViewRef.get();
- final TextView textView = textViewRef.get();
- if (getLoaderTask(imageView) == this) {
- // Fade in the box art
- if (bitmap != null) {
- // Show the text if it's a placeholder
- textView.setVisibility(isBitmapPlaceholder(bitmap) ? View.VISIBLE : View.GONE);
-
- if (imageView.getVisibility() == View.VISIBLE) {
- // Fade out the placeholder first
- Animation fadeOutAnimation = AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadeout);
- fadeOutAnimation.setAnimationListener(new Animation.AnimationListener() {
- @Override
- public void onAnimationStart(Animation animation) {}
-
- @Override
- public void onAnimationEnd(Animation animation) {
- // Fade in the new box art
- imageView.setImageBitmap(bitmap.bitmap);
- imageView.startAnimation(AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadein));
- }
-
- @Override
- public void onAnimationRepeat(Animation animation) {}
- });
- imageView.startAnimation(fadeOutAnimation);
- }
- else {
- // View is invisible already, so just fade in the new art
- imageView.setImageBitmap(bitmap.bitmap);
- imageView.startAnimation(AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadein));
- imageView.setVisibility(View.VISIBLE);
- }
- }
- }
- }
- }
-
- static class AsyncDrawable extends BitmapDrawable {
- private final WeakReference loaderTaskReference;
-
- public AsyncDrawable(Resources res, Bitmap bitmap,
- LoaderTask loaderTask) {
- super(res, bitmap);
- loaderTaskReference = new WeakReference<>(loaderTask);
- }
-
- public LoaderTask getLoaderTask() {
- return loaderTaskReference.get();
- }
- }
-
- private static LoaderTask getLoaderTask(ImageView imageView) {
- if (imageView == null) {
- return null;
- }
-
- final Drawable drawable = imageView.getDrawable();
-
- // If our drawable is in play, get the loader task
- if (drawable instanceof AsyncDrawable) {
- final AsyncDrawable asyncDrawable = (AsyncDrawable) drawable;
- return asyncDrawable.getLoaderTask();
- }
-
- return null;
- }
-
- private static boolean cancelPendingLoad(LoaderTuple tuple, ImageView imageView) {
- final LoaderTask loaderTask = getLoaderTask(imageView);
-
- // Check if any task was pending for this image view
- if (loaderTask != null && !loaderTask.isCancelled()) {
- final LoaderTuple taskTuple = loaderTask.tuple;
-
- // Cancel the task if it's not already loading the same data
- if (taskTuple == null || !taskTuple.equals(tuple)) {
- loaderTask.cancel(true);
- } else {
- // It's already loading what we want
- return false;
- }
- }
-
- // Allow the load to proceed
- return true;
- }
-
- public void queueCacheLoad(NvApp app) {
- final LoaderTuple tuple = new LoaderTuple(computer, app);
-
- if (memoryLoader.loadBitmapFromCache(tuple) != null) {
- // It's in memory which means it must also be on disk
- return;
- }
-
- // Queue a fetch in the cache executor
- cacheExecutor.execute(new Runnable() {
- @Override
- public void run() {
- // Check if the image is cached on disk
- if (diskLoader.checkCacheExists(tuple)) {
- return;
- }
-
- // Try to load the asset from the network and cache result on disk
- doNetworkAssetLoad(tuple, null);
- }
- });
- }
-
- private boolean isBitmapPlaceholder(ScaledBitmap bitmap) {
- return (bitmap == null) ||
- (bitmap.originalWidth == 130 && bitmap.originalHeight == 180) || // GFE 2.0
- (bitmap.originalWidth == 628 && bitmap.originalHeight == 888); // GFE 3.0
- }
-
- public boolean populateImageView(NvApp app, ImageView imgView, TextView textView) {
- LoaderTuple tuple = new LoaderTuple(computer, app);
-
- // If there's already a task in progress for this view,
- // cancel it. If the task is already loading the same image,
- // we return and let that load finish.
- if (!cancelPendingLoad(tuple, imgView)) {
- return true;
- }
-
- // Always set the name text so we have it if needed later
- textView.setText(app.getAppName());
-
- // First, try the memory cache in the current context
- ScaledBitmap bmp = memoryLoader.loadBitmapFromCache(tuple);
- if (bmp != null) {
- // Show the bitmap immediately
- imgView.setVisibility(View.VISIBLE);
- imgView.setImageBitmap(bmp.bitmap);
-
- // Show the text if it's a placeholder bitmap
- textView.setVisibility(isBitmapPlaceholder(bmp) ? View.VISIBLE : View.GONE);
- return true;
- }
-
- // If it's not in memory, create an async task to load it. This task will be attached
- // via AsyncDrawable to this view.
- final LoaderTask task = new LoaderTask(imgView, textView, true);
- final AsyncDrawable asyncDrawable = new AsyncDrawable(imgView.getResources(), placeholderBitmap, task);
- textView.setVisibility(View.INVISIBLE);
- imgView.setVisibility(View.INVISIBLE);
- imgView.setImageDrawable(asyncDrawable);
-
- // Run the task on our foreground executor
- task.executeOnExecutor(foregroundExecutor, tuple);
- return false;
- }
-
- public static class LoaderTuple {
- public final ComputerDetails computer;
- public final NvApp app;
-
- public LoaderTuple(ComputerDetails computer, NvApp app) {
- this.computer = computer;
- this.app = app;
- }
-
- @Override
- public boolean equals(Object o) {
- if (!(o instanceof LoaderTuple)) {
- return false;
- }
-
- LoaderTuple other = (LoaderTuple) o;
- return computer.uuid.equals(other.computer.uuid) && app.getAppId() == other.app.getAppId();
- }
-
- @Override
- public String toString() {
- return "("+computer.uuid+", "+app.getAppId()+")";
- }
- }
-}
+package com.limelight.grid.assets;
+
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.os.AsyncTask;
+import android.view.View;
+import android.view.animation.Animation;
+import android.view.animation.AnimationUtils;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import com.limelight.R;
+import com.limelight.nvstream.http.ComputerDetails;
+import com.limelight.nvstream.http.NvApp;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.ref.WeakReference;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+public class CachedAppAssetLoader {
+ private static final int MAX_CONCURRENT_DISK_LOADS = 3;
+ private static final int MAX_CONCURRENT_NETWORK_LOADS = 3;
+ private static final int MAX_CONCURRENT_CACHE_LOADS = 1;
+
+ private static final int MAX_PENDING_CACHE_LOADS = 100;
+ private static final int MAX_PENDING_NETWORK_LOADS = 40;
+ private static final int MAX_PENDING_DISK_LOADS = 40;
+
+ private final ThreadPoolExecutor cacheExecutor = new ThreadPoolExecutor(
+ MAX_CONCURRENT_CACHE_LOADS, MAX_CONCURRENT_CACHE_LOADS,
+ Long.MAX_VALUE, TimeUnit.DAYS,
+ new LinkedBlockingQueue(MAX_PENDING_CACHE_LOADS),
+ new ThreadPoolExecutor.DiscardOldestPolicy());
+
+ private final ThreadPoolExecutor foregroundExecutor = new ThreadPoolExecutor(
+ MAX_CONCURRENT_DISK_LOADS, MAX_CONCURRENT_DISK_LOADS,
+ Long.MAX_VALUE, TimeUnit.DAYS,
+ new LinkedBlockingQueue(MAX_PENDING_DISK_LOADS),
+ new ThreadPoolExecutor.DiscardOldestPolicy());
+
+ private final ThreadPoolExecutor networkExecutor = new ThreadPoolExecutor(
+ MAX_CONCURRENT_NETWORK_LOADS, MAX_CONCURRENT_NETWORK_LOADS,
+ Long.MAX_VALUE, TimeUnit.DAYS,
+ new LinkedBlockingQueue(MAX_PENDING_NETWORK_LOADS),
+ new ThreadPoolExecutor.DiscardOldestPolicy());
+
+ private final ComputerDetails computer;
+ private final double scalingDivider;
+ private final NetworkAssetLoader networkLoader;
+ private final MemoryAssetLoader memoryLoader;
+ private final DiskAssetLoader diskLoader;
+ private final Bitmap placeholderBitmap;
+ private final Bitmap noAppImageBitmap;
+
+ public CachedAppAssetLoader(ComputerDetails computer, double scalingDivider,
+ NetworkAssetLoader networkLoader, MemoryAssetLoader memoryLoader,
+ DiskAssetLoader diskLoader, Bitmap noAppImageBitmap) {
+ this.computer = computer;
+ this.scalingDivider = scalingDivider;
+ this.networkLoader = networkLoader;
+ this.memoryLoader = memoryLoader;
+ this.diskLoader = diskLoader;
+ this.noAppImageBitmap = noAppImageBitmap;
+ this.placeholderBitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888);
+ }
+
+ public void cancelBackgroundLoads() {
+ Runnable r;
+ while ((r = cacheExecutor.getQueue().poll()) != null) {
+ cacheExecutor.remove(r);
+ }
+ }
+
+ public void cancelForegroundLoads() {
+ Runnable r;
+
+ while ((r = foregroundExecutor.getQueue().poll()) != null) {
+ foregroundExecutor.remove(r);
+ }
+
+ while ((r = networkExecutor.getQueue().poll()) != null) {
+ networkExecutor.remove(r);
+ }
+ }
+
+ public void freeCacheMemory() {
+ memoryLoader.clearCache();
+ }
+
+ private ScaledBitmap doNetworkAssetLoad(LoaderTuple tuple, LoaderTask task) {
+ // Try 3 times
+ for (int i = 0; i < 3; i++) {
+ // Check again whether we've been cancelled or the image view is gone
+ if (task != null && (task.isCancelled() || task.imageViewRef.get() == null)) {
+ return null;
+ }
+
+ InputStream in = networkLoader.getBitmapStream(tuple);
+ if (in != null) {
+ // Write the stream straight to disk
+ diskLoader.populateCacheWithStream(tuple, in);
+
+ // Close the network input stream
+ try {
+ in.close();
+ } catch (IOException ignored) {}
+
+ // If there's a task associated with this load, we should return the bitmap
+ if (task != null) {
+ // If the cached bitmap is valid, return it. Otherwise, we'll try the load again
+ ScaledBitmap bmp = diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider);
+ if (bmp != null) {
+ return bmp;
+ }
+ }
+ else {
+ // Otherwise it's a background load and we return nothing
+ return null;
+ }
+ }
+
+ // Wait 1 second with a bit of fuzz
+ try {
+ Thread.sleep((int) (1000 + (Math.random() * 500)));
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+
+ // InterruptedException clears the thread's interrupt status. Since we can't
+ // handle that here, we will re-interrupt the thread to set the interrupt
+ // status back to true.
+ Thread.currentThread().interrupt();
+
+ return null;
+ }
+ }
+
+ return null;
+ }
+
+ private class LoaderTask extends AsyncTask {
+ private final WeakReference imageViewRef;
+ private final WeakReference textViewRef;
+ private final boolean diskOnly;
+
+ private LoaderTuple tuple;
+
+ public LoaderTask(ImageView imageView, TextView textView, boolean diskOnly) {
+ this.imageViewRef = new WeakReference<>(imageView);
+ this.textViewRef = new WeakReference<>(textView);
+ this.diskOnly = diskOnly;
+ }
+
+ @Override
+ protected ScaledBitmap doInBackground(LoaderTuple... params) {
+ tuple = params[0];
+
+ // Check whether it has been cancelled or the views are gone
+ if (isCancelled() || imageViewRef.get() == null || textViewRef.get() == null) {
+ return null;
+ }
+
+ ScaledBitmap bmp = diskLoader.loadBitmapFromCache(tuple, (int) scalingDivider);
+ if (bmp == null) {
+ if (!diskOnly) {
+ // Try to load the asset from the network
+ bmp = doNetworkAssetLoad(tuple, this);
+ } else {
+ // Report progress to display the placeholder and spin
+ // off the network-capable task
+ publishProgress();
+ }
+ }
+
+ // Cache the bitmap
+ if (bmp != null) {
+ memoryLoader.populateCache(tuple, bmp);
+ }
+
+ return bmp;
+ }
+
+ @Override
+ protected void onProgressUpdate(Void... nothing) {
+ // Do nothing if cancelled
+ if (isCancelled()) {
+ return;
+ }
+
+ // If the current loader task for this view isn't us, do nothing
+ final ImageView imageView = imageViewRef.get();
+ final TextView textView = textViewRef.get();
+ if (getLoaderTask(imageView) == this) {
+ // Set off another loader task on the network executor. This time our AsyncDrawable
+ // will use the app image placeholder bitmap, rather than an empty bitmap.
+ LoaderTask task = new LoaderTask(imageView, textView, false);
+ AsyncDrawable asyncDrawable = new AsyncDrawable(imageView.getResources(), noAppImageBitmap, task);
+ imageView.setImageDrawable(asyncDrawable);
+ imageView.startAnimation(AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadein));
+ imageView.setVisibility(View.VISIBLE);
+ textView.setVisibility(View.VISIBLE);
+ task.executeOnExecutor(networkExecutor, tuple);
+ }
+ }
+
+ @Override
+ protected void onPostExecute(final ScaledBitmap bitmap) {
+ // Do nothing if cancelled
+ if (isCancelled()) {
+ return;
+ }
+
+ final ImageView imageView = imageViewRef.get();
+ final TextView textView = textViewRef.get();
+ if (getLoaderTask(imageView) == this) {
+ // Fade in the box art
+ if (bitmap != null) {
+ // Show the text if it's a placeholder
+ textView.setVisibility(isBitmapPlaceholder(bitmap) ? View.VISIBLE : View.GONE);
+
+ if (imageView.getVisibility() == View.VISIBLE) {
+ // Fade out the placeholder first
+ Animation fadeOutAnimation = AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadeout);
+ fadeOutAnimation.setAnimationListener(new Animation.AnimationListener() {
+ @Override
+ public void onAnimationStart(Animation animation) {}
+
+ @Override
+ public void onAnimationEnd(Animation animation) {
+ // Fade in the new box art
+ imageView.setImageBitmap(bitmap.bitmap);
+ imageView.startAnimation(AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadein));
+ }
+
+ @Override
+ public void onAnimationRepeat(Animation animation) {}
+ });
+ imageView.startAnimation(fadeOutAnimation);
+ }
+ else {
+ // View is invisible already, so just fade in the new art
+ imageView.setImageBitmap(bitmap.bitmap);
+ imageView.startAnimation(AnimationUtils.loadAnimation(imageView.getContext(), R.anim.boxart_fadein));
+ imageView.setVisibility(View.VISIBLE);
+ }
+ }
+ }
+ }
+ }
+
+ static class AsyncDrawable extends BitmapDrawable {
+ private final WeakReference loaderTaskReference;
+
+ public AsyncDrawable(Resources res, Bitmap bitmap,
+ LoaderTask loaderTask) {
+ super(res, bitmap);
+ loaderTaskReference = new WeakReference<>(loaderTask);
+ }
+
+ public LoaderTask getLoaderTask() {
+ return loaderTaskReference.get();
+ }
+ }
+
+ private static LoaderTask getLoaderTask(ImageView imageView) {
+ if (imageView == null) {
+ return null;
+ }
+
+ final Drawable drawable = imageView.getDrawable();
+
+ // If our drawable is in play, get the loader task
+ if (drawable instanceof AsyncDrawable) {
+ final AsyncDrawable asyncDrawable = (AsyncDrawable) drawable;
+ return asyncDrawable.getLoaderTask();
+ }
+
+ return null;
+ }
+
+ private static boolean cancelPendingLoad(LoaderTuple tuple, ImageView imageView) {
+ final LoaderTask loaderTask = getLoaderTask(imageView);
+
+ // Check if any task was pending for this image view
+ if (loaderTask != null && !loaderTask.isCancelled()) {
+ final LoaderTuple taskTuple = loaderTask.tuple;
+
+ // Cancel the task if it's not already loading the same data
+ if (taskTuple == null || !taskTuple.equals(tuple)) {
+ loaderTask.cancel(true);
+ } else {
+ // It's already loading what we want
+ return false;
+ }
+ }
+
+ // Allow the load to proceed
+ return true;
+ }
+
+ public void queueCacheLoad(NvApp app) {
+ final LoaderTuple tuple = new LoaderTuple(computer, app);
+
+ if (memoryLoader.loadBitmapFromCache(tuple) != null) {
+ // It's in memory which means it must also be on disk
+ return;
+ }
+
+ // Queue a fetch in the cache executor
+ cacheExecutor.execute(new Runnable() {
+ @Override
+ public void run() {
+ // Check if the image is cached on disk
+ if (diskLoader.checkCacheExists(tuple)) {
+ return;
+ }
+
+ // Try to load the asset from the network and cache result on disk
+ doNetworkAssetLoad(tuple, null);
+ }
+ });
+ }
+
+ private boolean isBitmapPlaceholder(ScaledBitmap bitmap) {
+ return (bitmap == null) ||
+ (bitmap.originalWidth == 130 && bitmap.originalHeight == 180) || // GFE 2.0
+ (bitmap.originalWidth == 628 && bitmap.originalHeight == 888); // GFE 3.0
+ }
+
+ public boolean populateImageView(NvApp app, ImageView imgView, TextView textView) {
+ LoaderTuple tuple = new LoaderTuple(computer, app);
+
+ // If there's already a task in progress for this view,
+ // cancel it. If the task is already loading the same image,
+ // we return and let that load finish.
+ if (!cancelPendingLoad(tuple, imgView)) {
+ return true;
+ }
+
+ // Always set the name text so we have it if needed later
+ textView.setText(app.getAppName());
+
+ // First, try the memory cache in the current context
+ ScaledBitmap bmp = memoryLoader.loadBitmapFromCache(tuple);
+ if (bmp != null) {
+ // Show the bitmap immediately
+ imgView.setVisibility(View.VISIBLE);
+ imgView.setImageBitmap(bmp.bitmap);
+
+ // Show the text if it's a placeholder bitmap
+ textView.setVisibility(isBitmapPlaceholder(bmp) ? View.VISIBLE : View.GONE);
+ return true;
+ }
+
+ // If it's not in memory, create an async task to load it. This task will be attached
+ // via AsyncDrawable to this view.
+ final LoaderTask task = new LoaderTask(imgView, textView, true);
+ final AsyncDrawable asyncDrawable = new AsyncDrawable(imgView.getResources(), placeholderBitmap, task);
+ textView.setVisibility(View.INVISIBLE);
+ imgView.setVisibility(View.INVISIBLE);
+ imgView.setImageDrawable(asyncDrawable);
+
+ // Run the task on our foreground executor
+ task.executeOnExecutor(foregroundExecutor, tuple);
+ return false;
+ }
+
+ public static class LoaderTuple {
+ public final ComputerDetails computer;
+ public final NvApp app;
+
+ public LoaderTuple(ComputerDetails computer, NvApp app) {
+ this.computer = computer;
+ this.app = app;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof LoaderTuple)) {
+ return false;
+ }
+
+ LoaderTuple other = (LoaderTuple) o;
+ return computer.uuid.equals(other.computer.uuid) && app.getAppId() == other.app.getAppId();
+ }
+
+ @Override
+ public String toString() {
+ return "("+computer.uuid+", "+app.getAppId()+")";
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/grid/assets/DiskAssetLoader.java b/app/src/main/java/com/limelight/grid/assets/DiskAssetLoader.java
old mode 100644
new mode 100755
index dc496a6bbd..3767b509a2
--- a/app/src/main/java/com/limelight/grid/assets/DiskAssetLoader.java
+++ b/app/src/main/java/com/limelight/grid/assets/DiskAssetLoader.java
@@ -1,166 +1,176 @@
-package com.limelight.grid.assets;
-
-import android.app.ActivityManager;
-import android.content.Context;
-import android.graphics.Bitmap;
-import android.graphics.BitmapFactory;
-import android.graphics.ImageDecoder;
-import android.os.Build;
-
-import com.limelight.LimeLog;
-import com.limelight.utils.CacheHelper;
-
-import java.io.File;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-
-public class DiskAssetLoader {
- // 5 MB
- private static final long MAX_ASSET_SIZE = 5 * 1024 * 1024;
-
- // Standard box art is 300x400
- private static final int STANDARD_ASSET_WIDTH = 300;
- private static final int STANDARD_ASSET_HEIGHT = 400;
-
- private final boolean isLowRamDevice;
- private final File cacheDir;
-
- public DiskAssetLoader(Context context) {
- this.cacheDir = context.getCacheDir();
- this.isLowRamDevice =
- ((ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE)).isLowRamDevice();
- }
-
- public boolean checkCacheExists(CachedAppAssetLoader.LoaderTuple tuple) {
- return CacheHelper.cacheFileExists(cacheDir, "boxart", tuple.computer.uuid, tuple.app.getAppId() + ".png");
- }
-
- // https://developer.android.com/topic/performance/graphics/load-bitmap.html
- public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
- // Raw height and width of image
- final int height = options.outHeight;
- final int width = options.outWidth;
- int inSampleSize = 1;
-
- if (height > reqHeight || width > reqWidth) {
-
- final int halfHeight = height / 2;
- final int halfWidth = width / 2;
-
- // Calculates the largest inSampleSize value that is a power of 2 and keeps both
- // height and width larger than the requested height and width.
- while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
- inSampleSize *= 2;
- }
- }
-
- return inSampleSize;
- }
-
- public ScaledBitmap loadBitmapFromCache(CachedAppAssetLoader.LoaderTuple tuple, int sampleSize) {
- File file = getFile(tuple.computer.uuid, tuple.app.getAppId());
-
- // Don't bother with anything if it doesn't exist
- if (!file.exists()) {
- return null;
- }
-
- // Make sure the cached asset doesn't exceed the maximum size
- if (file.length() > MAX_ASSET_SIZE) {
- LimeLog.warning("Removing cached tuple exceeding size threshold: "+tuple);
- file.delete();
- return null;
- }
-
- Bitmap bmp;
-
- // For OSes prior to P, we have to use the ugly BitmapFactory API
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
- // Lookup bounds of the downloaded image
- BitmapFactory.Options decodeOnlyOptions = new BitmapFactory.Options();
- decodeOnlyOptions.inJustDecodeBounds = true;
- BitmapFactory.decodeFile(file.getAbsolutePath(), decodeOnlyOptions);
- if (decodeOnlyOptions.outWidth <= 0 || decodeOnlyOptions.outHeight <= 0) {
- // Dimensions set to -1 on error. Return value always null.
- return null;
- }
-
- LimeLog.info("Tuple "+tuple+" has cached art of size: "+decodeOnlyOptions.outWidth+"x"+decodeOnlyOptions.outHeight);
-
- // Load the image scaled to the appropriate size
- BitmapFactory.Options options = new BitmapFactory.Options();
- options.inSampleSize = calculateInSampleSize(decodeOnlyOptions,
- STANDARD_ASSET_WIDTH / sampleSize,
- STANDARD_ASSET_HEIGHT / sampleSize);
- if (isLowRamDevice) {
- options.inPreferredConfig = Bitmap.Config.RGB_565;
- options.inDither = true;
- }
- else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- options.inPreferredConfig = Bitmap.Config.HARDWARE;
- }
-
- bmp = BitmapFactory.decodeFile(file.getAbsolutePath(), options);
- if (bmp != null) {
- LimeLog.info("Tuple "+tuple+" decoded from disk cache with sample size: "+options.inSampleSize);
- return new ScaledBitmap(decodeOnlyOptions.outWidth, decodeOnlyOptions.outHeight, bmp);
- }
- }
- else {
- // On P, we can get a bitmap back in one step with ImageDecoder
- final ScaledBitmap scaledBitmap = new ScaledBitmap();
- try {
- scaledBitmap.bitmap = ImageDecoder.decodeBitmap(ImageDecoder.createSource(file), new ImageDecoder.OnHeaderDecodedListener() {
- @Override
- public void onHeaderDecoded(ImageDecoder imageDecoder, ImageDecoder.ImageInfo imageInfo, ImageDecoder.Source source) {
- scaledBitmap.originalWidth = imageInfo.getSize().getWidth();
- scaledBitmap.originalHeight = imageInfo.getSize().getHeight();
-
- imageDecoder.setTargetSize(STANDARD_ASSET_WIDTH, STANDARD_ASSET_HEIGHT);
- if (isLowRamDevice) {
- imageDecoder.setMemorySizePolicy(ImageDecoder.MEMORY_POLICY_LOW_RAM);
- }
- }
- });
- return scaledBitmap;
- } catch (IOException e) {
- e.printStackTrace();
- return null;
- }
- }
-
- return null;
- }
-
- public File getFile(String computerUuid, int appId) {
- return CacheHelper.openPath(false, cacheDir, "boxart", computerUuid, appId + ".png");
- }
-
- public void deleteAssetsForComputer(String computerUuid) {
- File dir = CacheHelper.openPath(false, cacheDir, "boxart", computerUuid);
- File[] files = dir.listFiles();
- if (files != null) {
- for (File f : files) {
- f.delete();
- }
- }
- }
-
- public void populateCacheWithStream(CachedAppAssetLoader.LoaderTuple tuple, InputStream input) {
- boolean success = false;
- try (final OutputStream out = CacheHelper.openCacheFileForOutput(
- cacheDir, "boxart", tuple.computer.uuid, tuple.app.getAppId() + ".png")
- ) {
- CacheHelper.writeInputStreamToOutputStream(input, out, MAX_ASSET_SIZE);
- success = true;
- } catch (IOException e) {
- e.printStackTrace();
- } finally {
- if (!success) {
- LimeLog.warning("Unable to populate cache with tuple: "+tuple);
- CacheHelper.deleteCacheFile(cacheDir, "boxart", tuple.computer.uuid, tuple.app.getAppId() + ".png");
- }
- }
- }
-}
+package com.limelight.grid.assets;
+
+import android.app.ActivityManager;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.ImageDecoder;
+import android.os.Build;
+
+import com.limelight.LimeLog;
+import com.limelight.utils.CacheHelper;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+public class DiskAssetLoader {
+ // 5 MB
+ private static final long MAX_ASSET_SIZE = 5 * 1024 * 1024;
+
+ // Standard box art is 300x400
+ private static final int STANDARD_ASSET_WIDTH = 300;
+ private static final int STANDARD_ASSET_HEIGHT = 400;
+
+ private final boolean isLowRamDevice;
+ private final File cacheDir;
+
+ public DiskAssetLoader(Context context) {
+ this.cacheDir = context.getCacheDir();
+ this.isLowRamDevice =
+ ((ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE)).isLowRamDevice();
+ }
+
+ public boolean checkCacheExists(CachedAppAssetLoader.LoaderTuple tuple) {
+ return CacheHelper.cacheFileExists(cacheDir, "boxart", tuple.computer.uuid, tuple.app.getAppId() + ".png");
+ }
+
+ // https://developer.android.com/topic/performance/graphics/load-bitmap.html
+ public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
+ // Raw height and width of image
+ final int height = options.outHeight;
+ final int width = options.outWidth;
+ int inSampleSize = 1;
+
+ if (height > reqHeight || width > reqWidth) {
+
+ final int halfHeight = height / 2;
+ final int halfWidth = width / 2;
+
+ // Calculates the largest inSampleSize value that is a power of 2 and keeps both
+ // height and width larger than the requested height and width.
+ while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
+ inSampleSize *= 2;
+ }
+ }
+
+ return inSampleSize;
+ }
+
+ public ScaledBitmap loadBitmapFromCache(CachedAppAssetLoader.LoaderTuple tuple, int sampleSize) {
+ File file = getFile(tuple.computer.uuid, tuple.app.getAppId());
+
+ // Don't bother with anything if it doesn't exist
+ if (!file.exists()) {
+ return null;
+ }
+
+ // Make sure the cached asset doesn't exceed the maximum size
+ if (file.length() > MAX_ASSET_SIZE) {
+ LimeLog.warning("Removing cached tuple exceeding size threshold: "+tuple);
+ file.delete();
+ return null;
+ }
+
+ Bitmap bmp;
+
+ // For OSes prior to P, we have to use the ugly BitmapFactory API
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
+ // Lookup bounds of the downloaded image
+ BitmapFactory.Options decodeOnlyOptions = new BitmapFactory.Options();
+ decodeOnlyOptions.inJustDecodeBounds = true;
+ BitmapFactory.decodeFile(file.getAbsolutePath(), decodeOnlyOptions);
+ if (decodeOnlyOptions.outWidth <= 0 || decodeOnlyOptions.outHeight <= 0) {
+ // Dimensions set to -1 on error. Return value always null.
+ return null;
+ }
+
+ LimeLog.info("Tuple "+tuple+" has cached art of size: "+decodeOnlyOptions.outWidth+"x"+decodeOnlyOptions.outHeight);
+
+ // Load the image scaled to the appropriate size
+ BitmapFactory.Options options = new BitmapFactory.Options();
+ options.inSampleSize = calculateInSampleSize(decodeOnlyOptions,
+ STANDARD_ASSET_WIDTH / sampleSize,
+ STANDARD_ASSET_HEIGHT / sampleSize);
+ if (isLowRamDevice) {
+ options.inPreferredConfig = Bitmap.Config.RGB_565;
+ options.inDither = true;
+ }
+ else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ options.inPreferredConfig = Bitmap.Config.HARDWARE;
+ }
+
+ bmp = BitmapFactory.decodeFile(file.getAbsolutePath(), options);
+ if (bmp != null) {
+ LimeLog.info("Tuple "+tuple+" decoded from disk cache with sample size: "+options.inSampleSize);
+ return new ScaledBitmap(decodeOnlyOptions.outWidth, decodeOnlyOptions.outHeight, bmp);
+ }
+ }
+ else {
+ // On P, we can get a bitmap back in one step with ImageDecoder
+ final ScaledBitmap scaledBitmap = new ScaledBitmap();
+ try {
+ scaledBitmap.bitmap = ImageDecoder.decodeBitmap(ImageDecoder.createSource(file), new ImageDecoder.OnHeaderDecodedListener() {
+ @Override
+ public void onHeaderDecoded(ImageDecoder imageDecoder, ImageDecoder.ImageInfo imageInfo, ImageDecoder.Source source) {
+ scaledBitmap.originalWidth = imageInfo.getSize().getWidth();
+ scaledBitmap.originalHeight = imageInfo.getSize().getHeight();
+
+ float aspectRatio = (float) scaledBitmap.originalWidth / scaledBitmap.originalHeight;
+ float standardAspectRatio = (float) STANDARD_ASSET_WIDTH / STANDARD_ASSET_HEIGHT;
+ int targetWidth = STANDARD_ASSET_WIDTH;
+ int targetHeight = STANDARD_ASSET_HEIGHT;
+
+ if (aspectRatio < standardAspectRatio) {
+ targetHeight = (int) (standardAspectRatio / aspectRatio * targetHeight);
+ } else {
+ targetWidth = (int) (aspectRatio / standardAspectRatio * targetWidth);
+ }
+ imageDecoder.setTargetSize(targetWidth, targetHeight);
+ if (isLowRamDevice) {
+ imageDecoder.setMemorySizePolicy(ImageDecoder.MEMORY_POLICY_LOW_RAM);
+ }
+ }
+ });
+ return scaledBitmap;
+ } catch (IOException e) {
+ e.printStackTrace();
+ return null;
+ }
+ }
+
+ return null;
+ }
+
+ public File getFile(String computerUuid, int appId) {
+ return CacheHelper.openPath(false, cacheDir, "boxart", computerUuid, appId + ".png");
+ }
+
+ public void deleteAssetsForComputer(String computerUuid) {
+ File dir = CacheHelper.openPath(false, cacheDir, "boxart", computerUuid);
+ File[] files = dir.listFiles();
+ if (files != null) {
+ for (File f : files) {
+ f.delete();
+ }
+ }
+ }
+
+ public void populateCacheWithStream(CachedAppAssetLoader.LoaderTuple tuple, InputStream input) {
+ boolean success = false;
+ try (final OutputStream out = CacheHelper.openCacheFileForOutput(
+ cacheDir, "boxart", tuple.computer.uuid, tuple.app.getAppId() + ".png")
+ ) {
+ CacheHelper.writeInputStreamToOutputStream(input, out, MAX_ASSET_SIZE);
+ success = true;
+ } catch (IOException e) {
+ e.printStackTrace();
+ } finally {
+ if (!success) {
+ LimeLog.warning("Unable to populate cache with tuple: "+tuple);
+ CacheHelper.deleteCacheFile(cacheDir, "boxart", tuple.computer.uuid, tuple.app.getAppId() + ".png");
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/grid/assets/MemoryAssetLoader.java b/app/src/main/java/com/limelight/grid/assets/MemoryAssetLoader.java
old mode 100644
new mode 100755
index aab6651b93..a5a4b54689
--- a/app/src/main/java/com/limelight/grid/assets/MemoryAssetLoader.java
+++ b/app/src/main/java/com/limelight/grid/assets/MemoryAssetLoader.java
@@ -1,74 +1,74 @@
-package com.limelight.grid.assets;
-
-import android.util.LruCache;
-
-import com.limelight.LimeLog;
-
-import java.lang.ref.SoftReference;
-import java.util.HashMap;
-
-public class MemoryAssetLoader {
- private static final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
- private static final LruCache memoryCache = new LruCache(maxMemory / 16) {
- @Override
- protected int sizeOf(String key, ScaledBitmap bitmap) {
- // Sizeof returns kilobytes
- return bitmap.bitmap.getByteCount() / 1024;
- }
-
- @Override
- protected void entryRemoved(boolean evicted, String key, ScaledBitmap oldValue, ScaledBitmap newValue) {
- super.entryRemoved(evicted, key, oldValue, newValue);
-
- if (evicted) {
- // Keep a soft reference around to the bitmap as long as we can
- evictionCache.put(key, new SoftReference<>(oldValue));
- }
- }
- };
- private static final HashMap> evictionCache = new HashMap<>();
-
- private static String constructKey(CachedAppAssetLoader.LoaderTuple tuple) {
- return tuple.computer.uuid+"-"+tuple.app.getAppId();
- }
-
- public ScaledBitmap loadBitmapFromCache(CachedAppAssetLoader.LoaderTuple tuple) {
- final String key = constructKey(tuple);
-
- ScaledBitmap bmp = memoryCache.get(key);
- if (bmp != null) {
- LimeLog.info("LRU cache hit for tuple: "+tuple);
- return bmp;
- }
-
- SoftReference bmpRef = evictionCache.get(key);
- if (bmpRef != null) {
- bmp = bmpRef.get();
- if (bmp != null) {
- LimeLog.info("Eviction cache hit for tuple: "+tuple);
-
- // Put this entry back into the LRU cache
- evictionCache.remove(key);
- memoryCache.put(key, bmp);
-
- return bmp;
- }
- else {
- // The data is gone, so remove the dangling SoftReference now
- evictionCache.remove(key);
- }
- }
-
- return null;
- }
-
- public void populateCache(CachedAppAssetLoader.LoaderTuple tuple, ScaledBitmap bitmap) {
- memoryCache.put(constructKey(tuple), bitmap);
- }
-
- public void clearCache() {
- // We must evict first because that will push all items into the eviction cache
- memoryCache.evictAll();
- evictionCache.clear();
- }
-}
+package com.limelight.grid.assets;
+
+import android.util.LruCache;
+
+import com.limelight.LimeLog;
+
+import java.lang.ref.SoftReference;
+import java.util.HashMap;
+
+public class MemoryAssetLoader {
+ private static final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
+ private static final LruCache memoryCache = new LruCache(maxMemory / 16) {
+ @Override
+ protected int sizeOf(String key, ScaledBitmap bitmap) {
+ // Sizeof returns kilobytes
+ return bitmap.bitmap.getByteCount() / 1024;
+ }
+
+ @Override
+ protected void entryRemoved(boolean evicted, String key, ScaledBitmap oldValue, ScaledBitmap newValue) {
+ super.entryRemoved(evicted, key, oldValue, newValue);
+
+ if (evicted) {
+ // Keep a soft reference around to the bitmap as long as we can
+ evictionCache.put(key, new SoftReference<>(oldValue));
+ }
+ }
+ };
+ private static final HashMap> evictionCache = new HashMap<>();
+
+ private static String constructKey(CachedAppAssetLoader.LoaderTuple tuple) {
+ return tuple.computer.uuid+"-"+tuple.app.getAppId();
+ }
+
+ public ScaledBitmap loadBitmapFromCache(CachedAppAssetLoader.LoaderTuple tuple) {
+ final String key = constructKey(tuple);
+
+ ScaledBitmap bmp = memoryCache.get(key);
+ if (bmp != null) {
+ LimeLog.info("LRU cache hit for tuple: "+tuple);
+ return bmp;
+ }
+
+ SoftReference bmpRef = evictionCache.get(key);
+ if (bmpRef != null) {
+ bmp = bmpRef.get();
+ if (bmp != null) {
+ LimeLog.info("Eviction cache hit for tuple: "+tuple);
+
+ // Put this entry back into the LRU cache
+ evictionCache.remove(key);
+ memoryCache.put(key, bmp);
+
+ return bmp;
+ }
+ else {
+ // The data is gone, so remove the dangling SoftReference now
+ evictionCache.remove(key);
+ }
+ }
+
+ return null;
+ }
+
+ public void populateCache(CachedAppAssetLoader.LoaderTuple tuple, ScaledBitmap bitmap) {
+ memoryCache.put(constructKey(tuple), bitmap);
+ }
+
+ public void clearCache() {
+ // We must evict first because that will push all items into the eviction cache
+ memoryCache.evictAll();
+ evictionCache.clear();
+ }
+}
diff --git a/app/src/main/java/com/limelight/grid/assets/NetworkAssetLoader.java b/app/src/main/java/com/limelight/grid/assets/NetworkAssetLoader.java
old mode 100644
new mode 100755
index d75b4c5440..f0dc068311
--- a/app/src/main/java/com/limelight/grid/assets/NetworkAssetLoader.java
+++ b/app/src/main/java/com/limelight/grid/assets/NetworkAssetLoader.java
@@ -1,40 +1,40 @@
-package com.limelight.grid.assets;
-
-import android.content.Context;
-
-import com.limelight.LimeLog;
-import com.limelight.binding.PlatformBinding;
-import com.limelight.nvstream.http.NvHTTP;
-import com.limelight.utils.ServerHelper;
-
-import java.io.IOException;
-import java.io.InputStream;
-
-public class NetworkAssetLoader {
- private final Context context;
- private final String uniqueId;
-
- public NetworkAssetLoader(Context context, String uniqueId) {
- this.context = context;
- this.uniqueId = uniqueId;
- }
-
- public InputStream getBitmapStream(CachedAppAssetLoader.LoaderTuple tuple) {
- InputStream in = null;
- try {
- NvHTTP http = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(tuple.computer),
- tuple.computer.httpsPort, uniqueId, tuple.computer.serverCert,
- PlatformBinding.getCryptoProvider(context));
- in = http.getBoxArt(tuple.app);
- } catch (IOException ignored) {}
-
- if (in != null) {
- LimeLog.info("Network asset load complete: " + tuple);
- }
- else {
- LimeLog.info("Network asset load failed: " + tuple);
- }
-
- return in;
- }
-}
+package com.limelight.grid.assets;
+
+import android.content.Context;
+
+import com.limelight.LimeLog;
+import com.limelight.binding.PlatformBinding;
+import com.limelight.nvstream.http.NvHTTP;
+import com.limelight.utils.ServerHelper;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+public class NetworkAssetLoader {
+ private final Context context;
+ private final String uniqueId;
+
+ public NetworkAssetLoader(Context context, String uniqueId) {
+ this.context = context;
+ this.uniqueId = uniqueId;
+ }
+
+ public InputStream getBitmapStream(CachedAppAssetLoader.LoaderTuple tuple) {
+ InputStream in = null;
+ try {
+ NvHTTP http = new NvHTTP(ServerHelper.getCurrentAddressFromComputer(tuple.computer),
+ tuple.computer.httpsPort, uniqueId, tuple.computer.serverCert,
+ PlatformBinding.getCryptoProvider(context));
+ in = http.getBoxArt(tuple.app);
+ } catch (IOException ignored) {}
+
+ if (in != null) {
+ LimeLog.info("Network asset load complete: " + tuple);
+ }
+ else {
+ LimeLog.info("Network asset load failed: " + tuple);
+ }
+
+ return in;
+ }
+}
diff --git a/app/src/main/java/com/limelight/grid/assets/ScaledBitmap.java b/app/src/main/java/com/limelight/grid/assets/ScaledBitmap.java
old mode 100644
new mode 100755
index dbf9f0f4d5..39a048825a
--- a/app/src/main/java/com/limelight/grid/assets/ScaledBitmap.java
+++ b/app/src/main/java/com/limelight/grid/assets/ScaledBitmap.java
@@ -1,18 +1,18 @@
-package com.limelight.grid.assets;
-
-import android.graphics.Bitmap;
-
-public class ScaledBitmap {
- public int originalWidth;
- public int originalHeight;
-
- public Bitmap bitmap;
-
- public ScaledBitmap() {}
-
- public ScaledBitmap(int originalWidth, int originalHeight, Bitmap bitmap) {
- this.originalWidth = originalWidth;
- this.originalHeight = originalHeight;
- this.bitmap = bitmap;
- }
-}
+package com.limelight.grid.assets;
+
+import android.graphics.Bitmap;
+
+public class ScaledBitmap {
+ public int originalWidth;
+ public int originalHeight;
+
+ public Bitmap bitmap;
+
+ public ScaledBitmap() {}
+
+ public ScaledBitmap(int originalWidth, int originalHeight, Bitmap bitmap) {
+ this.originalWidth = originalWidth;
+ this.originalHeight = originalHeight;
+ this.bitmap = bitmap;
+ }
+}
diff --git a/app/src/main/java/com/limelight/nvstream/ConnectionContext.java b/app/src/main/java/com/limelight/nvstream/ConnectionContext.java
old mode 100644
new mode 100755
index d6b9224273..aa7795bde3
--- a/app/src/main/java/com/limelight/nvstream/ConnectionContext.java
+++ b/app/src/main/java/com/limelight/nvstream/ConnectionContext.java
@@ -1,34 +1,34 @@
-package com.limelight.nvstream;
-
-import com.limelight.nvstream.http.ComputerDetails;
-
-import java.security.cert.X509Certificate;
-
-import javax.crypto.SecretKey;
-
-public class ConnectionContext {
- public ComputerDetails.AddressTuple serverAddress;
- public int httpsPort;
- public boolean isNvidiaServerSoftware;
- public X509Certificate serverCert;
- public StreamConfiguration streamConfig;
- public NvConnectionListener connListener;
- public SecretKey riKey;
- public int riKeyId;
-
- // This is the version quad from the appversion tag of /serverinfo
- public String serverAppVersion;
- public String serverGfeVersion;
- public int serverCodecModeSupport;
-
- // This is the sessionUrl0 tag from /resume and /launch
- public String rtspSessionUrl;
-
- public int negotiatedWidth, negotiatedHeight;
- public boolean negotiatedHdr;
-
- public int negotiatedRemoteStreaming;
- public int negotiatedPacketSize;
-
- public int videoCapabilities;
-}
+package com.limelight.nvstream;
+
+import com.limelight.nvstream.http.ComputerDetails;
+
+import java.security.cert.X509Certificate;
+
+import javax.crypto.SecretKey;
+
+public class ConnectionContext {
+ public ComputerDetails.AddressTuple serverAddress;
+ public int httpsPort;
+ public boolean isNvidiaServerSoftware;
+ public X509Certificate serverCert;
+ public StreamConfiguration streamConfig;
+ public NvConnectionListener connListener;
+ public SecretKey riKey;
+ public int riKeyId;
+
+ // This is the version quad from the appversion tag of /serverinfo
+ public String serverAppVersion;
+ public String serverGfeVersion;
+ public int serverCodecModeSupport;
+
+ // This is the sessionUrl0 tag from /resume and /launch
+ public String rtspSessionUrl;
+
+ public int negotiatedWidth, negotiatedHeight;
+ public boolean negotiatedHdr;
+
+ public int negotiatedRemoteStreaming;
+ public int negotiatedPacketSize;
+
+ public int videoCapabilities;
+}
diff --git a/app/src/main/java/com/limelight/nvstream/NvConnection.java b/app/src/main/java/com/limelight/nvstream/NvConnection.java
old mode 100644
new mode 100755
index f29563f1c1..66eabf9fb5
--- a/app/src/main/java/com/limelight/nvstream/NvConnection.java
+++ b/app/src/main/java/com/limelight/nvstream/NvConnection.java
@@ -1,591 +1,633 @@
-package com.limelight.nvstream;
-
-import android.app.ActivityManager;
-import android.content.Context;
-import android.net.ConnectivityManager;
-import android.net.IpPrefix;
-import android.net.LinkProperties;
-import android.net.Network;
-import android.net.NetworkCapabilities;
-import android.net.NetworkInfo;
-import android.net.RouteInfo;
-import android.os.Build;
-
-import java.io.IOException;
-import java.net.InetAddress;
-import java.net.InetSocketAddress;
-import java.net.Socket;
-import java.nio.ByteBuffer;
-import java.security.NoSuchAlgorithmException;
-import java.security.SecureRandom;
-import java.security.cert.X509Certificate;
-import java.util.Timer;
-import java.util.TimerTask;
-import java.util.concurrent.Semaphore;
-
-import javax.crypto.KeyGenerator;
-import javax.crypto.SecretKey;
-
-import org.xmlpull.v1.XmlPullParserException;
-
-import com.limelight.LimeLog;
-import com.limelight.nvstream.av.audio.AudioRenderer;
-import com.limelight.nvstream.av.video.VideoDecoderRenderer;
-import com.limelight.nvstream.http.ComputerDetails;
-import com.limelight.nvstream.http.HostHttpResponseException;
-import com.limelight.nvstream.http.LimelightCryptoProvider;
-import com.limelight.nvstream.http.NvApp;
-import com.limelight.nvstream.http.NvHTTP;
-import com.limelight.nvstream.http.PairingManager;
-import com.limelight.nvstream.input.MouseButtonPacket;
-import com.limelight.nvstream.jni.MoonBridge;
-
-public class NvConnection {
- // Context parameters
- private LimelightCryptoProvider cryptoProvider;
- private String uniqueId;
- private ConnectionContext context;
- private static Semaphore connectionAllowed = new Semaphore(1);
- private final boolean isMonkey;
- private final Context appContext;
-
- public NvConnection(Context appContext, ComputerDetails.AddressTuple host, int httpsPort, String uniqueId, StreamConfiguration config, LimelightCryptoProvider cryptoProvider, X509Certificate serverCert)
- {
- this.appContext = appContext;
- this.cryptoProvider = cryptoProvider;
- this.uniqueId = uniqueId;
-
- this.context = new ConnectionContext();
- this.context.serverAddress = host;
- this.context.httpsPort = httpsPort;
- this.context.streamConfig = config;
- this.context.serverCert = serverCert;
-
- // This is unique per connection
- this.context.riKey = generateRiAesKey();
- this.context.riKeyId = generateRiKeyId();
-
- this.isMonkey = ActivityManager.isUserAMonkey();
- }
-
- private static SecretKey generateRiAesKey() {
- try {
- KeyGenerator keyGen = KeyGenerator.getInstance("AES");
-
- // RI keys are 128 bits
- keyGen.init(128);
-
- return keyGen.generateKey();
- } catch (NoSuchAlgorithmException e) {
- e.printStackTrace();
- throw new RuntimeException(e);
- }
- }
-
- private static int generateRiKeyId() {
- return new SecureRandom().nextInt();
- }
-
- public void stop() {
- // Interrupt any pending connection. This is thread-safe.
- MoonBridge.interruptConnection();
-
- // Moonlight-core is not thread-safe with respect to connection start and stop, so
- // we must not invoke that functionality in parallel.
- synchronized (MoonBridge.class) {
- MoonBridge.stopConnection();
- MoonBridge.cleanupBridge();
- }
-
- // Now a pending connection can be processed
- connectionAllowed.release();
- }
-
- private InetAddress resolveServerAddress() throws IOException {
- // Try to find an address that works for this host
- InetAddress[] addrs = InetAddress.getAllByName(context.serverAddress.address);
- for (InetAddress addr : addrs) {
- try (Socket s = new Socket()) {
- s.setSoLinger(true, 0);
- s.connect(new InetSocketAddress(addr, context.serverAddress.port), 1000);
- return addr;
- } catch (IOException e) {
- e.printStackTrace();
- }
- }
-
- // If we made it here, we didn't manage to find a working address. If DNS returned any
- // address, we'll use the first available address and hope for the best.
- if (addrs.length > 0) {
- return addrs[0];
- }
- else {
- throw new IOException("No addresses found for "+context.serverAddress);
- }
- }
-
- private int detectServerConnectionType() {
- ConnectivityManager connMgr = (ConnectivityManager) appContext.getSystemService(Context.CONNECTIVITY_SERVICE);
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- Network activeNetwork = connMgr.getActiveNetwork();
- if (activeNetwork != null) {
- NetworkCapabilities netCaps = connMgr.getNetworkCapabilities(activeNetwork);
- if (netCaps != null) {
- if (netCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) ||
- !netCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) {
- // VPNs are treated as remote connections
- return StreamConfiguration.STREAM_CFG_REMOTE;
- }
- else if (netCaps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
- // Cellular is always treated as remote to avoid any possible
- // issues with 464XLAT or similar technologies.
- return StreamConfiguration.STREAM_CFG_REMOTE;
- }
- }
-
- // Check if the server address is on-link
- LinkProperties linkProperties = connMgr.getLinkProperties(activeNetwork);
- if (linkProperties != null) {
- InetAddress serverAddress;
- try {
- serverAddress = resolveServerAddress();
- } catch (IOException e) {
- e.printStackTrace();
-
- // We can't decide without being able to resolve the server address
- return StreamConfiguration.STREAM_CFG_AUTO;
- }
-
- // If the address is in the NAT64 prefix, always treat it as remote
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- IpPrefix nat64Prefix = linkProperties.getNat64Prefix();
- if (nat64Prefix != null && nat64Prefix.contains(serverAddress)) {
- return StreamConfiguration.STREAM_CFG_REMOTE;
- }
- }
-
- for (RouteInfo route : linkProperties.getRoutes()) {
- // Skip non-unicast routes (which are all we get prior to Android 13)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && route.getType() != RouteInfo.RTN_UNICAST) {
- continue;
- }
-
- // Find the first route that matches this address
- if (route.matches(serverAddress)) {
- // If there's no gateway, this is an on-link destination
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- // We want to use hasGateway() because getGateway() doesn't adhere
- // to documented behavior of returning null for on-link addresses.
- if (!route.hasGateway()) {
- return StreamConfiguration.STREAM_CFG_LOCAL;
- }
- }
- else {
- // getGateway() is documented to return null for on-link destinations,
- // but it actually returns the unspecified address (0.0.0.0 or ::).
- InetAddress gateway = route.getGateway();
- if (gateway == null || gateway.isAnyLocalAddress()) {
- return StreamConfiguration.STREAM_CFG_LOCAL;
- }
- }
-
- // We _should_ stop after the first matching route, but for some reason
- // Android doesn't always report IPv6 routes in descending order of
- // specificity and metric. To handle that case, we enumerate all matching
- // routes, assuming that an on-link route will always be preferred.
- }
- }
- }
- }
- }
- else {
- NetworkInfo activeNetworkInfo = connMgr.getActiveNetworkInfo();
- if (activeNetworkInfo != null) {
- switch (activeNetworkInfo.getType()) {
- case ConnectivityManager.TYPE_VPN:
- case ConnectivityManager.TYPE_MOBILE:
- case ConnectivityManager.TYPE_MOBILE_DUN:
- case ConnectivityManager.TYPE_MOBILE_HIPRI:
- case ConnectivityManager.TYPE_MOBILE_MMS:
- case ConnectivityManager.TYPE_MOBILE_SUPL:
- case ConnectivityManager.TYPE_WIMAX:
- // VPNs and cellular connections are always remote connections
- return StreamConfiguration.STREAM_CFG_REMOTE;
- }
- }
- }
-
- // If we can't determine the connection type, let moonlight-common-c decide.
- return StreamConfiguration.STREAM_CFG_AUTO;
- }
-
- private boolean startApp() throws XmlPullParserException, IOException
- {
- NvHTTP h = new NvHTTP(context.serverAddress, context.httpsPort, uniqueId, context.serverCert, cryptoProvider);
-
- String serverInfo = h.getServerInfo(true);
-
- context.serverAppVersion = h.getServerVersion(serverInfo);
- if (context.serverAppVersion == null) {
- context.connListener.displayMessage("Server version malformed");
- return false;
- }
-
- ComputerDetails details = h.getComputerDetails(serverInfo);
- context.isNvidiaServerSoftware = details.nvidiaServer;
-
- // May be missing for older servers
- context.serverGfeVersion = h.getGfeVersion(serverInfo);
-
- if (h.getPairState(serverInfo) != PairingManager.PairState.PAIRED) {
- context.connListener.displayMessage("Device not paired with computer");
- return false;
- }
-
- context.serverCodecModeSupport = (int)h.getServerCodecModeSupport(serverInfo);
-
- context.negotiatedHdr = (context.streamConfig.getSupportedVideoFormats() & MoonBridge.VIDEO_FORMAT_MASK_10BIT) != 0;
- if ((context.serverCodecModeSupport & 0x20200) == 0 && context.negotiatedHdr) {
- context.connListener.displayTransientMessage("Your PC GPU does not support streaming HDR. The stream will be SDR.");
- context.negotiatedHdr = false;
- }
-
- //
- // Decide on negotiated stream parameters now
- //
-
- // Check for a supported stream resolution
- if ((context.streamConfig.getWidth() > 4096 || context.streamConfig.getHeight() > 4096) &&
- (h.getServerCodecModeSupport(serverInfo) & 0x200) == 0 && context.isNvidiaServerSoftware) {
- context.connListener.displayMessage("Your host PC does not support streaming at resolutions above 4K.");
- return false;
- }
- else if ((context.streamConfig.getWidth() > 4096 || context.streamConfig.getHeight() > 4096) &&
- (context.streamConfig.getSupportedVideoFormats() & ~MoonBridge.VIDEO_FORMAT_MASK_H264) == 0) {
- context.connListener.displayMessage("Your streaming device must support HEVC or AV1 to stream at resolutions above 4K.");
- return false;
- }
- else if (context.streamConfig.getHeight() >= 2160 && !h.supports4K(serverInfo)) {
- // Client wants 4K but the server can't do it
- context.connListener.displayTransientMessage("You must update GeForce Experience to stream in 4K. The stream will be 1080p.");
-
- // Lower resolution to 1080p
- context.negotiatedWidth = 1920;
- context.negotiatedHeight = 1080;
- }
- else {
- // Take what the client wanted
- context.negotiatedWidth = context.streamConfig.getWidth();
- context.negotiatedHeight = context.streamConfig.getHeight();
- }
-
- // We will perform some connection type detection if the caller asked for it
- if (context.streamConfig.getRemote() == StreamConfiguration.STREAM_CFG_AUTO) {
- context.negotiatedRemoteStreaming = detectServerConnectionType();
- context.negotiatedPacketSize =
- context.negotiatedRemoteStreaming == StreamConfiguration.STREAM_CFG_REMOTE ?
- 1024 : context.streamConfig.getMaxPacketSize();
- }
- else {
- context.negotiatedRemoteStreaming = context.streamConfig.getRemote();
- context.negotiatedPacketSize = context.streamConfig.getMaxPacketSize();
- }
-
- //
- // Video stream format will be decided during the RTSP handshake
- //
-
- NvApp app = context.streamConfig.getApp();
-
- // If the client did not provide an exact app ID, do a lookup with the applist
- if (!context.streamConfig.getApp().isInitialized()) {
- LimeLog.info("Using deprecated app lookup method - Please specify an app ID in your StreamConfiguration instead");
- app = h.getAppByName(context.streamConfig.getApp().getAppName());
- if (app == null) {
- context.connListener.displayMessage("The app " + context.streamConfig.getApp().getAppName() + " is not in GFE app list");
- return false;
- }
- }
-
- // If there's a game running, resume it
- if (h.getCurrentGame(serverInfo) != 0) {
- try {
- if (h.getCurrentGame(serverInfo) == app.getAppId()) {
- if (!h.launchApp(context, "resume", app.getAppId(), context.negotiatedHdr)) {
- context.connListener.displayMessage("Failed to resume existing session");
- return false;
- }
- } else {
- return quitAndLaunch(h, context);
- }
- } catch (HostHttpResponseException e) {
- if (e.getErrorCode() == 470) {
- // This is the error you get when you try to resume a session that's not yours.
- // Because this is fairly common, we'll display a more detailed message.
- context.connListener.displayMessage("This session wasn't started by this device," +
- " so it cannot be resumed. End streaming on the original " +
- "device or the PC itself and try again. (Error code: "+e.getErrorCode()+")");
- return false;
- }
- else if (e.getErrorCode() == 525) {
- context.connListener.displayMessage("The application is minimized. Resume it on the PC manually or " +
- "quit the session and start streaming again.");
- return false;
- } else {
- throw e;
- }
- }
-
- LimeLog.info("Resumed existing game session");
- return true;
- }
- else {
- return launchNotRunningApp(h, context);
- }
- }
-
- protected boolean quitAndLaunch(NvHTTP h, ConnectionContext context) throws IOException,
- XmlPullParserException {
- try {
- if (!h.quitApp()) {
- context.connListener.displayMessage("Failed to quit previous session! You must quit it manually");
- return false;
- }
- } catch (HostHttpResponseException e) {
- if (e.getErrorCode() == 599) {
- context.connListener.displayMessage("This session wasn't started by this device," +
- " so it cannot be quit. End streaming on the original " +
- "device or the PC itself. (Error code: "+e.getErrorCode()+")");
- return false;
- }
- else {
- throw e;
- }
- }
-
- return launchNotRunningApp(h, context);
- }
-
- private boolean launchNotRunningApp(NvHTTP h, ConnectionContext context)
- throws IOException, XmlPullParserException {
- // Launch the app since it's not running
- if (!h.launchApp(context, "launch", context.streamConfig.getApp().getAppId(), context.negotiatedHdr)) {
- context.connListener.displayMessage("Failed to launch application");
- return false;
- }
-
- LimeLog.info("Launched new game session");
-
- return true;
- }
-
- public void start(final AudioRenderer audioRenderer, final VideoDecoderRenderer videoDecoderRenderer, final NvConnectionListener connectionListener)
- {
- new Thread(new Runnable() {
- public void run() {
- context.connListener = connectionListener;
- context.videoCapabilities = videoDecoderRenderer.getCapabilities();
-
- String appName = context.streamConfig.getApp().getAppName();
-
- context.connListener.stageStarting(appName);
-
- try {
- if (!startApp()) {
- context.connListener.stageFailed(appName, 0, 0);
- return;
- }
- context.connListener.stageComplete(appName);
- } catch (HostHttpResponseException e) {
- e.printStackTrace();
- context.connListener.displayMessage(e.getMessage());
- context.connListener.stageFailed(appName, 0, e.getErrorCode());
- return;
- } catch (XmlPullParserException | IOException e) {
- e.printStackTrace();
- context.connListener.displayMessage(e.getMessage());
- context.connListener.stageFailed(appName, MoonBridge.ML_PORT_FLAG_TCP_47984 | MoonBridge.ML_PORT_FLAG_TCP_47989, 0);
- return;
- }
-
- ByteBuffer ib = ByteBuffer.allocate(16);
- ib.putInt(context.riKeyId);
-
- // Acquire the connection semaphore to ensure we only have one
- // connection going at once.
- try {
- connectionAllowed.acquire();
- } catch (InterruptedException e) {
- context.connListener.displayMessage(e.getMessage());
- context.connListener.stageFailed(appName, 0, 0);
- return;
- }
-
- // Moonlight-core is not thread-safe with respect to connection start and stop, so
- // we must not invoke that functionality in parallel.
- synchronized (MoonBridge.class) {
- MoonBridge.setupBridge(videoDecoderRenderer, audioRenderer, connectionListener);
- int ret = MoonBridge.startConnection(context.serverAddress.address,
- context.serverAppVersion, context.serverGfeVersion, context.rtspSessionUrl,
- context.serverCodecModeSupport,
- context.negotiatedWidth, context.negotiatedHeight,
- context.streamConfig.getRefreshRate(), context.streamConfig.getBitrate(),
- context.negotiatedPacketSize, context.negotiatedRemoteStreaming,
- context.streamConfig.getAudioConfiguration().toInt(),
- context.streamConfig.getSupportedVideoFormats(),
- context.streamConfig.getClientRefreshRateX100(),
- context.riKey.getEncoded(), ib.array(),
- context.videoCapabilities,
- context.streamConfig.getColorSpace(),
- context.streamConfig.getColorRange());
- if (ret != 0) {
- // LiStartConnection() failed, so the caller is not expected
- // to stop the connection themselves. We need to release their
- // semaphore count for them.
- connectionAllowed.release();
- return;
- }
- }
- }
- }).start();
- }
-
- public void sendMouseMove(final short deltaX, final short deltaY)
- {
- if (!isMonkey) {
- MoonBridge.sendMouseMove(deltaX, deltaY);
- }
- }
-
- public void sendMousePosition(short x, short y, short referenceWidth, short referenceHeight)
- {
- if (!isMonkey) {
- MoonBridge.sendMousePosition(x, y, referenceWidth, referenceHeight);
- }
- }
-
- public void sendMouseMoveAsMousePosition(short deltaX, short deltaY, short referenceWidth, short referenceHeight)
- {
- if (!isMonkey) {
- MoonBridge.sendMouseMoveAsMousePosition(deltaX, deltaY, referenceWidth, referenceHeight);
- }
- }
-
- public void sendMouseButtonDown(final byte mouseButton)
- {
- if (!isMonkey) {
- MoonBridge.sendMouseButton(MouseButtonPacket.PRESS_EVENT, mouseButton);
- }
- }
-
- public void sendMouseButtonUp(final byte mouseButton)
- {
- if (!isMonkey) {
- MoonBridge.sendMouseButton(MouseButtonPacket.RELEASE_EVENT, mouseButton);
- }
- }
-
- public void sendControllerInput(final short controllerNumber,
- final short activeGamepadMask, final int buttonFlags,
- final byte leftTrigger, final byte rightTrigger,
- final short leftStickX, final short leftStickY,
- final short rightStickX, final short rightStickY)
- {
- if (!isMonkey) {
- MoonBridge.sendMultiControllerInput(controllerNumber, activeGamepadMask, buttonFlags,
- leftTrigger, rightTrigger, leftStickX, leftStickY, rightStickX, rightStickY);
- }
- }
-
- public void sendKeyboardInput(final short keyMap, final byte keyDirection, final byte modifier, final byte flags) {
- if (!isMonkey) {
- MoonBridge.sendKeyboardInput(keyMap, keyDirection, modifier, flags);
- }
- }
-
- public void sendMouseScroll(final byte scrollClicks) {
- if (!isMonkey) {
- MoonBridge.sendMouseHighResScroll((short)(scrollClicks * 120)); // WHEEL_DELTA
- }
- }
-
- public void sendMouseHScroll(final byte scrollClicks) {
- if (!isMonkey) {
- MoonBridge.sendMouseHighResHScroll((short)(scrollClicks * 120)); // WHEEL_DELTA
- }
- }
-
- public void sendMouseHighResScroll(final short scrollAmount) {
- if (!isMonkey) {
- MoonBridge.sendMouseHighResScroll(scrollAmount);
- }
- }
-
- public void sendMouseHighResHScroll(final short scrollAmount) {
- if (!isMonkey) {
- MoonBridge.sendMouseHighResHScroll(scrollAmount);
- }
- }
-
- public int sendTouchEvent(byte eventType, int pointerId, float x, float y, float pressureOrDistance,
- float contactAreaMajor, float contactAreaMinor, short rotation) {
- if (!isMonkey) {
- return MoonBridge.sendTouchEvent(eventType, pointerId, x, y, pressureOrDistance,
- contactAreaMajor, contactAreaMinor, rotation);
- }
- else {
- return MoonBridge.LI_ERR_UNSUPPORTED;
- }
- }
-
- public int sendPenEvent(byte eventType, byte toolType, byte penButtons, float x, float y,
- float pressureOrDistance, float contactAreaMajor, float contactAreaMinor,
- short rotation, byte tilt) {
- if (!isMonkey) {
- return MoonBridge.sendPenEvent(eventType, toolType, penButtons, x, y, pressureOrDistance,
- contactAreaMajor, contactAreaMinor, rotation, tilt);
- }
- else {
- return MoonBridge.LI_ERR_UNSUPPORTED;
- }
- }
-
- public int sendControllerArrivalEvent(byte controllerNumber, short activeGamepadMask, byte type,
- int supportedButtonFlags, short capabilities) {
- return MoonBridge.sendControllerArrivalEvent(controllerNumber, activeGamepadMask, type, supportedButtonFlags, capabilities);
- }
-
- public int sendControllerTouchEvent(byte controllerNumber, byte eventType, int pointerId,
- float x, float y, float pressure) {
- if (!isMonkey) {
- return MoonBridge.sendControllerTouchEvent(controllerNumber, eventType, pointerId, x, y, pressure);
- }
- else {
- return MoonBridge.LI_ERR_UNSUPPORTED;
- }
- }
-
- public int sendControllerMotionEvent(byte controllerNumber, byte motionType,
- float x, float y, float z) {
- if (!isMonkey) {
- return MoonBridge.sendControllerMotionEvent(controllerNumber, motionType, x, y, z);
- }
- else {
- return MoonBridge.LI_ERR_UNSUPPORTED;
- }
- }
-
- public void sendControllerBatteryEvent(byte controllerNumber, byte batteryState, byte batteryPercentage) {
- MoonBridge.sendControllerBatteryEvent(controllerNumber, batteryState, batteryPercentage);
- }
-
- public void sendUtf8Text(final String text) {
- if (!isMonkey) {
- MoonBridge.sendUtf8Text(text);
- }
- }
-
- public static String findExternalAddressForMdns(String stunHostname, int stunPort) {
- return MoonBridge.findExternalAddressIP4(stunHostname, stunPort);
- }
-}
+package com.limelight.nvstream;
+
+import static java.lang.Thread.sleep;
+
+import android.app.ActivityManager;
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.IpPrefix;
+import android.net.LinkProperties;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.net.NetworkInfo;
+import android.net.RouteInfo;
+import android.os.Build;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.nio.ByteBuffer;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.security.cert.X509Certificate;
+import java.util.Timer;
+import java.util.TimerTask;
+import java.util.concurrent.Semaphore;
+
+import javax.crypto.KeyGenerator;
+import javax.crypto.SecretKey;
+
+import org.xmlpull.v1.XmlPullParserException;
+
+import com.limelight.LimeLog;
+import com.limelight.nvstream.av.audio.AudioRenderer;
+import com.limelight.nvstream.av.video.VideoDecoderRenderer;
+import com.limelight.nvstream.http.ComputerDetails;
+import com.limelight.nvstream.http.HostHttpResponseException;
+import com.limelight.nvstream.http.LimelightCryptoProvider;
+import com.limelight.nvstream.http.NvApp;
+import com.limelight.nvstream.http.NvHTTP;
+import com.limelight.nvstream.http.PairingManager;
+import com.limelight.nvstream.input.MouseButtonPacket;
+import com.limelight.nvstream.jni.MoonBridge;
+
+public class NvConnection {
+ // Context parameters
+ private LimelightCryptoProvider cryptoProvider;
+ private String uniqueId;
+ private ConnectionContext context;
+ private static Semaphore connectionAllowed = new Semaphore(1);
+ private final boolean isMonkey;
+ private final Context appContext;
+
+ public NvConnection(Context appContext, ComputerDetails.AddressTuple host, int httpsPort, String uniqueId, StreamConfiguration config, LimelightCryptoProvider cryptoProvider, X509Certificate serverCert)
+ {
+ this.appContext = appContext;
+ this.cryptoProvider = cryptoProvider;
+ this.uniqueId = uniqueId;
+
+ this.context = new ConnectionContext();
+ this.context.serverAddress = host;
+ this.context.httpsPort = httpsPort;
+ this.context.streamConfig = config;
+ this.context.serverCert = serverCert;
+
+ // This is unique per connection
+ this.context.riKey = generateRiAesKey();
+ this.context.riKeyId = generateRiKeyId();
+
+ this.isMonkey = ActivityManager.isUserAMonkey();
+ }
+
+ private static SecretKey generateRiAesKey() {
+ try {
+ KeyGenerator keyGen = KeyGenerator.getInstance("AES");
+
+ // RI keys are 128 bits
+ keyGen.init(128);
+
+ return keyGen.generateKey();
+ } catch (NoSuchAlgorithmException e) {
+ e.printStackTrace();
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static int generateRiKeyId() {
+ return new SecureRandom().nextInt();
+ }
+
+ public void stop() {
+ // Interrupt any pending connection. This is thread-safe.
+ MoonBridge.interruptConnection();
+
+ // Moonlight-core is not thread-safe with respect to connection start and stop, so
+ // we must not invoke that functionality in parallel.
+ synchronized (MoonBridge.class) {
+ MoonBridge.stopConnection();
+ MoonBridge.cleanupBridge();
+ }
+
+ // Now a pending connection can be processed
+ connectionAllowed.release();
+ }
+
+ private InetAddress resolveServerAddress() throws IOException {
+ // Try to find an address that works for this host
+ InetAddress[] addrs = InetAddress.getAllByName(context.serverAddress.address);
+ for (InetAddress addr : addrs) {
+ try (Socket s = new Socket()) {
+ s.setSoLinger(true, 0);
+ s.connect(new InetSocketAddress(addr, context.serverAddress.port), 1000);
+ return addr;
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ // If we made it here, we didn't manage to find a working address. If DNS returned any
+ // address, we'll use the first available address and hope for the best.
+ if (addrs.length > 0) {
+ return addrs[0];
+ }
+ else {
+ throw new IOException("No addresses found for "+context.serverAddress);
+ }
+ }
+
+ private int detectServerConnectionType() {
+ ConnectivityManager connMgr = (ConnectivityManager) appContext.getSystemService(Context.CONNECTIVITY_SERVICE);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ Network activeNetwork = connMgr.getActiveNetwork();
+ if (activeNetwork != null) {
+ NetworkCapabilities netCaps = connMgr.getNetworkCapabilities(activeNetwork);
+ if (netCaps != null) {
+ if (netCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) ||
+ !netCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) {
+ // VPNs are treated as remote connections
+ return StreamConfiguration.STREAM_CFG_REMOTE;
+ }
+ else if (netCaps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
+ // Cellular is always treated as remote to avoid any possible
+ // issues with 464XLAT or similar technologies.
+ return StreamConfiguration.STREAM_CFG_REMOTE;
+ }
+ }
+
+ // Check if the server address is on-link
+ LinkProperties linkProperties = connMgr.getLinkProperties(activeNetwork);
+ if (linkProperties != null) {
+ InetAddress serverAddress;
+ try {
+ serverAddress = resolveServerAddress();
+ } catch (IOException e) {
+ e.printStackTrace();
+
+ // We can't decide without being able to resolve the server address
+ return StreamConfiguration.STREAM_CFG_AUTO;
+ }
+
+ // If the address is in the NAT64 prefix, always treat it as remote
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ IpPrefix nat64Prefix = linkProperties.getNat64Prefix();
+ if (nat64Prefix != null && nat64Prefix.contains(serverAddress)) {
+ return StreamConfiguration.STREAM_CFG_REMOTE;
+ }
+ }
+
+ for (RouteInfo route : linkProperties.getRoutes()) {
+ // Skip non-unicast routes (which are all we get prior to Android 13)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && route.getType() != RouteInfo.RTN_UNICAST) {
+ continue;
+ }
+
+ // Find the first route that matches this address
+ if (route.matches(serverAddress)) {
+ // If there's no gateway, this is an on-link destination
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ // We want to use hasGateway() because getGateway() doesn't adhere
+ // to documented behavior of returning null for on-link addresses.
+ if (!route.hasGateway()) {
+ return StreamConfiguration.STREAM_CFG_LOCAL;
+ }
+ }
+ else {
+ // getGateway() is documented to return null for on-link destinations,
+ // but it actually returns the unspecified address (0.0.0.0 or ::).
+ InetAddress gateway = route.getGateway();
+ if (gateway == null || gateway.isAnyLocalAddress()) {
+ return StreamConfiguration.STREAM_CFG_LOCAL;
+ }
+ }
+
+ // We _should_ stop after the first matching route, but for some reason
+ // Android doesn't always report IPv6 routes in descending order of
+ // specificity and metric. To handle that case, we enumerate all matching
+ // routes, assuming that an on-link route will always be preferred.
+ }
+ }
+ }
+ }
+ }
+ else {
+ NetworkInfo activeNetworkInfo = connMgr.getActiveNetworkInfo();
+ if (activeNetworkInfo != null) {
+ switch (activeNetworkInfo.getType()) {
+ case ConnectivityManager.TYPE_VPN:
+ case ConnectivityManager.TYPE_MOBILE:
+ case ConnectivityManager.TYPE_MOBILE_DUN:
+ case ConnectivityManager.TYPE_MOBILE_HIPRI:
+ case ConnectivityManager.TYPE_MOBILE_MMS:
+ case ConnectivityManager.TYPE_MOBILE_SUPL:
+ case ConnectivityManager.TYPE_WIMAX:
+ // VPNs and cellular connections are always remote connections
+ return StreamConfiguration.STREAM_CFG_REMOTE;
+ }
+ }
+ }
+
+ // If we can't determine the connection type, let moonlight-common-c decide.
+ return StreamConfiguration.STREAM_CFG_AUTO;
+ }
+
+ private boolean startApp() throws XmlPullParserException, IOException
+ {
+ NvHTTP h = new NvHTTP(context.serverAddress, context.httpsPort, uniqueId, context.serverCert, cryptoProvider);
+
+ String serverInfo = h.getServerInfo(true);
+
+ context.serverAppVersion = h.getServerVersion(serverInfo);
+ if (context.serverAppVersion == null) {
+ context.connListener.displayMessage("Server version malformed");
+ return false;
+ }
+
+ ComputerDetails details = h.getComputerDetails(serverInfo);
+ context.isNvidiaServerSoftware = details.nvidiaServer;
+
+ // May be missing for older servers
+ context.serverGfeVersion = h.getGfeVersion(serverInfo);
+
+ if (h.getPairState(serverInfo) != PairingManager.PairState.PAIRED) {
+ context.connListener.displayMessage("Device not paired with computer");
+ return false;
+ }
+
+ context.serverCodecModeSupport = (int)h.getServerCodecModeSupport(serverInfo);
+
+ context.negotiatedHdr = (context.streamConfig.getSupportedVideoFormats() & MoonBridge.VIDEO_FORMAT_MASK_10BIT) != 0;
+ if ((context.serverCodecModeSupport & 0x20200) == 0 && context.negotiatedHdr) {
+ context.connListener.displayTransientMessage("Your PC GPU does not support streaming HDR. The stream will be SDR.");
+ context.negotiatedHdr = false;
+ }
+
+ //
+ // Decide on negotiated stream parameters now
+ //
+
+ // Check for a supported stream resolution
+ if ((context.streamConfig.getWidth() > 4096 || context.streamConfig.getHeight() > 4096) &&
+ (h.getServerCodecModeSupport(serverInfo) & 0x200) == 0 && context.isNvidiaServerSoftware) {
+ context.connListener.displayMessage("Your host PC does not support streaming at resolutions above 4K.");
+ return false;
+ }
+ else if ((context.streamConfig.getWidth() > 4096 || context.streamConfig.getHeight() > 4096) &&
+ (context.streamConfig.getSupportedVideoFormats() & ~MoonBridge.VIDEO_FORMAT_MASK_H264) == 0) {
+ context.connListener.displayMessage("Your streaming device must support HEVC or AV1 to stream at resolutions above 4K.");
+ return false;
+ }
+ else if (context.streamConfig.getHeight() >= 2160 && !h.supports4K(serverInfo)) {
+ // Client wants 4K but the server can't do it
+ context.connListener.displayTransientMessage("You must update GeForce Experience to stream in 4K. The stream will be 1080p.");
+
+ // Lower resolution to 1080p
+ context.negotiatedWidth = 1920;
+ context.negotiatedHeight = 1080;
+ }
+ else {
+ // Take what the client wanted
+ context.negotiatedWidth = context.streamConfig.getWidth();
+ context.negotiatedHeight = context.streamConfig.getHeight();
+ }
+
+ // We will perform some connection type detection if the caller asked for it
+ if (context.streamConfig.getRemote() == StreamConfiguration.STREAM_CFG_AUTO) {
+ context.negotiatedRemoteStreaming = detectServerConnectionType();
+ context.negotiatedPacketSize =
+ context.negotiatedRemoteStreaming == StreamConfiguration.STREAM_CFG_REMOTE ?
+ 1024 : context.streamConfig.getMaxPacketSize();
+ }
+ else {
+ context.negotiatedRemoteStreaming = context.streamConfig.getRemote();
+ context.negotiatedPacketSize = context.streamConfig.getMaxPacketSize();
+ }
+
+ //
+ // Video stream format will be decided during the RTSP handshake
+ //
+
+ NvApp app = context.streamConfig.getApp();
+
+ // If the client did not provide an exact app ID, do a lookup with the applist
+ if (!context.streamConfig.getApp().isInitialized()) {
+ LimeLog.info("Using deprecated app lookup method - Please specify an app ID in your StreamConfiguration instead");
+ app = h.getAppByName(context.streamConfig.getApp().getAppName());
+ if (app == null) {
+ context.connListener.displayMessage("The app " + context.streamConfig.getApp().getAppName() + " is not in GFE app list");
+ return false;
+ }
+ }
+
+ // If there's a game running, resume it
+ if (h.getCurrentGame(serverInfo) != 0) {
+ try {
+ if (h.getCurrentGame(serverInfo) == app.getAppId()) {
+ if (!h.launchApp(context, "resume", app.getAppId(), context.negotiatedHdr)) {
+ context.connListener.displayMessage("Failed to resume existing session");
+ return false;
+ }
+ } else {
+ return quitAndLaunch(h, context);
+ }
+ } catch (HostHttpResponseException e) {
+ if (e.getErrorCode() == 470) {
+ // This is the error you get when you try to resume a session that's not yours.
+ // Because this is fairly common, we'll display a more detailed message.
+ context.connListener.displayMessage("This session wasn't started by this device," +
+ " so it cannot be resumed. End streaming on the original " +
+ "device or the PC itself and try again. (Error code: "+e.getErrorCode()+")");
+ return false;
+ }
+ else if (e.getErrorCode() == 525) {
+ context.connListener.displayMessage("The application is minimized. Resume it on the PC manually or " +
+ "quit the session and start streaming again.");
+ return false;
+ } else {
+ throw e;
+ }
+ }
+
+ LimeLog.info("Resumed existing game session");
+ return true;
+ }
+ else {
+ return launchNotRunningApp(h, context);
+ }
+ }
+
+ protected boolean quitAndLaunch(NvHTTP h, ConnectionContext context) throws IOException,
+ XmlPullParserException {
+ try {
+ if (!h.quitApp()) {
+ context.connListener.displayMessage("Failed to quit previous session! You must quit it manually");
+ return false;
+ }
+ } catch (HostHttpResponseException e) {
+ if (e.getErrorCode() == 599) {
+ context.connListener.displayMessage("This session wasn't started by this device," +
+ " so it cannot be quit. End streaming on the original " +
+ "device or the PC itself. (Error code: "+e.getErrorCode()+")");
+ return false;
+ }
+ else {
+ throw e;
+ }
+ }
+
+ return launchNotRunningApp(h, context);
+ }
+
+ private boolean launchNotRunningApp(NvHTTP h, ConnectionContext context)
+ throws IOException, XmlPullParserException {
+ // Launch the app since it's not running
+ if (!h.launchApp(context, "launch", context.streamConfig.getApp().getAppId(), context.negotiatedHdr)) {
+ context.connListener.displayMessage("Failed to launch application");
+ return false;
+ }
+
+ LimeLog.info("Launched new game session");
+
+ return true;
+ }
+
+ public void start(final AudioRenderer audioRenderer, final VideoDecoderRenderer videoDecoderRenderer, final NvConnectionListener connectionListener)
+ {
+ new Thread(new Runnable() {
+ public void run() {
+ context.connListener = connectionListener;
+ context.videoCapabilities = videoDecoderRenderer.getCapabilities();
+
+ String appName = context.streamConfig.getApp().getAppName();
+
+ context.connListener.stageStarting(appName);
+
+ int tryCount = 0;
+
+ do {
+ boolean retry = false;
+ try {
+ if (!startApp()) {
+ retry = context.connListener.stageFailed(appName, 0, 0);
+ if (!retry) {
+ return;
+ }
+ }
+ context.connListener.stageComplete(appName);
+ } catch (HostHttpResponseException e) {
+ e.printStackTrace();
+ context.connListener.displayMessage(e.getMessage());
+ retry = context.connListener.stageFailed(appName, 0, e.getErrorCode());
+ if (!retry) {
+ return;
+ }
+ } catch (XmlPullParserException | IOException e) {
+ e.printStackTrace();
+ context.connListener.displayMessage(e.getMessage());
+ retry = context.connListener.stageFailed(appName, MoonBridge.ML_PORT_FLAG_TCP_47984 | MoonBridge.ML_PORT_FLAG_TCP_47989, tryCount < 2 ? 0 : -408);
+ if (!retry) {
+ return;
+ }
+ }
+
+ if (!retry) break;
+ tryCount += 1;
+
+ try {
+ sleep(2000);
+ } catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ } while (tryCount < 5);
+
+ if (tryCount >= 5) {
+ context.connListener.stageFailed(appName, 0, -408);
+ return;
+ }
+
+ ByteBuffer ib = ByteBuffer.allocate(16);
+ ib.putInt(context.riKeyId);
+
+ // Acquire the connection semaphore to ensure we only have one
+ // connection going at once.
+ try {
+ connectionAllowed.acquire();
+ } catch (InterruptedException e) {
+ context.connListener.displayMessage(e.getMessage());
+ context.connListener.stageFailed(appName, 0, 0);
+ return;
+ }
+
+ // Moonlight-core is not thread-safe with respect to connection start and stop, so
+ // we must not invoke that functionality in parallel.
+ synchronized (MoonBridge.class) {
+ MoonBridge.setupBridge(videoDecoderRenderer, audioRenderer, connectionListener);
+ int ret = MoonBridge.startConnection(context.serverAddress.address,
+ context.serverAppVersion, context.serverGfeVersion, context.rtspSessionUrl,
+ context.serverCodecModeSupport,
+ context.negotiatedWidth, context.negotiatedHeight,
+ context.streamConfig.getRefreshRate(), context.streamConfig.getBitrate(),
+ context.negotiatedPacketSize, context.negotiatedRemoteStreaming,
+ context.streamConfig.getAudioConfiguration().toInt(),
+ context.streamConfig.getSupportedVideoFormats(),
+ context.streamConfig.getClientRefreshRateX100(),
+ context.riKey.getEncoded(), ib.array(),
+ context.videoCapabilities,
+ context.streamConfig.getColorSpace(),
+ context.streamConfig.getColorRange());
+ if (ret != 0) {
+ // LiStartConnection() failed, so the caller is not expected
+ // to stop the connection themselves. We need to release their
+ // semaphore count for them.
+ connectionAllowed.release();
+ return;
+ }
+ }
+ }
+ }).start();
+ }
+
+ public void sendExecServerCmd(final int cmdId) {
+ LimeLog.info("sendExecServerCmd: " + cmdId);
+
+ if (!isMonkey) {
+ MoonBridge.sendExecServerCmd(cmdId);
+ }
+ }
+
+ public void sendMouseMove(final short deltaX, final short deltaY)
+ {
+ LimeLog.info("sendMousePosition==1-"+deltaX+","+deltaY);
+
+ if (!isMonkey) {
+ MoonBridge.sendMouseMove(deltaX, deltaY);
+ }
+ }
+
+ public void sendMousePosition(short x, short y, short referenceWidth, short referenceHeight)
+ {
+ LimeLog.info("sendMousePosition==2");
+ if (!isMonkey) {
+ MoonBridge.sendMousePosition(x, y, referenceWidth, referenceHeight);
+ }
+ }
+
+ public void sendMouseMoveAsMousePosition(short deltaX, short deltaY, short referenceWidth, short referenceHeight)
+ {
+ LimeLog.info("sendMousePosition==3-"+deltaX);
+
+ if (!isMonkey) {
+ MoonBridge.sendMouseMoveAsMousePosition(deltaX, deltaY, referenceWidth, referenceHeight);
+ }
+ }
+
+ public void sendMouseButtonDown(final byte mouseButton)
+ {
+ LimeLog.info("sendMousePosition==4");
+ if (!isMonkey) {
+ MoonBridge.sendMouseButton(MouseButtonPacket.PRESS_EVENT, mouseButton);
+ }
+ }
+
+ public void sendMouseButtonUp(final byte mouseButton)
+ {
+ LimeLog.info("sendMousePosition==5");
+ if (!isMonkey) {
+ MoonBridge.sendMouseButton(MouseButtonPacket.RELEASE_EVENT, mouseButton);
+ }
+ }
+
+ public void sendControllerInput(final short controllerNumber,
+ final short activeGamepadMask, final int buttonFlags,
+ final byte leftTrigger, final byte rightTrigger,
+ final short leftStickX, final short leftStickY,
+ final short rightStickX, final short rightStickY)
+ {
+ if (!isMonkey) {
+ MoonBridge.sendMultiControllerInput(controllerNumber, activeGamepadMask, buttonFlags,
+ leftTrigger, rightTrigger, leftStickX, leftStickY, rightStickX, rightStickY);
+ }
+ }
+
+ public void sendKeyboardInput(final short keyMap, final byte keyDirection, final byte modifier, final byte flags) {
+ if (!isMonkey) {
+ MoonBridge.sendKeyboardInput(keyMap, keyDirection, modifier, flags);
+ }
+ }
+
+ public void sendMouseScroll(final byte scrollClicks) {
+ if (!isMonkey) {
+ MoonBridge.sendMouseHighResScroll((short)(scrollClicks * 120)); // WHEEL_DELTA
+ }
+ }
+
+ public void sendMouseHScroll(final byte scrollClicks) {
+ if (!isMonkey) {
+ MoonBridge.sendMouseHighResHScroll((short)(scrollClicks * 120)); // WHEEL_DELTA
+ }
+ }
+
+ public void sendMouseHighResScroll(final short scrollAmount) {
+ if (!isMonkey) {
+ MoonBridge.sendMouseHighResScroll(scrollAmount);
+ }
+ }
+
+ public void sendMouseHighResHScroll(final short scrollAmount) {
+ if (!isMonkey) {
+ MoonBridge.sendMouseHighResHScroll(scrollAmount);
+ }
+ }
+
+ public int sendTouchEvent(byte eventType, int pointerId, float x, float y, float pressureOrDistance,
+ float contactAreaMajor, float contactAreaMinor, short rotation) {
+ if (!isMonkey) {
+ return MoonBridge.sendTouchEvent(eventType, pointerId, x, y, pressureOrDistance,
+ contactAreaMajor, contactAreaMinor, rotation);
+ }
+ else {
+ return MoonBridge.LI_ERR_UNSUPPORTED;
+ }
+ }
+
+ public int sendPenEvent(byte eventType, byte toolType, byte penButtons, float x, float y,
+ float pressureOrDistance, float contactAreaMajor, float contactAreaMinor,
+ short rotation, byte tilt) {
+ if (!isMonkey) {
+ return MoonBridge.sendPenEvent(eventType, toolType, penButtons, x, y, pressureOrDistance,
+ contactAreaMajor, contactAreaMinor, rotation, tilt);
+ }
+ else {
+ return MoonBridge.LI_ERR_UNSUPPORTED;
+ }
+ }
+
+ public int sendControllerArrivalEvent(byte controllerNumber, short activeGamepadMask, byte type,
+ int supportedButtonFlags, short capabilities) {
+ return MoonBridge.sendControllerArrivalEvent(controllerNumber, activeGamepadMask, type, supportedButtonFlags, capabilities);
+ }
+
+ public int sendControllerTouchEvent(byte controllerNumber, byte eventType, int pointerId,
+ float x, float y, float pressure) {
+ if (!isMonkey) {
+ return MoonBridge.sendControllerTouchEvent(controllerNumber, eventType, pointerId, x, y, pressure);
+ }
+ else {
+ return MoonBridge.LI_ERR_UNSUPPORTED;
+ }
+ }
+
+ public int sendControllerMotionEvent(byte controllerNumber, byte motionType,
+ float x, float y, float z) {
+ if (!isMonkey) {
+ return MoonBridge.sendControllerMotionEvent(controllerNumber, motionType, x, y, z);
+ }
+ else {
+ return MoonBridge.LI_ERR_UNSUPPORTED;
+ }
+ }
+
+ public void sendControllerBatteryEvent(byte controllerNumber, byte batteryState, byte batteryPercentage) {
+ MoonBridge.sendControllerBatteryEvent(controllerNumber, batteryState, batteryPercentage);
+ }
+
+ public void sendUtf8Text(final String text) {
+ if (!isMonkey) {
+ MoonBridge.sendUtf8Text(text);
+ }
+ }
+
+ public static String findExternalAddressForMdns(String stunHostname, int stunPort) {
+ return MoonBridge.findExternalAddressIP4(stunHostname, stunPort);
+ }
+}
diff --git a/app/src/main/java/com/limelight/nvstream/NvConnectionListener.java b/app/src/main/java/com/limelight/nvstream/NvConnectionListener.java
old mode 100644
new mode 100755
index a6109dbfe5..5dadec5163
--- a/app/src/main/java/com/limelight/nvstream/NvConnectionListener.java
+++ b/app/src/main/java/com/limelight/nvstream/NvConnectionListener.java
@@ -1,23 +1,23 @@
-package com.limelight.nvstream;
-
-public interface NvConnectionListener {
- void stageStarting(String stage);
- void stageComplete(String stage);
- void stageFailed(String stage, int portFlags, int errorCode);
-
- void connectionStarted();
- void connectionTerminated(int errorCode);
- void connectionStatusUpdate(int connectionStatus);
-
- void displayMessage(String message);
- void displayTransientMessage(String message);
-
- void rumble(short controllerNumber, short lowFreqMotor, short highFreqMotor);
- void rumbleTriggers(short controllerNumber, short leftTrigger, short rightTrigger);
-
- void setHdrMode(boolean enabled, byte[] hdrMetadata);
-
- void setMotionEventState(short controllerNumber, byte motionType, short reportRateHz);
-
- void setControllerLED(short controllerNumber, byte r, byte g, byte b);
-}
+package com.limelight.nvstream;
+
+public interface NvConnectionListener {
+ void stageStarting(String stage);
+ void stageComplete(String stage);
+ boolean stageFailed(String stage, int portFlags, int errorCode);
+
+ void connectionStarted();
+ void connectionTerminated(int errorCode);
+ void connectionStatusUpdate(int connectionStatus);
+
+ void displayMessage(String message);
+ void displayTransientMessage(String message);
+
+ void rumble(short controllerNumber, short lowFreqMotor, short highFreqMotor);
+ void rumbleTriggers(short controllerNumber, short leftTrigger, short rightTrigger);
+
+ void setHdrMode(boolean enabled, byte[] hdrMetadata);
+
+ void setMotionEventState(short controllerNumber, byte motionType, short reportRateHz);
+
+ void setControllerLED(short controllerNumber, byte r, byte g, byte b);
+}
diff --git a/app/src/main/java/com/limelight/nvstream/StreamConfiguration.java b/app/src/main/java/com/limelight/nvstream/StreamConfiguration.java
old mode 100644
new mode 100755
index 317f9381c1..bd17bc727e
--- a/app/src/main/java/com/limelight/nvstream/StreamConfiguration.java
+++ b/app/src/main/java/com/limelight/nvstream/StreamConfiguration.java
@@ -1,224 +1,261 @@
-package com.limelight.nvstream;
-
-import com.limelight.nvstream.http.NvApp;
-import com.limelight.nvstream.jni.MoonBridge;
-
-public class StreamConfiguration {
- public static final int INVALID_APP_ID = 0;
-
- public static final int STREAM_CFG_LOCAL = 0;
- public static final int STREAM_CFG_REMOTE = 1;
- public static final int STREAM_CFG_AUTO = 2;
-
- private NvApp app;
- private int width, height;
- private int refreshRate;
- private int launchRefreshRate;
- private int clientRefreshRateX100;
- private int bitrate;
- private boolean sops;
- private boolean enableAdaptiveResolution;
- private boolean playLocalAudio;
- private int maxPacketSize;
- private int remote;
- private MoonBridge.AudioConfiguration audioConfiguration;
- private int supportedVideoFormats;
- private int attachedGamepadMask;
- private int encryptionFlags;
- private int colorRange;
- private int colorSpace;
- private boolean persistGamepadsAfterDisconnect;
-
- public static class Builder {
- private StreamConfiguration config = new StreamConfiguration();
-
- public StreamConfiguration.Builder setApp(NvApp app) {
- config.app = app;
- return this;
- }
-
- public StreamConfiguration.Builder setRemoteConfiguration(int remote) {
- config.remote = remote;
- return this;
- }
-
- public StreamConfiguration.Builder setResolution(int width, int height) {
- config.width = width;
- config.height = height;
- return this;
- }
-
- public StreamConfiguration.Builder setRefreshRate(int refreshRate) {
- config.refreshRate = refreshRate;
- return this;
- }
-
- public StreamConfiguration.Builder setLaunchRefreshRate(int refreshRate) {
- config.launchRefreshRate = refreshRate;
- return this;
- }
-
- public StreamConfiguration.Builder setBitrate(int bitrate) {
- config.bitrate = bitrate;
- return this;
- }
-
- public StreamConfiguration.Builder setEnableSops(boolean enable) {
- config.sops = enable;
- return this;
- }
-
- public StreamConfiguration.Builder enableAdaptiveResolution(boolean enable) {
- config.enableAdaptiveResolution = enable;
- return this;
- }
-
- public StreamConfiguration.Builder enableLocalAudioPlayback(boolean enable) {
- config.playLocalAudio = enable;
- return this;
- }
-
- public StreamConfiguration.Builder setMaxPacketSize(int maxPacketSize) {
- config.maxPacketSize = maxPacketSize;
- return this;
- }
-
- public StreamConfiguration.Builder setAttachedGamepadMask(int attachedGamepadMask) {
- config.attachedGamepadMask = attachedGamepadMask;
- return this;
- }
-
- public StreamConfiguration.Builder setAttachedGamepadMaskByCount(int gamepadCount) {
- config.attachedGamepadMask = 0;
- for (int i = 0; i < 4; i++) {
- if (gamepadCount > i) {
- config.attachedGamepadMask |= 1 << i;
- }
- }
- return this;
- }
-
- public StreamConfiguration.Builder setPersistGamepadsAfterDisconnect(boolean value) {
- config.persistGamepadsAfterDisconnect = value;
- return this;
- }
-
- public StreamConfiguration.Builder setClientRefreshRateX100(int refreshRateX100) {
- config.clientRefreshRateX100 = refreshRateX100;
- return this;
- }
-
- public StreamConfiguration.Builder setAudioConfiguration(MoonBridge.AudioConfiguration audioConfig) {
- config.audioConfiguration = audioConfig;
- return this;
- }
-
- public StreamConfiguration.Builder setSupportedVideoFormats(int supportedVideoFormats) {
- config.supportedVideoFormats = supportedVideoFormats;
- return this;
- }
-
- public StreamConfiguration.Builder setColorRange(int colorRange) {
- config.colorRange = colorRange;
- return this;
- }
-
- public StreamConfiguration.Builder setColorSpace(int colorSpace) {
- config.colorSpace = colorSpace;
- return this;
- }
-
- public StreamConfiguration build() {
- return config;
- }
- }
-
- private StreamConfiguration() {
- // Set default attributes
- this.app = new NvApp("Steam");
- this.width = 1280;
- this.height = 720;
- this.refreshRate = 60;
- this.launchRefreshRate = 60;
- this.bitrate = 10000;
- this.maxPacketSize = 1024;
- this.remote = STREAM_CFG_AUTO;
- this.sops = true;
- this.enableAdaptiveResolution = false;
- this.audioConfiguration = MoonBridge.AUDIO_CONFIGURATION_STEREO;
- this.supportedVideoFormats = MoonBridge.VIDEO_FORMAT_H264;
- this.attachedGamepadMask = 0;
- }
-
- public int getWidth() {
- return width;
- }
-
- public int getHeight() {
- return height;
- }
-
- public int getRefreshRate() {
- return refreshRate;
- }
-
- public int getLaunchRefreshRate() {
- return launchRefreshRate;
- }
-
- public int getBitrate() {
- return bitrate;
- }
-
- public int getMaxPacketSize() {
- return maxPacketSize;
- }
-
- public NvApp getApp() {
- return app;
- }
-
- public boolean getSops() {
- return sops;
- }
-
- public boolean getAdaptiveResolutionEnabled() {
- return enableAdaptiveResolution;
- }
-
- public boolean getPlayLocalAudio() {
- return playLocalAudio;
- }
-
- public int getRemote() {
- return remote;
- }
-
- public MoonBridge.AudioConfiguration getAudioConfiguration() {
- return audioConfiguration;
- }
-
- public int getSupportedVideoFormats() {
- return supportedVideoFormats;
- }
-
- public int getAttachedGamepadMask() {
- return attachedGamepadMask;
- }
-
- public boolean getPersistGamepadsAfterDisconnect() {
- return persistGamepadsAfterDisconnect;
- }
-
- public int getClientRefreshRateX100() {
- return clientRefreshRateX100;
- }
-
- public int getColorRange() {
- return colorRange;
- }
-
- public int getColorSpace() {
- return colorSpace;
- }
-}
+package com.limelight.nvstream;
+
+import com.limelight.nvstream.http.NvApp;
+import com.limelight.nvstream.jni.MoonBridge;
+
+public class StreamConfiguration {
+ public static final int INVALID_APP_ID = 0;
+
+ public static final int STREAM_CFG_LOCAL = 0;
+ public static final int STREAM_CFG_REMOTE = 1;
+ public static final int STREAM_CFG_AUTO = 2;
+
+ private NvApp app;
+ private int width, height;
+ private float refreshRate;
+ private float launchRefreshRate;
+ private boolean virtualDisplay;
+ private int resolutionScaleFactor;
+ private int clientRefreshRateX100;
+ private int bitrate;
+ private boolean sops;
+ private boolean enableAdaptiveResolution;
+ private boolean playLocalAudio;
+ private int maxPacketSize;
+ private int remote;
+ private MoonBridge.AudioConfiguration audioConfiguration;
+ private int supportedVideoFormats;
+ private int attachedGamepadMask;
+ private int encryptionFlags;
+ private int colorRange;
+ private int colorSpace;
+ private boolean persistGamepadsAfterDisconnect;
+ private boolean enableUltraLowLatency;
+
+ public static class Builder {
+ private StreamConfiguration config = new StreamConfiguration();
+
+ public StreamConfiguration.Builder setApp(NvApp app) {
+ config.app = app;
+ return this;
+ }
+
+ public StreamConfiguration.Builder setRemoteConfiguration(int remote) {
+ config.remote = remote;
+ return this;
+ }
+
+ public StreamConfiguration.Builder setResolution(int width, int height) {
+ config.width = width;
+ config.height = height;
+ return this;
+ }
+
+ public StreamConfiguration.Builder setRefreshRate(float refreshRate) {
+ config.refreshRate = refreshRate;
+ return this;
+ }
+
+ public StreamConfiguration.Builder setLaunchRefreshRate(float refreshRate) {
+ config.launchRefreshRate = refreshRate;
+ return this;
+ }
+
+ public StreamConfiguration.Builder setVirtualDisplay(boolean enable) {
+ config.virtualDisplay = enable;
+ return this;
+ }
+
+ public StreamConfiguration.Builder setResolutionScaleFactor(int scaleFactor) {
+ config.resolutionScaleFactor = scaleFactor;
+ return this;
+ }
+
+ public StreamConfiguration.Builder setBitrate(int bitrate) {
+ config.bitrate = bitrate;
+ return this;
+ }
+
+ public StreamConfiguration.Builder setEnableSops(boolean enable) {
+ config.sops = enable;
+ return this;
+ }
+
+ public StreamConfiguration.Builder enableAdaptiveResolution(boolean enable) {
+ config.enableAdaptiveResolution = enable;
+ return this;
+ }
+
+ public StreamConfiguration.Builder enableLocalAudioPlayback(boolean enable) {
+ config.playLocalAudio = enable;
+ return this;
+ }
+
+ public StreamConfiguration.Builder setMaxPacketSize(int maxPacketSize) {
+ config.maxPacketSize = maxPacketSize;
+ return this;
+ }
+
+ public StreamConfiguration.Builder setAttachedGamepadMask(int attachedGamepadMask) {
+ config.attachedGamepadMask = attachedGamepadMask;
+ return this;
+ }
+
+ public StreamConfiguration.Builder setAttachedGamepadMaskByCount(int gamepadCount) {
+ config.attachedGamepadMask = 0;
+ for (int i = 0; i < 4; i++) {
+ if (gamepadCount > i) {
+ config.attachedGamepadMask |= 1 << i;
+ }
+ }
+ return this;
+ }
+
+ public StreamConfiguration.Builder setPersistGamepadsAfterDisconnect(boolean value) {
+ config.persistGamepadsAfterDisconnect = value;
+ return this;
+ }
+
+ public StreamConfiguration.Builder setClientRefreshRateX100(int refreshRateX100) {
+ config.clientRefreshRateX100 = refreshRateX100;
+ return this;
+ }
+
+ public StreamConfiguration.Builder setAudioConfiguration(MoonBridge.AudioConfiguration audioConfig) {
+ config.audioConfiguration = audioConfig;
+ return this;
+ }
+
+ public StreamConfiguration.Builder setSupportedVideoFormats(int supportedVideoFormats) {
+ config.supportedVideoFormats = supportedVideoFormats;
+ return this;
+ }
+
+ public StreamConfiguration.Builder setColorRange(int colorRange) {
+ config.colorRange = colorRange;
+ return this;
+ }
+
+ public StreamConfiguration.Builder setColorSpace(int colorSpace) {
+ config.colorSpace = colorSpace;
+ return this;
+ }
+
+ public StreamConfiguration.Builder setEnableUltraLowLatency(boolean enable) {
+ config.enableUltraLowLatency = enable;
+ return this;
+ }
+
+ public StreamConfiguration build() {
+ return config;
+ }
+ }
+
+ private StreamConfiguration() {
+ // Set default attributes
+ this.app = new NvApp("Steam");
+ this.width = 1280;
+ this.height = 720;
+ this.refreshRate = 60;
+ this.launchRefreshRate = 60;
+ this.virtualDisplay = false;
+ this.resolutionScaleFactor = 100;
+ this.bitrate = 10000;
+ this.maxPacketSize = 1024;
+ this.remote = STREAM_CFG_AUTO;
+ this.sops = true;
+ this.enableAdaptiveResolution = false;
+ this.audioConfiguration = MoonBridge.AUDIO_CONFIGURATION_STEREO;
+ this.supportedVideoFormats = MoonBridge.VIDEO_FORMAT_H264;
+ this.attachedGamepadMask = 0;
+ this.enableUltraLowLatency = false;
+ }
+
+ public int getWidth() {
+ return width;
+ }
+
+ public int getHeight() {
+ return height;
+ }
+
+ public int getRefreshRate() {
+ if (refreshRate == (int)refreshRate) {
+ return (int)refreshRate;
+ } else {
+ return (int)(refreshRate * 1000);
+ }
+ }
+
+ public int getLaunchRefreshRate() {
+ if (launchRefreshRate == (int)launchRefreshRate) {
+ return (int) launchRefreshRate;
+ } else {
+ return (int)(launchRefreshRate * 1000);
+ }
+ }
+
+ public boolean getVirtualDisplay() { return virtualDisplay; }
+
+ public int getResolutionScaleFactor() { return resolutionScaleFactor; }
+
+ public int getBitrate() {
+ return bitrate;
+ }
+
+ public int getMaxPacketSize() {
+ return maxPacketSize;
+ }
+
+ public NvApp getApp() {
+ return app;
+ }
+
+ public boolean getSops() {
+ return sops;
+ }
+
+ public boolean getAdaptiveResolutionEnabled() {
+ return enableAdaptiveResolution;
+ }
+
+ public boolean getPlayLocalAudio() {
+ return playLocalAudio;
+ }
+
+ public int getRemote() {
+ return remote;
+ }
+
+ public MoonBridge.AudioConfiguration getAudioConfiguration() {
+ return audioConfiguration;
+ }
+
+ public int getSupportedVideoFormats() {
+ return supportedVideoFormats;
+ }
+
+ public int getAttachedGamepadMask() {
+ return attachedGamepadMask;
+ }
+
+ public boolean getPersistGamepadsAfterDisconnect() {
+ return persistGamepadsAfterDisconnect;
+ }
+
+ public int getClientRefreshRateX100() {
+ return clientRefreshRateX100;
+ }
+
+ public int getColorRange() {
+ return colorRange;
+ }
+
+ public int getColorSpace() {
+ return colorSpace;
+ }
+
+ public boolean getEnableUltraLowLatency() {
+ return enableUltraLowLatency;
+ }
+}
diff --git a/app/src/main/java/com/limelight/nvstream/av/ByteBufferDescriptor.java b/app/src/main/java/com/limelight/nvstream/av/ByteBufferDescriptor.java
old mode 100644
new mode 100755
index b2a5910141..35e9419166
--- a/app/src/main/java/com/limelight/nvstream/av/ByteBufferDescriptor.java
+++ b/app/src/main/java/com/limelight/nvstream/av/ByteBufferDescriptor.java
@@ -1,57 +1,57 @@
-package com.limelight.nvstream.av;
-
-public class ByteBufferDescriptor {
- public byte[] data;
- public int offset;
- public int length;
-
- public ByteBufferDescriptor nextDescriptor;
-
- public ByteBufferDescriptor(byte[] data, int offset, int length)
- {
- this.data = data;
- this.offset = offset;
- this.length = length;
- }
-
- public ByteBufferDescriptor(ByteBufferDescriptor desc)
- {
- this.data = desc.data;
- this.offset = desc.offset;
- this.length = desc.length;
- }
-
- public void reinitialize(byte[] data, int offset, int length)
- {
- this.data = data;
- this.offset = offset;
- this.length = length;
- this.nextDescriptor = null;
- }
-
- public void print()
- {
- print(offset, length);
- }
-
- public void print(int length)
- {
- print(this.offset, length);
- }
-
- public void print(int offset, int length)
- {
- for (int i = offset; i < offset+length;) {
- if (i + 8 <= offset+length) {
- System.out.printf("%x: %02x %02x %02x %02x %02x %02x %02x %02x\n", i,
- data[i], data[i+1], data[i+2], data[i+3], data[i+4], data[i+5], data[i+6], data[i+7]);
- i += 8;
- }
- else {
- System.out.printf("%x: %02x \n", i, data[i]);
- i++;
- }
- }
- System.out.println();
- }
-}
+package com.limelight.nvstream.av;
+
+public class ByteBufferDescriptor {
+ public byte[] data;
+ public int offset;
+ public int length;
+
+ public ByteBufferDescriptor nextDescriptor;
+
+ public ByteBufferDescriptor(byte[] data, int offset, int length)
+ {
+ this.data = data;
+ this.offset = offset;
+ this.length = length;
+ }
+
+ public ByteBufferDescriptor(ByteBufferDescriptor desc)
+ {
+ this.data = desc.data;
+ this.offset = desc.offset;
+ this.length = desc.length;
+ }
+
+ public void reinitialize(byte[] data, int offset, int length)
+ {
+ this.data = data;
+ this.offset = offset;
+ this.length = length;
+ this.nextDescriptor = null;
+ }
+
+ public void print()
+ {
+ print(offset, length);
+ }
+
+ public void print(int length)
+ {
+ print(this.offset, length);
+ }
+
+ public void print(int offset, int length)
+ {
+ for (int i = offset; i < offset+length;) {
+ if (i + 8 <= offset+length) {
+ System.out.printf("%x: %02x %02x %02x %02x %02x %02x %02x %02x\n", i,
+ data[i], data[i+1], data[i+2], data[i+3], data[i+4], data[i+5], data[i+6], data[i+7]);
+ i += 8;
+ }
+ else {
+ System.out.printf("%x: %02x \n", i, data[i]);
+ i++;
+ }
+ }
+ System.out.println();
+ }
+}
diff --git a/app/src/main/java/com/limelight/nvstream/av/audio/AudioRenderer.java b/app/src/main/java/com/limelight/nvstream/av/audio/AudioRenderer.java
old mode 100644
new mode 100755
index 8b0053b2b1..49674bfe59
--- a/app/src/main/java/com/limelight/nvstream/av/audio/AudioRenderer.java
+++ b/app/src/main/java/com/limelight/nvstream/av/audio/AudioRenderer.java
@@ -1,15 +1,15 @@
-package com.limelight.nvstream.av.audio;
-
-import com.limelight.nvstream.jni.MoonBridge;
-
-public interface AudioRenderer {
- int setup(MoonBridge.AudioConfiguration audioConfiguration, int sampleRate, int samplesPerFrame);
-
- void start();
-
- void stop();
-
- void playDecodedAudio(short[] audioData);
-
- void cleanup();
-}
+package com.limelight.nvstream.av.audio;
+
+import com.limelight.nvstream.jni.MoonBridge;
+
+public interface AudioRenderer {
+ int setup(MoonBridge.AudioConfiguration audioConfiguration, int sampleRate, int samplesPerFrame);
+
+ void start();
+
+ void stop();
+
+ void playDecodedAudio(short[] audioData);
+
+ void cleanup();
+}
diff --git a/app/src/main/java/com/limelight/nvstream/av/video/VideoDecoderRenderer.java b/app/src/main/java/com/limelight/nvstream/av/video/VideoDecoderRenderer.java
old mode 100644
new mode 100755
index 8c222eeee8..673a90aeeb
--- a/app/src/main/java/com/limelight/nvstream/av/video/VideoDecoderRenderer.java
+++ b/app/src/main/java/com/limelight/nvstream/av/video/VideoDecoderRenderer.java
@@ -1,21 +1,21 @@
-package com.limelight.nvstream.av.video;
-
-public abstract class VideoDecoderRenderer {
- public abstract int setup(int format, int width, int height, int redrawRate);
-
- public abstract void start();
-
- public abstract void stop();
-
- // This is called once for each frame-start NALU. This means it will be called several times
- // for an IDR frame which contains several parameter sets and the I-frame data.
- public abstract int submitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength, int decodeUnitType,
- int frameNumber, int frameType, char frameHostProcessingLatency,
- long receiveTimeMs, long enqueueTimeMs);
-
- public abstract void cleanup();
-
- public abstract int getCapabilities();
-
- public abstract void setHdrMode(boolean enabled, byte[] hdrMetadata);
-}
+package com.limelight.nvstream.av.video;
+
+public abstract class VideoDecoderRenderer {
+ public abstract int setup(int format, int width, int height, int redrawRate);
+
+ public abstract void start();
+
+ public abstract void stop();
+
+ // This is called once for each frame-start NALU. This means it will be called several times
+ // for an IDR frame which contains several parameter sets and the I-frame data.
+ public abstract int submitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength, int decodeUnitType,
+ int frameNumber, int frameType, char frameHostProcessingLatency,
+ long receiveTimeMs, long enqueueTimeMs);
+
+ public abstract void cleanup();
+
+ public abstract int getCapabilities();
+
+ public abstract void setHdrMode(boolean enabled, byte[] hdrMetadata);
+}
diff --git a/app/src/main/java/com/limelight/nvstream/http/ComputerDetails.java b/app/src/main/java/com/limelight/nvstream/http/ComputerDetails.java
old mode 100644
new mode 100755
index 44ed59628d..0ba3e8e461
--- a/app/src/main/java/com/limelight/nvstream/http/ComputerDetails.java
+++ b/app/src/main/java/com/limelight/nvstream/http/ComputerDetails.java
@@ -1,168 +1,236 @@
-package com.limelight.nvstream.http;
-
-import java.security.cert.X509Certificate;
-import java.util.Objects;
-
-
-public class ComputerDetails {
- public enum State {
- ONLINE, OFFLINE, UNKNOWN
- }
-
- public static class AddressTuple {
- public String address;
- public int port;
-
- public AddressTuple(String address, int port) {
- if (address == null) {
- throw new IllegalArgumentException("Address cannot be null");
- }
- if (port <= 0) {
- throw new IllegalArgumentException("Invalid port");
- }
-
- // If this was an escaped IPv6 address, remove the brackets
- if (address.startsWith("[") && address.endsWith("]")) {
- address = address.substring(1, address.length() - 1);
- }
-
- this.address = address;
- this.port = port;
- }
-
- @Override
- public int hashCode() {
- return Objects.hash(address, port);
- }
-
- @Override
- public boolean equals(Object obj) {
- if (!(obj instanceof AddressTuple)) {
- return false;
- }
-
- AddressTuple that = (AddressTuple) obj;
- return address.equals(that.address) && port == that.port;
- }
-
- public String toString() {
- if (address.contains(":")) {
- // IPv6
- return "[" + address + "]:" + port;
- }
- else {
- // IPv4 and hostnames
- return address + ":" + port;
- }
- }
- }
-
- // Persistent attributes
- public String uuid;
- public String name;
- public AddressTuple localAddress;
- public AddressTuple remoteAddress;
- public AddressTuple manualAddress;
- public AddressTuple ipv6Address;
- public String macAddress;
- public X509Certificate serverCert;
-
- // Transient attributes
- public State state;
- public AddressTuple activeAddress;
- public int httpsPort;
- public int externalPort;
- public PairingManager.PairState pairState;
- public int runningGameId;
- public String rawAppList;
- public boolean nvidiaServer;
-
- public ComputerDetails() {
- // Use defaults
- state = State.UNKNOWN;
- }
-
- public ComputerDetails(ComputerDetails details) {
- // Copy details from the other computer
- update(details);
- }
-
- public int guessExternalPort() {
- if (externalPort != 0) {
- return externalPort;
- }
- else if (remoteAddress != null) {
- return remoteAddress.port;
- }
- else if (activeAddress != null) {
- return activeAddress.port;
- }
- else if (ipv6Address != null) {
- return ipv6Address.port;
- }
- else if (localAddress != null) {
- return localAddress.port;
- }
- else {
- return NvHTTP.DEFAULT_HTTP_PORT;
- }
- }
-
- public void update(ComputerDetails details) {
- this.state = details.state;
- this.name = details.name;
- this.uuid = details.uuid;
- if (details.activeAddress != null) {
- this.activeAddress = details.activeAddress;
- }
- // We can get IPv4 loopback addresses with GS IPv6 Forwarder
- if (details.localAddress != null && !details.localAddress.address.startsWith("127.")) {
- this.localAddress = details.localAddress;
- }
- if (details.remoteAddress != null) {
- this.remoteAddress = details.remoteAddress;
- }
- else if (this.remoteAddress != null && details.externalPort != 0) {
- // If we have a remote address already (perhaps via STUN) but our updated details
- // don't have a new one (because GFE doesn't send one), propagate the external
- // port to the current remote address. We may have tried to guess it previously.
- this.remoteAddress.port = details.externalPort;
- }
- if (details.manualAddress != null) {
- this.manualAddress = details.manualAddress;
- }
- if (details.ipv6Address != null) {
- this.ipv6Address = details.ipv6Address;
- }
- if (details.macAddress != null && !details.macAddress.equals("00:00:00:00:00:00")) {
- this.macAddress = details.macAddress;
- }
- if (details.serverCert != null) {
- this.serverCert = details.serverCert;
- }
- this.externalPort = details.externalPort;
- this.httpsPort = details.httpsPort;
- this.pairState = details.pairState;
- this.runningGameId = details.runningGameId;
- this.nvidiaServer = details.nvidiaServer;
- this.rawAppList = details.rawAppList;
- }
-
- @Override
- public String toString() {
- StringBuilder str = new StringBuilder();
- str.append("Name: ").append(name).append("\n");
- str.append("State: ").append(state).append("\n");
- str.append("Active Address: ").append(activeAddress).append("\n");
- str.append("UUID: ").append(uuid).append("\n");
- str.append("Local Address: ").append(localAddress).append("\n");
- str.append("Remote Address: ").append(remoteAddress).append("\n");
- str.append("IPv6 Address: ").append(ipv6Address).append("\n");
- str.append("Manual Address: ").append(manualAddress).append("\n");
- str.append("MAC Address: ").append(macAddress).append("\n");
- str.append("Pair State: ").append(pairState).append("\n");
- str.append("Running Game ID: ").append(runningGameId).append("\n");
- str.append("HTTPS Port: ").append(httpsPort).append("\n");
- return str.toString();
- }
-}
+package com.limelight.nvstream.http;
+
+import androidx.annotation.NonNull;
+
+import java.security.cert.X509Certificate;
+import java.util.List;
+import java.util.Objects;
+
+
+public class ComputerDetails {
+ public enum State {
+ ONLINE, OFFLINE, UNKNOWN
+ }
+
+ public static class AddressTuple {
+ public String address;
+ public int port;
+
+ public AddressTuple(String address, int port) {
+ if (address == null) {
+ throw new IllegalArgumentException("Address cannot be null");
+ }
+ if (port <= 0) {
+ throw new IllegalArgumentException("Invalid port");
+ }
+
+ // If this was an escaped IPv6 address, remove the brackets
+ if (address.startsWith("[") && address.endsWith("]")) {
+ address = address.substring(1, address.length() - 1);
+ }
+
+ this.address = address;
+ this.port = port;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(address, port);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (!(obj instanceof AddressTuple)) {
+ return false;
+ }
+
+ AddressTuple that = (AddressTuple) obj;
+ return address.equals(that.address) && port == that.port;
+ }
+
+ public String toString() {
+ if (address.contains(":")) {
+ // IPv6
+ return "[" + address + "]:" + port;
+ }
+ else {
+ // IPv4 and hostnames
+ return address + ":" + port;
+ }
+ }
+ }
+
+ // Persistent attributes
+ public String uuid;
+ public String name;
+ public AddressTuple localAddress;
+ public AddressTuple remoteAddress;
+ public AddressTuple manualAddress;
+ public AddressTuple ipv6Address;
+ public String macAddress;
+ public X509Certificate serverCert;
+
+ // Transient attributes
+ public State state;
+ public int permission = -1;
+ public AddressTuple activeAddress;
+ public int httpsPort;
+ public int externalPort;
+ public PairingManager.PairState pairState;
+ public int runningGameId;
+ public String rawAppList;
+ public boolean nvidiaServer;
+
+ // VDisplay info
+ public boolean vDisplaySupported = false;
+ public boolean vDisplayDriverReady = false;
+
+ // Server commands
+ public List serverCommands;
+
+ public ComputerDetails() {
+ // Use defaults
+ state = State.UNKNOWN;
+ }
+
+ public ComputerDetails(ComputerDetails details) {
+ // Copy details from the other computer
+ update(details);
+ }
+
+ public int guessExternalPort() {
+ if (externalPort != 0) {
+ return externalPort;
+ }
+ else if (remoteAddress != null) {
+ return remoteAddress.port;
+ }
+ else if (activeAddress != null) {
+ return activeAddress.port;
+ }
+ else if (ipv6Address != null) {
+ return ipv6Address.port;
+ }
+ else if (localAddress != null) {
+ return localAddress.port;
+ }
+ else {
+ return NvHTTP.DEFAULT_HTTP_PORT;
+ }
+ }
+
+ public void update(ComputerDetails details) {
+ this.state = details.state;
+ this.name = details.name;
+ this.uuid = details.uuid;
+ this.permission = details.permission;
+ if (details.activeAddress != null) {
+ this.activeAddress = details.activeAddress;
+ }
+ // We can get IPv4 loopback addresses with GS IPv6 Forwarder
+ if (details.localAddress != null && !details.localAddress.address.startsWith("127.")) {
+ this.localAddress = details.localAddress;
+ }
+ if (details.remoteAddress != null) {
+ this.remoteAddress = details.remoteAddress;
+ }
+ else if (this.remoteAddress != null && details.externalPort != 0) {
+ // If we have a remote address already (perhaps via STUN) but our updated details
+ // don't have a new one (because GFE doesn't send one), propagate the external
+ // port to the current remote address. We may have tried to guess it previously.
+ this.remoteAddress.port = details.externalPort;
+ }
+ if (details.manualAddress != null) {
+ this.manualAddress = details.manualAddress;
+ }
+ if (details.ipv6Address != null) {
+ this.ipv6Address = details.ipv6Address;
+ }
+ if (details.macAddress != null && !details.macAddress.equals("00:00:00:00:00:00")) {
+ this.macAddress = details.macAddress;
+ }
+ if (details.serverCert != null) {
+ this.serverCert = details.serverCert;
+ }
+ this.externalPort = details.externalPort;
+ this.httpsPort = details.httpsPort;
+ this.pairState = details.pairState;
+ this.runningGameId = details.runningGameId;
+ this.nvidiaServer = details.nvidiaServer;
+ this.rawAppList = details.rawAppList;
+
+ this.vDisplayDriverReady = details.vDisplayDriverReady;
+ this.vDisplaySupported = details.vDisplaySupported;
+
+ this.serverCommands = details.serverCommands;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ /*
+ * Permissions:
+ enum class PERM: uint32_t {
+ _reserved = 1,
+
+ _input = _reserved << 8, // Input permission group
+ input_controller = _input << 0, // Allow controller input
+ input_touch = _input << 1, // Allow touch input
+ input_pen = _input << 2, // Allow pen input
+ input_mouse = _input << 3, // Allow mouse input
+ input_kbd = _input << 4, // Allow keyboard input
+ _all_inputs = input_controller | input_touch | input_pen | input_mouse | input_kbd,
+
+ _operation = _input << 8, // Operation permission group
+ clipboard_set = _operation << 0, // Allow set clipboard from client
+ clipboard_read = _operation << 1, // Allow read clipboard from host
+ file_upload = _operation << 2, // Allow upload files to host
+ file_dwnload = _operation << 3, // Allow download files from host
+ server_cmd = _operation << 4, // Allow execute server cmd
+ _all_opeiations = clipboard_set | clipboard_read | file_upload | file_dwnload | server_cmd,
+
+ _action = _operation << 8, // Action permission group
+ list = _action << 0, // Allow list apps
+ view = _action << 1, // Allow view streams
+ launch = _action << 2, // Allow launch apps
+ _allow_view = view | launch, // Launch contains view permission
+ _all_actions = list | view | launch,
+
+ _default = view | list, // Default permissions for new clients
+ _no = 0, // No permissions are granted
+ _all = _all_inputs | _all_opeiations | _all_actions, // All current permissions
+ };
+ */
+
+ String permissionsStr = permission < 0 ? "N/A\n" : "0x" + Integer.toHexString(permission) + "\n" +
+ " - Controller Input: " + ((permission & 0x00000100) != 0) + "\n" +
+ " - Touch Input: " + ((permission & 0x00000200) != 0) + "\n" +
+ " - Pen Input: " + ((permission & 0x00000400) != 0) + "\n" +
+ " - Mouse Input: " + ((permission & 0x00000800) != 0) + "\n" +
+ " - Keyboard Input: " + ((permission & 0x00001000) != 0) + "\n" +
+ "\n" +
+// " - Set Clipboard: " + ((permission & 0x00010000) != 0) + "\n" +
+// " - Read Clipboard: " + ((permission & 0x00020000) != 0) + "\n" +
+// " - Upload Files: " + ((permission & 0x00040000) != 0) + "\n" +
+// " - Download Files: " + ((permission & 0x00080000) != 0) + "\n" +
+ " - Server Command: " + ((permission & 0x00100000) != 0) + "\n" +
+ "\n" +
+ " - List Apps: " + ((permission & 0x01000000) != 0) + "\n" +
+ " - View Streams: " + ((permission & (0x02000000 | 0x01000000)) != 0) + "\n" +
+ " - Launch Apps: " + ((permission & (0x04000000 | 0x02000000 | 0x01000000)) != 0) + "\n";
+
+ return "Name: " + name + "\n" +
+ "State: " + state + "\n" +
+ "Active Address: " + activeAddress + "\n" +
+ "UUID: " + uuid + "\n" +
+ "\nPermissions: " + permissionsStr + "\n" +
+ "Local Address: " + localAddress + "\n" +
+ "Remote Address: " + remoteAddress + "\n" +
+ "IPv6 Address: " + ipv6Address + "\n" +
+ "Manual Address: " + manualAddress + "\n" +
+ "MAC Address: " + macAddress + "\n" +
+ "Pair State: " + pairState + "\n" +
+ "Running Game ID: " + runningGameId + "\n" +
+ "HTTPS Port: " + httpsPort + "\n";
+ }
+}
diff --git a/app/src/main/java/com/limelight/nvstream/http/HostHttpResponseException.java b/app/src/main/java/com/limelight/nvstream/http/HostHttpResponseException.java
old mode 100644
new mode 100755
index 25a883347e..196b96b1b2
--- a/app/src/main/java/com/limelight/nvstream/http/HostHttpResponseException.java
+++ b/app/src/main/java/com/limelight/nvstream/http/HostHttpResponseException.java
@@ -1,28 +1,28 @@
-package com.limelight.nvstream.http;
-
-import java.io.IOException;
-
-public class HostHttpResponseException extends IOException {
- private static final long serialVersionUID = 1543508830807804222L;
-
- private int errorCode;
- private String errorMsg;
-
- public HostHttpResponseException(int errorCode, String errorMsg) {
- this.errorCode = errorCode;
- this.errorMsg = errorMsg;
- }
-
- public int getErrorCode() {
- return errorCode;
- }
-
- public String getErrorMessage() {
- return errorMsg;
- }
-
- @Override
- public String getMessage() {
- return "Host PC returned error: "+errorMsg+" (Error code: "+errorCode+")";
- }
-}
+package com.limelight.nvstream.http;
+
+import java.io.IOException;
+
+public class HostHttpResponseException extends IOException {
+ private static final long serialVersionUID = 1543508830807804222L;
+
+ private int errorCode;
+ private String errorMsg;
+
+ public HostHttpResponseException(int errorCode, String errorMsg) {
+ this.errorCode = errorCode;
+ this.errorMsg = errorMsg;
+ }
+
+ public int getErrorCode() {
+ return errorCode;
+ }
+
+ public String getErrorMessage() {
+ return errorMsg;
+ }
+
+ @Override
+ public String getMessage() {
+ return "Host PC returned error: "+errorMsg+" (Error code: "+errorCode+")";
+ }
+}
diff --git a/app/src/main/java/com/limelight/nvstream/http/LimelightCryptoProvider.java b/app/src/main/java/com/limelight/nvstream/http/LimelightCryptoProvider.java
old mode 100644
new mode 100755
index 1232f6673b..a75b83711b
--- a/app/src/main/java/com/limelight/nvstream/http/LimelightCryptoProvider.java
+++ b/app/src/main/java/com/limelight/nvstream/http/LimelightCryptoProvider.java
@@ -1,11 +1,11 @@
-package com.limelight.nvstream.http;
-
-import java.security.PrivateKey;
-import java.security.cert.X509Certificate;
-
-public interface LimelightCryptoProvider {
- X509Certificate getClientCertificate();
- PrivateKey getClientPrivateKey();
- byte[] getPemEncodedClientCertificate();
- String encodeBase64String(byte[] data);
-}
+package com.limelight.nvstream.http;
+
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+
+public interface LimelightCryptoProvider {
+ X509Certificate getClientCertificate();
+ PrivateKey getClientPrivateKey();
+ byte[] getPemEncodedClientCertificate();
+ String encodeBase64String(byte[] data);
+}
diff --git a/app/src/main/java/com/limelight/nvstream/http/NvApp.java b/app/src/main/java/com/limelight/nvstream/http/NvApp.java
old mode 100644
new mode 100755
index bb5a1072c7..fd3a307fe0
--- a/app/src/main/java/com/limelight/nvstream/http/NvApp.java
+++ b/app/src/main/java/com/limelight/nvstream/http/NvApp.java
@@ -1,70 +1,70 @@
-package com.limelight.nvstream.http;
-
-import com.limelight.LimeLog;
-
-public class NvApp {
- private String appName = "";
- private int appId;
- private boolean initialized;
- private boolean hdrSupported;
-
- public NvApp() {}
-
- public NvApp(String appName) {
- this.appName = appName;
- }
-
- public NvApp(String appName, int appId, boolean hdrSupported) {
- this.appName = appName;
- this.appId = appId;
- this.hdrSupported = hdrSupported;
- this.initialized = true;
- }
-
- public void setAppName(String appName) {
- this.appName = appName;
- }
-
- public void setAppId(String appId) {
- try {
- this.appId = Integer.parseInt(appId);
- this.initialized = true;
- } catch (NumberFormatException e) {
- LimeLog.warning("Malformed app ID: "+appId);
- }
- }
-
- public void setAppId(int appId) {
- this.appId = appId;
- this.initialized = true;
- }
-
- public void setHdrSupported(boolean hdrSupported) {
- this.hdrSupported = hdrSupported;
- }
-
- public String getAppName() {
- return this.appName;
- }
-
- public int getAppId() {
- return this.appId;
- }
-
- public boolean isHdrSupported() {
- return this.hdrSupported;
- }
-
- public boolean isInitialized() {
- return this.initialized;
- }
-
- @Override
- public String toString() {
- StringBuilder str = new StringBuilder();
- str.append("Name: ").append(appName).append("\n");
- str.append("HDR Supported: ").append(hdrSupported ? "Yes" : "Unknown").append("\n");
- str.append("ID: ").append(appId).append("\n");
- return str.toString();
- }
-}
+package com.limelight.nvstream.http;
+
+import com.limelight.LimeLog;
+
+public class NvApp {
+ private String appName = "";
+ private int appId;
+ private boolean initialized;
+ private boolean hdrSupported;
+
+ public NvApp() {}
+
+ public NvApp(String appName) {
+ this.appName = appName;
+ }
+
+ public NvApp(String appName, int appId, boolean hdrSupported) {
+ this.appName = appName;
+ this.appId = appId;
+ this.hdrSupported = hdrSupported;
+ this.initialized = true;
+ }
+
+ public void setAppName(String appName) {
+ this.appName = appName;
+ }
+
+ public void setAppId(String appId) {
+ try {
+ this.appId = Integer.parseInt(appId);
+ this.initialized = true;
+ } catch (NumberFormatException e) {
+ LimeLog.warning("Malformed app ID: "+appId);
+ }
+ }
+
+ public void setAppId(int appId) {
+ this.appId = appId;
+ this.initialized = true;
+ }
+
+ public void setHdrSupported(boolean hdrSupported) {
+ this.hdrSupported = hdrSupported;
+ }
+
+ public String getAppName() {
+ return this.appName;
+ }
+
+ public int getAppId() {
+ return this.appId;
+ }
+
+ public boolean isHdrSupported() {
+ return this.hdrSupported;
+ }
+
+ public boolean isInitialized() {
+ return this.initialized;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder str = new StringBuilder();
+ str.append("Name: ").append(appName).append("\n");
+ str.append("HDR Supported: ").append(hdrSupported ? "Yes" : "Unknown").append("\n");
+ str.append("ID: ").append(appId).append("\n");
+ return str.toString();
+ }
+}
diff --git a/app/src/main/java/com/limelight/nvstream/http/NvHTTP.java b/app/src/main/java/com/limelight/nvstream/http/NvHTTP.java
old mode 100644
new mode 100755
index 13dad8be9f..3373ef6ab8
--- a/app/src/main/java/com/limelight/nvstream/http/NvHTTP.java
+++ b/app/src/main/java/com/limelight/nvstream/http/NvHTTP.java
@@ -1,7 +1,5 @@
package com.limelight.nvstream.http;
-import android.os.Build;
-
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
@@ -22,7 +20,9 @@
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
+import java.util.ArrayList;
import java.util.LinkedList;
+import java.util.List;
import java.util.ListIterator;
import java.util.Stack;
import java.util.UUID;
@@ -35,8 +35,6 @@
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.SSLPeerUnverifiedException;
import javax.net.ssl.SSLSession;
-import javax.net.ssl.SSLSocket;
-import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509KeyManager;
@@ -51,17 +49,21 @@
import com.limelight.nvstream.ConnectionContext;
import com.limelight.nvstream.http.PairingManager.PairState;
import com.limelight.nvstream.jni.MoonBridge;
+import com.limelight.utils.DeviceUtils;
import okhttp3.ConnectionPool;
import okhttp3.HttpUrl;
+import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
+import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
public class NvHTTP {
private String uniqueId;
+ private String deviceName;
private PairingManager pm;
private static final int DEFAULT_HTTPS_PORT = 47984;
@@ -200,9 +202,9 @@ public HttpUrl getHttpsUrl(boolean likelyOnline) throws IOException {
}
public NvHTTP(ComputerDetails.AddressTuple address, int httpsPort, String uniqueId, X509Certificate serverCert, LimelightCryptoProvider cryptoProvider) throws IOException {
- // Use the same UID for all Moonlight clients so we can quit games
- // started by other Moonlight clients.
- this.uniqueId = "0123456789ABCDEF";
+ this.uniqueId = uniqueId;
+
+ this.deviceName = DeviceUtils.getModel();
this.serverCert = serverCert;
@@ -279,6 +281,45 @@ static String getXmlString(Reader r, String tagname, boolean throwIfMissing) thr
static String getXmlString(String str, String tagname, boolean throwIfMissing) throws XmlPullParserException, IOException {
return getXmlString(new StringReader(str), tagname, throwIfMissing);
}
+
+ static List getXmlArray(Reader r, String tagname, boolean throwIfMissing) throws XmlPullParserException, IOException {
+ XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
+ factory.setNamespaceAware(true);
+ XmlPullParser xpp = factory.newPullParser();
+
+ xpp.setInput(r);
+ int eventType = xpp.getEventType();
+ Stack currentTag = new Stack<>();
+
+ List array = new ArrayList<>();
+
+ while (eventType != XmlPullParser.END_DOCUMENT) {
+ switch (eventType) {
+ case (XmlPullParser.START_TAG):
+ currentTag.push(xpp.getName());
+ break;
+ case (XmlPullParser.END_TAG):
+ currentTag.pop();
+ break;
+ case (XmlPullParser.TEXT):
+ if (currentTag.peek().equals(tagname)) {
+ array.add(xpp.getText());
+ }
+ break;
+ }
+ eventType = xpp.next();
+ }
+
+ if (throwIfMissing && array.isEmpty()) {
+ throw new XmlPullParserException("Missing mandatory field in host response: "+tagname);
+ }
+
+ return array;
+ }
+
+ static List getXmlArray(String str, String tagname, boolean throwIfMissing) throws XmlPullParserException, IOException {
+ return getXmlArray(new StringReader(str), tagname, throwIfMissing);
+ }
private static void verifyResponseStatus(XmlPullParser xpp) throws HostHttpResponseException {
// We use Long.parseLong() because in rare cases GFE can send back a status code of
@@ -368,6 +409,15 @@ public ComputerDetails getComputerDetails(String serverInfo) throws IOException,
// UUID is mandatory to determine which machine is responding
details.uuid = getXmlString(serverInfo, "uniqueid", true);
+ String permStr = getXmlString(serverInfo, "Permission", false);
+ if (permStr != null) {
+ try {
+ details.permission = Integer.parseInt(permStr);
+ } catch (Exception ignored) {
+ details.permission = -1;
+ }
+ }
+
details.httpsPort = getHttpsPort(serverInfo);
details.macAddress = getXmlString(serverInfo, "mac", false);
@@ -379,6 +429,13 @@ public ComputerDetails getComputerDetails(String serverInfo) throws IOException,
details.externalPort = getExternalPort(serverInfo);
details.remoteAddress = makeTuple(getXmlString(serverInfo, "ExternalIP", false), details.externalPort);
+ details.vDisplaySupported = getServerSupportsVDisplay(serverInfo);
+ if (details.vDisplaySupported) {
+ details.vDisplayDriverReady = getServerVDisplayDriverReady(serverInfo);
+ }
+
+ details.serverCommands = getServerCmds(serverInfo);
+
details.pairState = getPairState(serverInfo);
details.runningGameId = getCurrentGame(serverInfo);
@@ -411,24 +468,25 @@ private OkHttpClient performAndroidTlsHack(OkHttpClient client) {
private HttpUrl getCompleteUrl(HttpUrl baseUrl, String path, String query) {
return baseUrl.newBuilder()
- .addPathSegment(path)
+ .addPathSegments(path)
.query(query)
+ .addQueryParameter("devicename", deviceName)
.addQueryParameter("uniqueid", uniqueId)
.addQueryParameter("uuid", UUID.randomUUID().toString())
.build();
}
- private ResponseBody openHttpConnection(OkHttpClient client, HttpUrl baseUrl, String path) throws IOException {
- return openHttpConnection(client, baseUrl, path, null);
- }
-
// Read timeout should be enabled for any HTTP query that requires no outside action
// on the GFE server. Examples of queries that DO require outside action are launch, resume, and quit.
// The initial pair query does require outside action (user entering a PIN) but subsequent pairing
// queries do not.
- private ResponseBody openHttpConnection(OkHttpClient client, HttpUrl baseUrl, String path, String query) throws IOException {
+ private ResponseBody openHttpConnection(OkHttpClient client, HttpUrl baseUrl, String path, String query, RequestBody requestBody) throws IOException {
HttpUrl completeUrl = getCompleteUrl(baseUrl, path, query);
- Request request = new Request.Builder().url(completeUrl).get().build();
+ Request.Builder _builder = new Request.Builder().url(completeUrl);
+ Request request;
+ if (requestBody == null) request = _builder.get().build();
+ else request = _builder.post(requestBody).build();
+
Response response = performAndroidTlsHack(client).newCall(request).execute();
ResponseBody body = response.body();
@@ -451,12 +509,16 @@ private ResponseBody openHttpConnection(OkHttpClient client, HttpUrl baseUrl, St
}
private String openHttpConnectionToString(OkHttpClient client, HttpUrl baseUrl, String path) throws IOException {
- return openHttpConnectionToString(client, baseUrl, path, null);
+ return openHttpConnectionToString(client, baseUrl, path, null, null);
}
private String openHttpConnectionToString(OkHttpClient client, HttpUrl baseUrl, String path, String query) throws IOException {
+ return openHttpConnectionToString(client, baseUrl, path, query, null);
+ }
+
+ private String openHttpConnectionToString(OkHttpClient client, HttpUrl baseUrl, String path, String query, RequestBody requestBody) throws IOException {
try {
- ResponseBody resp = openHttpConnection(client, baseUrl, path, query);
+ ResponseBody resp = openHttpConnection(client, baseUrl, path, query, requestBody);
String respString = resp.string();
resp.close();
@@ -480,6 +542,28 @@ public String getServerVersion(String serverInfo) throws XmlPullParserException,
return getXmlString(serverInfo, "appversion", true);
}
+ public boolean getServerSupportsVDisplay(String serverInfo) throws XmlPullParserException, IOException {
+ String supportVdisplay = getXmlString(serverInfo, "VirtualDisplayCapable", false);
+ if (supportVdisplay == null) {
+ return false;
+ }
+
+ return supportVdisplay.equals("true");
+ }
+
+ public boolean getServerVDisplayDriverReady(String serverInfo) throws XmlPullParserException, IOException {
+ String driverReady = getXmlString(serverInfo, "VirtualDisplayDriverReady", false);
+ if (driverReady == null) {
+ return false;
+ }
+
+ return driverReady.equals("true");
+ }
+
+ public List getServerCmds(String serverInfo) throws XmlPullParserException, IOException {
+ return getXmlArray(serverInfo, "ServerCommand", false);
+ }
+
public PairingManager.PairState getPairState() throws IOException, XmlPullParserException {
return getPairState(getServerInfo(true));
}
@@ -697,7 +781,7 @@ public LinkedList getAppList() throws HostHttpResponseException, IOExcept
return getAppListByReader(new StringReader(getAppListRaw()));
}
else {
- try (final ResponseBody resp = openHttpConnection(httpClientLongConnectTimeout, getHttpsUrl(true), "applist")) {
+ try (final ResponseBody resp = openHttpConnection(httpClientLongConnectTimeout, getHttpsUrl(true), "applist", null, null)) {
return getAppListByReader(new InputStreamReader(resp.byteStream()));
}
}
@@ -705,12 +789,12 @@ public LinkedList getAppList() throws HostHttpResponseException, IOExcept
String executePairingCommand(String additionalArguments, boolean enableReadTimeout) throws HostHttpResponseException, IOException {
return openHttpConnectionToString(enableReadTimeout ? httpClientLongConnectTimeout : httpClientLongConnectNoReadTimeout,
- baseUrlHttp, "pair", "devicename=roth&updateState=1&" + additionalArguments);
+ baseUrlHttp, "pair", "updateState=1&" + additionalArguments);
}
String executePairingChallenge() throws HostHttpResponseException, IOException {
return openHttpConnectionToString(httpClientLongConnectTimeout, getHttpsUrl(true),
- "pair", "devicename=roth&updateState=1&phrase=pairchallenge");
+ "pair", "updateState=1&phrase=pairchallenge");
}
public void unpair() throws IOException {
@@ -718,7 +802,7 @@ public void unpair() throws IOException {
}
public InputStream getBoxArt(NvApp app) throws IOException {
- ResponseBody resp = openHttpConnection(httpClientLongConnectTimeout, getHttpsUrl(true), "appasset", "appid=" + app.getAppId() + "&AssetType=2&AssetIdx=0");
+ ResponseBody resp = openHttpConnection(httpClientLongConnectTimeout, getHttpsUrl(true), "appasset", "appid=" + app.getAppId() + "&AssetType=2&AssetIdx=0", null);
return resp.byteStream();
}
@@ -758,9 +842,15 @@ public boolean launchApp(ConnectionContext context, String verb, int appId, bool
// so force it to 0 to ensure the correct resolution is set. We
// used to use 60 here but that locked the frame rate to 60 FPS
// on GFE 3.20.3.
- int fps = context.isNvidiaServerSoftware && context.streamConfig.getLaunchRefreshRate() > 60 ?
+ float fps = context.isNvidiaServerSoftware && context.streamConfig.getLaunchRefreshRate() > 60 ?
0 : context.streamConfig.getLaunchRefreshRate();
+ int fpsInt = (int)fps;
+
+ if (fpsInt != fps) {
+ fpsInt = (int)(fps * 1000);
+ }
+
boolean enableSops = context.streamConfig.getSops();
if (context.isNvidiaServerSoftware) {
// Using an unsupported resolution (not 720p, 1080p, or 4K) causes
@@ -778,11 +868,13 @@ public boolean launchApp(ConnectionContext context, String verb, int appId, bool
String xmlStr = openHttpConnectionToString(httpClientLongConnectNoReadTimeout, getHttpsUrl(true), verb,
"appid=" + appId +
- "&mode=" + context.negotiatedWidth + "x" + context.negotiatedHeight + "x" + fps +
+ "&mode=" + context.negotiatedWidth + "x" + context.negotiatedHeight + "x" + fpsInt +
+ "&scaleFactor=" + context.streamConfig.getResolutionScaleFactor() +
"&additionalStates=1&sops=" + (enableSops ? 1 : 0) +
"&rikey="+bytesToHex(context.riKey.getEncoded()) +
"&rikeyid="+context.riKeyId +
(!enableHdr ? "" : "&hdrMode=1&clientHdrCapVersion=0&clientHdrCapSupportedFlagsInUint32=0&clientHdrCapMetaDataId=NV_STATIC_METADATA_TYPE_1&clientHdrCapDisplayData=0x0x0x0x0x0x0x0x0x0x0") +
+ "&virtualDisplay=" + (context.streamConfig.getVirtualDisplay() ? 1 : 0) +
"&localAudioPlayMode=" + (context.streamConfig.getPlayLocalAudio() ? 1 : 0) +
"&surroundAudioInfo=" + context.streamConfig.getAudioConfiguration().getSurroundAudioInfo() +
"&remoteControllersBitmap=" + context.streamConfig.getAttachedGamepadMask() +
@@ -816,4 +908,21 @@ public boolean quitApp() throws IOException, XmlPullParserException {
return true;
}
+
+ public String getClipboard() throws IOException {
+ // Add type for future-proof
+ // Might return arbitrary type from host if not set
+ return openHttpConnectionToString(httpClientLongConnectTimeout, getHttpsUrl(true), "actions/clipboard", "type=text");
+ }
+
+ // We currently only support plain text
+ public Boolean sendClipboard(String content) throws IOException {
+ String resp = openHttpConnectionToString(httpClientLongConnectTimeout, getHttpsUrl(true), "actions/clipboard", "type=text", RequestBody.create(content, MediaType.parse("text/plain")));
+ // For handling the 200ed 404 from Sunshine
+ if (resp.isEmpty()) {
+ return true;
+ } else {
+ return false;
+ }
+ }
}
diff --git a/app/src/main/java/com/limelight/nvstream/http/PairingManager.java b/app/src/main/java/com/limelight/nvstream/http/PairingManager.java
old mode 100644
new mode 100755
index 65994b61ca..ee5c9aec4b
--- a/app/src/main/java/com/limelight/nvstream/http/PairingManager.java
+++ b/app/src/main/java/com/limelight/nvstream/http/PairingManager.java
@@ -1,334 +1,356 @@
-package com.limelight.nvstream.http;
-
-import org.bouncycastle.crypto.BlockCipher;
-import org.bouncycastle.crypto.engines.AESLightEngine;
-import org.bouncycastle.crypto.params.KeyParameter;
-
-import org.xmlpull.v1.XmlPullParserException;
-
-import com.limelight.LimeLog;
-
-import java.security.cert.Certificate;
-import java.io.*;
-import java.security.*;
-import java.security.cert.*;
-import java.util.Arrays;
-import java.util.Locale;
-
-public class PairingManager {
-
- private NvHTTP http;
-
- private PrivateKey pk;
- private X509Certificate cert;
- private byte[] pemCertBytes;
-
- private X509Certificate serverCert;
-
- public enum PairState {
- NOT_PAIRED,
- PAIRED,
- PIN_WRONG,
- FAILED,
- ALREADY_IN_PROGRESS
- }
-
- public PairingManager(NvHTTP http, LimelightCryptoProvider cryptoProvider) {
- this.http = http;
- this.cert = cryptoProvider.getClientCertificate();
- this.pemCertBytes = cryptoProvider.getPemEncodedClientCertificate();
- this.pk = cryptoProvider.getClientPrivateKey();
- }
-
- final private static char[] hexArray = "0123456789ABCDEF".toCharArray();
- private static String bytesToHex(byte[] bytes) {
- char[] hexChars = new char[bytes.length * 2];
- for ( int j = 0; j < bytes.length; j++ ) {
- int v = bytes[j] & 0xFF;
- hexChars[j * 2] = hexArray[v >>> 4];
- hexChars[j * 2 + 1] = hexArray[v & 0x0F];
- }
- return new String(hexChars);
- }
-
- private static byte[] hexToBytes(String s) {
- int len = s.length();
- if (len % 2 != 0) {
- throw new IllegalArgumentException("Illegal string length: "+len);
- }
-
- byte[] data = new byte[len / 2];
- for (int i = 0; i < len; i += 2) {
- data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4)
- + Character.digit(s.charAt(i+1), 16));
- }
- return data;
- }
-
- private X509Certificate extractPlainCert(String text) throws XmlPullParserException, IOException
- {
- // Plaincert may be null if another client is already trying to pair
- String certText = NvHTTP.getXmlString(text, "plaincert", false);
- if (certText != null) {
- byte[] certBytes = hexToBytes(certText);
-
- try {
- CertificateFactory cf = CertificateFactory.getInstance("X.509");
- return (X509Certificate)cf.generateCertificate(new ByteArrayInputStream(certBytes));
- } catch (CertificateException e) {
- e.printStackTrace();
- throw new RuntimeException(e);
- }
- }
- else {
- return null;
- }
- }
-
- private byte[] generateRandomBytes(int length)
- {
- byte[] rand = new byte[length];
- new SecureRandom().nextBytes(rand);
- return rand;
- }
-
- private static byte[] saltPin(byte[] salt, String pin) throws UnsupportedEncodingException {
- byte[] saltedPin = new byte[salt.length + pin.length()];
- System.arraycopy(salt, 0, saltedPin, 0, salt.length);
- System.arraycopy(pin.getBytes("UTF-8"), 0, saltedPin, salt.length, pin.length());
- return saltedPin;
- }
-
- private static Signature getSha256SignatureInstanceForKey(Key key) throws NoSuchAlgorithmException {
- switch (key.getAlgorithm()) {
- case "RSA":
- return Signature.getInstance("SHA256withRSA");
- case "EC":
- return Signature.getInstance("SHA256withECDSA");
- default:
- throw new NoSuchAlgorithmException("Unhandled key algorithm: " + key.getAlgorithm());
- }
- }
-
- private static boolean verifySignature(byte[] data, byte[] signature, Certificate cert) {
- try {
- Signature sig = PairingManager.getSha256SignatureInstanceForKey(cert.getPublicKey());
- sig.initVerify(cert.getPublicKey());
- sig.update(data);
- return sig.verify(signature);
- } catch (NoSuchAlgorithmException | SignatureException | InvalidKeyException e) {
- e.printStackTrace();
- throw new RuntimeException(e);
- }
- }
-
- private static byte[] signData(byte[] data, PrivateKey key) {
- try {
- Signature sig = PairingManager.getSha256SignatureInstanceForKey(key);
- sig.initSign(key);
- sig.update(data);
- return sig.sign();
- } catch (NoSuchAlgorithmException | SignatureException | InvalidKeyException e) {
- e.printStackTrace();
- throw new RuntimeException(e);
- }
- }
-
- private static byte[] performBlockCipher(BlockCipher blockCipher, byte[] input) {
- int blockSize = blockCipher.getBlockSize();
- int blockRoundedSize = (input.length + (blockSize - 1)) & ~(blockSize - 1);
-
- byte[] blockRoundedInputData = Arrays.copyOf(input, blockRoundedSize);
- byte[] blockRoundedOutputData = new byte[blockRoundedSize];
-
- for (int offset = 0; offset < blockRoundedSize; offset += blockSize) {
- blockCipher.processBlock(blockRoundedInputData, offset, blockRoundedOutputData, offset);
- }
-
- return blockRoundedOutputData;
- }
-
- private static byte[] decryptAes(byte[] encryptedData, byte[] aesKey) {
- BlockCipher aesEngine = new AESLightEngine();
- aesEngine.init(false, new KeyParameter(aesKey));
- return performBlockCipher(aesEngine, encryptedData);
- }
-
- private static byte[] encryptAes(byte[] plaintextData, byte[] aesKey) {
- BlockCipher aesEngine = new AESLightEngine();
- aesEngine.init(true, new KeyParameter(aesKey));
- return performBlockCipher(aesEngine, plaintextData);
- }
-
- private static byte[] generateAesKey(PairingHashAlgorithm hashAlgo, byte[] keyData) {
- return Arrays.copyOf(hashAlgo.hashData(keyData), 16);
- }
-
- private static byte[] concatBytes(byte[] a, byte[] b) {
- byte[] c = new byte[a.length + b.length];
- System.arraycopy(a, 0, c, 0, a.length);
- System.arraycopy(b, 0, c, a.length, b.length);
- return c;
- }
-
- public static String generatePinString() {
- SecureRandom r = new SecureRandom();
- return String.format((Locale)null, "%d%d%d%d",
- r.nextInt(10), r.nextInt(10),
- r.nextInt(10), r.nextInt(10));
- }
-
- public X509Certificate getPairedCert() {
- return serverCert;
- }
-
- public PairState pair(String serverInfo, String pin) throws IOException, XmlPullParserException {
- PairingHashAlgorithm hashAlgo;
-
- int serverMajorVersion = http.getServerMajorVersion(serverInfo);
- LimeLog.info("Pairing with server generation: "+serverMajorVersion);
- if (serverMajorVersion >= 7) {
- // Gen 7+ uses SHA-256 hashing
- hashAlgo = new Sha256PairingHash();
- }
- else {
- // Prior to Gen 7, SHA-1 is used
- hashAlgo = new Sha1PairingHash();
- }
-
- // Generate a salt for hashing the PIN
- byte[] salt = generateRandomBytes(16);
-
- // Combine the salt and pin, then create an AES key from them
- byte[] aesKey = generateAesKey(hashAlgo, saltPin(salt, pin));
-
- // Send the salt and get the server cert. This doesn't have a read timeout
- // because the user must enter the PIN before the server responds
- String getCert = http.executePairingCommand("phrase=getservercert&salt="+
- bytesToHex(salt)+"&clientcert="+bytesToHex(pemCertBytes),
- false);
- if (!NvHTTP.getXmlString(getCert, "paired", true).equals("1")) {
- return PairState.FAILED;
- }
-
- // Save this cert for retrieval later
- serverCert = extractPlainCert(getCert);
- if (serverCert == null) {
- // Attempting to pair while another device is pairing will cause GFE
- // to give an empty cert in the response.
- http.unpair();
- return PairState.ALREADY_IN_PROGRESS;
- }
-
- // Require this cert for TLS to this host
- http.setServerCert(serverCert);
-
- // Generate a random challenge and encrypt it with our AES key
- byte[] randomChallenge = generateRandomBytes(16);
- byte[] encryptedChallenge = encryptAes(randomChallenge, aesKey);
-
- // Send the encrypted challenge to the server
- String challengeResp = http.executePairingCommand("clientchallenge="+bytesToHex(encryptedChallenge), true);
- if (!NvHTTP.getXmlString(challengeResp, "paired", true).equals("1")) {
- http.unpair();
- return PairState.FAILED;
- }
-
- // Decode the server's response and subsequent challenge
- byte[] encServerChallengeResponse = hexToBytes(NvHTTP.getXmlString(challengeResp, "challengeresponse", true));
- byte[] decServerChallengeResponse = decryptAes(encServerChallengeResponse, aesKey);
-
- byte[] serverResponse = Arrays.copyOfRange(decServerChallengeResponse, 0, hashAlgo.getHashLength());
- byte[] serverChallenge = Arrays.copyOfRange(decServerChallengeResponse, hashAlgo.getHashLength(), hashAlgo.getHashLength() + 16);
-
- // Using another 16 bytes secret, compute a challenge response hash using the secret, our cert sig, and the challenge
- byte[] clientSecret = generateRandomBytes(16);
- byte[] challengeRespHash = hashAlgo.hashData(concatBytes(concatBytes(serverChallenge, cert.getSignature()), clientSecret));
- byte[] challengeRespEncrypted = encryptAes(challengeRespHash, aesKey);
- String secretResp = http.executePairingCommand("serverchallengeresp="+bytesToHex(challengeRespEncrypted), true);
- if (!NvHTTP.getXmlString(secretResp, "paired", true).equals("1")) {
- http.unpair();
- return PairState.FAILED;
- }
-
- // Get the server's signed secret
- byte[] serverSecretResp = hexToBytes(NvHTTP.getXmlString(secretResp, "pairingsecret", true));
- byte[] serverSecret = Arrays.copyOfRange(serverSecretResp, 0, 16);
- byte[] serverSignature = Arrays.copyOfRange(serverSecretResp, 16, serverSecretResp.length);
-
- // Ensure the authenticity of the data
- if (!verifySignature(serverSecret, serverSignature, serverCert)) {
- // Cancel the pairing process
- http.unpair();
-
- // Looks like a MITM
- return PairState.FAILED;
- }
-
- // Ensure the server challenge matched what we expected (aka the PIN was correct)
- byte[] serverChallengeRespHash = hashAlgo.hashData(concatBytes(concatBytes(randomChallenge, serverCert.getSignature()), serverSecret));
- if (!Arrays.equals(serverChallengeRespHash, serverResponse)) {
- // Cancel the pairing process
- http.unpair();
-
- // Probably got the wrong PIN
- return PairState.PIN_WRONG;
- }
-
- // Send the server our signed secret
- byte[] clientPairingSecret = concatBytes(clientSecret, signData(clientSecret, pk));
- String clientSecretResp = http.executePairingCommand("clientpairingsecret="+bytesToHex(clientPairingSecret), true);
- if (!NvHTTP.getXmlString(clientSecretResp, "paired", true).equals("1")) {
- http.unpair();
- return PairState.FAILED;
- }
-
- // Do the initial challenge (seems necessary for us to show as paired)
- String pairChallenge = http.executePairingChallenge();
- if (!NvHTTP.getXmlString(pairChallenge, "paired", true).equals("1")) {
- http.unpair();
- return PairState.FAILED;
- }
-
- return PairState.PAIRED;
- }
-
- private interface PairingHashAlgorithm {
- int getHashLength();
- byte[] hashData(byte[] data);
- }
-
- private static class Sha1PairingHash implements PairingHashAlgorithm {
- public int getHashLength() {
- return 20;
- }
-
- public byte[] hashData(byte[] data) {
- try {
- MessageDigest md = MessageDigest.getInstance("SHA-1");
- return md.digest(data);
- }
- catch (NoSuchAlgorithmException e) {
- e.printStackTrace();
- throw new RuntimeException(e);
- }
- }
- }
-
- private static class Sha256PairingHash implements PairingHashAlgorithm {
- public int getHashLength() {
- return 32;
- }
-
- public byte[] hashData(byte[] data) {
- try {
- MessageDigest md = MessageDigest.getInstance("SHA-256");
- return md.digest(data);
- }
- catch (NoSuchAlgorithmException e) {
- e.printStackTrace();
- throw new RuntimeException(e);
- }
- }
- }
-}
+package com.limelight.nvstream.http;
+
+import android.widget.Toast;
+
+import org.bouncycastle.crypto.BlockCipher;
+import org.bouncycastle.crypto.engines.AESLightEngine;
+import org.bouncycastle.crypto.params.KeyParameter;
+
+import org.xmlpull.v1.XmlPullParserException;
+
+import com.limelight.LimeLog;
+
+import java.security.cert.Certificate;
+import java.io.*;
+import java.security.*;
+import java.security.cert.*;
+import java.util.Arrays;
+import java.util.Locale;
+
+public class PairingManager {
+
+ private NvHTTP http;
+
+ private PrivateKey pk;
+ private X509Certificate cert;
+ private byte[] pemCertBytes;
+
+ private X509Certificate serverCert;
+
+ public enum PairState {
+ NOT_PAIRED,
+ PAIRED,
+ PIN_WRONG,
+ FAILED,
+ ALREADY_IN_PROGRESS
+ }
+
+ public PairingManager(NvHTTP http, LimelightCryptoProvider cryptoProvider) {
+ this.http = http;
+ this.cert = cryptoProvider.getClientCertificate();
+ this.pemCertBytes = cryptoProvider.getPemEncodedClientCertificate();
+ this.pk = cryptoProvider.getClientPrivateKey();
+ }
+
+ final private static char[] hexArray = "0123456789ABCDEF".toCharArray();
+ private static String bytesToHex(byte[] bytes) {
+ char[] hexChars = new char[bytes.length * 2];
+ for ( int j = 0; j < bytes.length; j++ ) {
+ int v = bytes[j] & 0xFF;
+ hexChars[j * 2] = hexArray[v >>> 4];
+ hexChars[j * 2 + 1] = hexArray[v & 0x0F];
+ }
+ return new String(hexChars);
+ }
+
+ private static byte[] hexToBytes(String s) {
+ int len = s.length();
+ if (len % 2 != 0) {
+ throw new IllegalArgumentException("Illegal string length: "+len);
+ }
+
+ byte[] data = new byte[len / 2];
+ for (int i = 0; i < len; i += 2) {
+ data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4)
+ + Character.digit(s.charAt(i+1), 16));
+ }
+ return data;
+ }
+
+ private X509Certificate extractPlainCert(String text) throws XmlPullParserException, IOException
+ {
+ // Plaincert may be null if another client is already trying to pair
+ String certText = NvHTTP.getXmlString(text, "plaincert", false);
+ if (certText != null) {
+ byte[] certBytes = hexToBytes(certText);
+
+ try {
+ CertificateFactory cf = CertificateFactory.getInstance("X.509");
+ return (X509Certificate)cf.generateCertificate(new ByteArrayInputStream(certBytes));
+ } catch (CertificateException e) {
+ e.printStackTrace();
+ throw new RuntimeException(e);
+ }
+ }
+ else {
+ return null;
+ }
+ }
+
+ private byte[] generateRandomBytes(int length)
+ {
+ byte[] rand = new byte[length];
+ new SecureRandom().nextBytes(rand);
+ return rand;
+ }
+
+ private static byte[] saltPin(byte[] salt, String pin) throws UnsupportedEncodingException {
+ byte[] saltedPin = new byte[salt.length + pin.length()];
+ System.arraycopy(salt, 0, saltedPin, 0, salt.length);
+ System.arraycopy(pin.getBytes("UTF-8"), 0, saltedPin, salt.length, pin.length());
+ return saltedPin;
+ }
+
+ private static Signature getSha256SignatureInstanceForKey(Key key) throws NoSuchAlgorithmException {
+ switch (key.getAlgorithm()) {
+ case "RSA":
+ return Signature.getInstance("SHA256withRSA");
+ case "EC":
+ return Signature.getInstance("SHA256withECDSA");
+ default:
+ throw new NoSuchAlgorithmException("Unhandled key algorithm: " + key.getAlgorithm());
+ }
+ }
+
+ private static boolean verifySignature(byte[] data, byte[] signature, Certificate cert) {
+ try {
+ Signature sig = PairingManager.getSha256SignatureInstanceForKey(cert.getPublicKey());
+ sig.initVerify(cert.getPublicKey());
+ sig.update(data);
+ return sig.verify(signature);
+ } catch (NoSuchAlgorithmException | SignatureException | InvalidKeyException e) {
+ e.printStackTrace();
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static byte[] signData(byte[] data, PrivateKey key) {
+ try {
+ Signature sig = PairingManager.getSha256SignatureInstanceForKey(key);
+ sig.initSign(key);
+ sig.update(data);
+ return sig.sign();
+ } catch (NoSuchAlgorithmException | SignatureException | InvalidKeyException e) {
+ e.printStackTrace();
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static byte[] performBlockCipher(BlockCipher blockCipher, byte[] input) {
+ int blockSize = blockCipher.getBlockSize();
+ int blockRoundedSize = (input.length + (blockSize - 1)) & ~(blockSize - 1);
+
+ byte[] blockRoundedInputData = Arrays.copyOf(input, blockRoundedSize);
+ byte[] blockRoundedOutputData = new byte[blockRoundedSize];
+
+ for (int offset = 0; offset < blockRoundedSize; offset += blockSize) {
+ blockCipher.processBlock(blockRoundedInputData, offset, blockRoundedOutputData, offset);
+ }
+
+ return blockRoundedOutputData;
+ }
+
+ private static byte[] decryptAes(byte[] encryptedData, byte[] aesKey) {
+ BlockCipher aesEngine = new AESLightEngine();
+ aesEngine.init(false, new KeyParameter(aesKey));
+ return performBlockCipher(aesEngine, encryptedData);
+ }
+
+ private static byte[] encryptAes(byte[] plaintextData, byte[] aesKey) {
+ BlockCipher aesEngine = new AESLightEngine();
+ aesEngine.init(true, new KeyParameter(aesKey));
+ return performBlockCipher(aesEngine, plaintextData);
+ }
+
+ private static byte[] generateAesKey(PairingHashAlgorithm hashAlgo, byte[] keyData) {
+ return Arrays.copyOf(hashAlgo.hashData(keyData), 16);
+ }
+
+ private static byte[] concatBytes(byte[] a, byte[] b) {
+ byte[] c = new byte[a.length + b.length];
+ System.arraycopy(a, 0, c, 0, a.length);
+ System.arraycopy(b, 0, c, a.length, b.length);
+ return c;
+ }
+
+ public static String generatePinString() {
+ SecureRandom r = new SecureRandom();
+ return String.format((Locale)null, "%d%d%d%d",
+ r.nextInt(10), r.nextInt(10),
+ r.nextInt(10), r.nextInt(10));
+ }
+
+ public X509Certificate getPairedCert() {
+ return serverCert;
+ }
+
+ public PairState pair(String serverInfo, String pin, String passphrase) throws IOException, XmlPullParserException {
+ PairingHashAlgorithm hashAlgo;
+
+ int serverMajorVersion = http.getServerMajorVersion(serverInfo);
+ LimeLog.info("Pairing with server generation: "+serverMajorVersion);
+ if (serverMajorVersion >= 7) {
+ // Gen 7+ uses SHA-256 hashing
+ hashAlgo = new Sha256PairingHash();
+ }
+ else {
+ // Prior to Gen 7, SHA-1 is used
+ hashAlgo = new Sha1PairingHash();
+ }
+
+ // Generate a salt for hashing the PIN
+ byte[] salt = generateRandomBytes(16);
+
+ // Combine the salt and pin, then create an AES key from them
+ byte[] aesKey = generateAesKey(hashAlgo, saltPin(salt, pin));
+
+ String saltStr = bytesToHex(salt);
+
+ String pairingArguments = "phrase=getservercert&salt="+
+ saltStr+"&clientcert="+bytesToHex(pemCertBytes);
+
+ if (passphrase != null) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ String plainText = pin + saltStr + passphrase;
+ byte[] hash = digest.digest(plainText.getBytes());
+
+ StringBuilder hexString = new StringBuilder();
+ for (byte b : hash) {
+ hexString.append(String.format("%02X", b));
+ }
+
+ pairingArguments += "&otpauth=" + hexString;
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ // Send the salt and get the server cert. This doesn't have a read timeout
+ // because the user must enter the PIN before the server responds
+ String getCert = http.executePairingCommand(pairingArguments, false);
+ if (!NvHTTP.getXmlString(getCert, "paired", true).equals("1")) {
+ return PairState.FAILED;
+ }
+
+ // Save this cert for retrieval later
+ serverCert = extractPlainCert(getCert);
+ if (serverCert == null) {
+ // Attempting to pair while another device is pairing will cause GFE
+ // to give an empty cert in the response.
+ http.unpair();
+ return PairState.ALREADY_IN_PROGRESS;
+ }
+
+ // Require this cert for TLS to this host
+ http.setServerCert(serverCert);
+
+ // Generate a random challenge and encrypt it with our AES key
+ byte[] randomChallenge = generateRandomBytes(16);
+ byte[] encryptedChallenge = encryptAes(randomChallenge, aesKey);
+
+ // Send the encrypted challenge to the server
+ String challengeResp = http.executePairingCommand("clientchallenge="+bytesToHex(encryptedChallenge), true);
+ if (!NvHTTP.getXmlString(challengeResp, "paired", true).equals("1")) {
+ http.unpair();
+ return PairState.FAILED;
+ }
+
+ // Decode the server's response and subsequent challenge
+ byte[] encServerChallengeResponse = hexToBytes(NvHTTP.getXmlString(challengeResp, "challengeresponse", true));
+ byte[] decServerChallengeResponse = decryptAes(encServerChallengeResponse, aesKey);
+
+ byte[] serverResponse = Arrays.copyOfRange(decServerChallengeResponse, 0, hashAlgo.getHashLength());
+ byte[] serverChallenge = Arrays.copyOfRange(decServerChallengeResponse, hashAlgo.getHashLength(), hashAlgo.getHashLength() + 16);
+
+ // Using another 16 bytes secret, compute a challenge response hash using the secret, our cert sig, and the challenge
+ byte[] clientSecret = generateRandomBytes(16);
+ byte[] challengeRespHash = hashAlgo.hashData(concatBytes(concatBytes(serverChallenge, cert.getSignature()), clientSecret));
+ byte[] challengeRespEncrypted = encryptAes(challengeRespHash, aesKey);
+ String secretResp = http.executePairingCommand("serverchallengeresp="+bytesToHex(challengeRespEncrypted), true);
+ if (!NvHTTP.getXmlString(secretResp, "paired", true).equals("1")) {
+ http.unpair();
+ return PairState.FAILED;
+ }
+
+ // Get the server's signed secret
+ byte[] serverSecretResp = hexToBytes(NvHTTP.getXmlString(secretResp, "pairingsecret", true));
+ byte[] serverSecret = Arrays.copyOfRange(serverSecretResp, 0, 16);
+ byte[] serverSignature = Arrays.copyOfRange(serverSecretResp, 16, serverSecretResp.length);
+
+ // Ensure the authenticity of the data
+ if (!verifySignature(serverSecret, serverSignature, serverCert)) {
+ // Cancel the pairing process
+ http.unpair();
+
+ // Looks like a MITM
+ return PairState.FAILED;
+ }
+
+ // Ensure the server challenge matched what we expected (aka the PIN was correct)
+ byte[] serverChallengeRespHash = hashAlgo.hashData(concatBytes(concatBytes(randomChallenge, serverCert.getSignature()), serverSecret));
+ if (!Arrays.equals(serverChallengeRespHash, serverResponse)) {
+ // Cancel the pairing process
+ http.unpair();
+
+ // Probably got the wrong PIN
+ return PairState.PIN_WRONG;
+ }
+
+ // Send the server our signed secret
+ byte[] clientPairingSecret = concatBytes(clientSecret, signData(clientSecret, pk));
+ String clientSecretResp = http.executePairingCommand("clientpairingsecret="+bytesToHex(clientPairingSecret), true);
+ if (!NvHTTP.getXmlString(clientSecretResp, "paired", true).equals("1")) {
+ http.unpair();
+ return PairState.FAILED;
+ }
+
+ // Do the initial challenge (seems necessary for us to show as paired)
+ String pairChallenge = http.executePairingChallenge();
+ if (!NvHTTP.getXmlString(pairChallenge, "paired", true).equals("1")) {
+ http.unpair();
+ return PairState.FAILED;
+ }
+
+ return PairState.PAIRED;
+ }
+
+ private interface PairingHashAlgorithm {
+ int getHashLength();
+ byte[] hashData(byte[] data);
+ }
+
+ private static class Sha1PairingHash implements PairingHashAlgorithm {
+ public int getHashLength() {
+ return 20;
+ }
+
+ public byte[] hashData(byte[] data) {
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-1");
+ return md.digest(data);
+ }
+ catch (NoSuchAlgorithmException e) {
+ e.printStackTrace();
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ private static class Sha256PairingHash implements PairingHashAlgorithm {
+ public int getHashLength() {
+ return 32;
+ }
+
+ public byte[] hashData(byte[] data) {
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ return md.digest(data);
+ }
+ catch (NoSuchAlgorithmException e) {
+ e.printStackTrace();
+ throw new RuntimeException(e);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/nvstream/input/ControllerPacket.java b/app/src/main/java/com/limelight/nvstream/input/ControllerPacket.java
old mode 100644
new mode 100755
index b956598b95..16ef589b31
--- a/app/src/main/java/com/limelight/nvstream/input/ControllerPacket.java
+++ b/app/src/main/java/com/limelight/nvstream/input/ControllerPacket.java
@@ -1,27 +1,27 @@
-package com.limelight.nvstream.input;
-
-public class ControllerPacket {
- public static final int A_FLAG = 0x1000;
- public static final int B_FLAG = 0x2000;
- public static final int X_FLAG = 0x4000;
- public static final int Y_FLAG = 0x8000;
- public static final int UP_FLAG = 0x0001;
- public static final int DOWN_FLAG = 0x0002;
- public static final int LEFT_FLAG = 0x0004;
- public static final int RIGHT_FLAG = 0x0008;
- public static final int LB_FLAG = 0x0100;
- public static final int RB_FLAG = 0x0200;
- public static final int PLAY_FLAG = 0x0010;
- public static final int BACK_FLAG = 0x0020;
- public static final int LS_CLK_FLAG = 0x0040;
- public static final int RS_CLK_FLAG = 0x0080;
- public static final int SPECIAL_BUTTON_FLAG = 0x0400;
-
- // Extended buttons (Sunshine only)
- public static final int PADDLE1_FLAG = 0x010000;
- public static final int PADDLE2_FLAG = 0x020000;
- public static final int PADDLE3_FLAG = 0x040000;
- public static final int PADDLE4_FLAG = 0x080000;
- public static final int TOUCHPAD_FLAG = 0x100000; // Touchpad buttons on Sony controllers
- public static final int MISC_FLAG = 0x200000; // Share/Mic/Capture/Mute buttons on various controllers
+package com.limelight.nvstream.input;
+
+public class ControllerPacket {
+ public static final int A_FLAG = 0x1000;
+ public static final int B_FLAG = 0x2000;
+ public static final int X_FLAG = 0x4000;
+ public static final int Y_FLAG = 0x8000;
+ public static final int UP_FLAG = 0x0001;
+ public static final int DOWN_FLAG = 0x0002;
+ public static final int LEFT_FLAG = 0x0004;
+ public static final int RIGHT_FLAG = 0x0008;
+ public static final int LB_FLAG = 0x0100;
+ public static final int RB_FLAG = 0x0200;
+ public static final int PLAY_FLAG = 0x0010;
+ public static final int BACK_FLAG = 0x0020;
+ public static final int LS_CLK_FLAG = 0x0040;
+ public static final int RS_CLK_FLAG = 0x0080;
+ public static final int SPECIAL_BUTTON_FLAG = 0x0400;
+
+ // Extended buttons (Sunshine only)
+ public static final int PADDLE1_FLAG = 0x010000;
+ public static final int PADDLE2_FLAG = 0x020000;
+ public static final int PADDLE3_FLAG = 0x040000;
+ public static final int PADDLE4_FLAG = 0x080000;
+ public static final int TOUCHPAD_FLAG = 0x100000; // Touchpad buttons on Sony controllers
+ public static final int MISC_FLAG = 0x200000; // Share/Mic/Capture/Mute buttons on various controllers
}
\ No newline at end of file
diff --git a/app/src/main/java/com/limelight/nvstream/input/KeyboardPacket.java b/app/src/main/java/com/limelight/nvstream/input/KeyboardPacket.java
old mode 100644
new mode 100755
index 0b9cfff53c..b363a53a1b
--- a/app/src/main/java/com/limelight/nvstream/input/KeyboardPacket.java
+++ b/app/src/main/java/com/limelight/nvstream/input/KeyboardPacket.java
@@ -1,11 +1,11 @@
-package com.limelight.nvstream.input;
-
-public class KeyboardPacket {
- public static final byte KEY_DOWN = 0x03;
- public static final byte KEY_UP = 0x04;
-
- public static final byte MODIFIER_SHIFT = 0x01;
- public static final byte MODIFIER_CTRL = 0x02;
- public static final byte MODIFIER_ALT = 0x04;
- public static final byte MODIFIER_META = 0x08;
+package com.limelight.nvstream.input;
+
+public class KeyboardPacket {
+ public static final byte KEY_DOWN = 0x03;
+ public static final byte KEY_UP = 0x04;
+
+ public static final byte MODIFIER_SHIFT = 0x01;
+ public static final byte MODIFIER_CTRL = 0x02;
+ public static final byte MODIFIER_ALT = 0x04;
+ public static final byte MODIFIER_META = 0x08;
}
\ No newline at end of file
diff --git a/app/src/main/java/com/limelight/nvstream/input/MouseButtonPacket.java b/app/src/main/java/com/limelight/nvstream/input/MouseButtonPacket.java
old mode 100644
new mode 100755
index 1d06e043da..f199df3502
--- a/app/src/main/java/com/limelight/nvstream/input/MouseButtonPacket.java
+++ b/app/src/main/java/com/limelight/nvstream/input/MouseButtonPacket.java
@@ -1,12 +1,12 @@
-package com.limelight.nvstream.input;
-
-public class MouseButtonPacket {
- public static final byte PRESS_EVENT = 0x07;
- public static final byte RELEASE_EVENT = 0x08;
-
- public static final byte BUTTON_LEFT = 0x01;
- public static final byte BUTTON_MIDDLE = 0x02;
- public static final byte BUTTON_RIGHT = 0x03;
- public static final byte BUTTON_X1 = 0x04;
- public static final byte BUTTON_X2 = 0x05;
-}
+package com.limelight.nvstream.input;
+
+public class MouseButtonPacket {
+ public static final byte PRESS_EVENT = 0x07;
+ public static final byte RELEASE_EVENT = 0x08;
+
+ public static final byte BUTTON_LEFT = 0x01;
+ public static final byte BUTTON_MIDDLE = 0x02;
+ public static final byte BUTTON_RIGHT = 0x03;
+ public static final byte BUTTON_X1 = 0x04;
+ public static final byte BUTTON_X2 = 0x05;
+}
diff --git a/app/src/main/java/com/limelight/nvstream/jni/MoonBridge.java b/app/src/main/java/com/limelight/nvstream/jni/MoonBridge.java
old mode 100644
new mode 100755
index 0a92ac9162..de2f617bdf
--- a/app/src/main/java/com/limelight/nvstream/jni/MoonBridge.java
+++ b/app/src/main/java/com/limelight/nvstream/jni/MoonBridge.java
@@ -1,420 +1,424 @@
-package com.limelight.nvstream.jni;
-
-import com.limelight.nvstream.NvConnectionListener;
-import com.limelight.nvstream.av.audio.AudioRenderer;
-import com.limelight.nvstream.av.video.VideoDecoderRenderer;
-
-public class MoonBridge {
- /* See documentation in Limelight.h for information about these functions and constants */
-
- public static final AudioConfiguration AUDIO_CONFIGURATION_STEREO = new AudioConfiguration(2, 0x3);
- public static final AudioConfiguration AUDIO_CONFIGURATION_51_SURROUND = new AudioConfiguration(6, 0x3F);
- public static final AudioConfiguration AUDIO_CONFIGURATION_71_SURROUND = new AudioConfiguration(8, 0x63F);
-
- public static final int VIDEO_FORMAT_H264 = 0x0001;
- public static final int VIDEO_FORMAT_H265 = 0x0100;
- public static final int VIDEO_FORMAT_H265_MAIN10 = 0x0200;
- public static final int VIDEO_FORMAT_AV1_MAIN8 = 0x1000;
- public static final int VIDEO_FORMAT_AV1_MAIN10 = 0x2000;
-
- public static final int VIDEO_FORMAT_MASK_H264 = 0x000F;
- public static final int VIDEO_FORMAT_MASK_H265 = 0x0F00;
- public static final int VIDEO_FORMAT_MASK_AV1 = 0xF000;
- public static final int VIDEO_FORMAT_MASK_10BIT = 0x2200;
-
- public static final int BUFFER_TYPE_PICDATA = 0;
- public static final int BUFFER_TYPE_SPS = 1;
- public static final int BUFFER_TYPE_PPS = 2;
- public static final int BUFFER_TYPE_VPS = 3;
-
- public static final int FRAME_TYPE_PFRAME = 0;
- public static final int FRAME_TYPE_IDR = 1;
-
- public static final int COLORSPACE_REC_601 = 0;
- public static final int COLORSPACE_REC_709 = 1;
- public static final int COLORSPACE_REC_2020 = 2;
-
- public static final int COLOR_RANGE_LIMITED = 0;
- public static final int COLOR_RANGE_FULL = 1;
-
- public static final int CAPABILITY_DIRECT_SUBMIT = 1;
- public static final int CAPABILITY_REFERENCE_FRAME_INVALIDATION_AVC = 2;
- public static final int CAPABILITY_REFERENCE_FRAME_INVALIDATION_HEVC = 4;
- public static final int CAPABILITY_REFERENCE_FRAME_INVALIDATION_AV1 = 0x40;
-
- public static final int DR_OK = 0;
- public static final int DR_NEED_IDR = -1;
-
- public static final int CONN_STATUS_OKAY = 0;
- public static final int CONN_STATUS_POOR = 1;
-
- public static final int ML_ERROR_GRACEFUL_TERMINATION = 0;
- public static final int ML_ERROR_NO_VIDEO_TRAFFIC = -100;
- public static final int ML_ERROR_NO_VIDEO_FRAME = -101;
- public static final int ML_ERROR_UNEXPECTED_EARLY_TERMINATION = -102;
- public static final int ML_ERROR_PROTECTED_CONTENT = -103;
- public static final int ML_ERROR_FRAME_CONVERSION = -104;
-
- public static final int ML_PORT_INDEX_TCP_47984 = 0;
- public static final int ML_PORT_INDEX_TCP_47989 = 1;
- public static final int ML_PORT_INDEX_TCP_48010 = 2;
- public static final int ML_PORT_INDEX_UDP_47998 = 8;
- public static final int ML_PORT_INDEX_UDP_47999 = 9;
- public static final int ML_PORT_INDEX_UDP_48000 = 10;
- public static final int ML_PORT_INDEX_UDP_48010 = 11;
-
- public static final int ML_PORT_FLAG_ALL = 0xFFFFFFFF;
- public static final int ML_PORT_FLAG_TCP_47984 = 0x0001;
- public static final int ML_PORT_FLAG_TCP_47989 = 0x0002;
- public static final int ML_PORT_FLAG_TCP_48010 = 0x0004;
- public static final int ML_PORT_FLAG_UDP_47998 = 0x0100;
- public static final int ML_PORT_FLAG_UDP_47999 = 0x0200;
- public static final int ML_PORT_FLAG_UDP_48000 = 0x0400;
- public static final int ML_PORT_FLAG_UDP_48010 = 0x0800;
-
- public static final int ML_TEST_RESULT_INCONCLUSIVE = 0xFFFFFFFF;
-
- public static final byte SS_KBE_FLAG_NON_NORMALIZED = 0x01;
-
- public static final int LI_ERR_UNSUPPORTED = -5501;
-
- public static final byte LI_TOUCH_EVENT_HOVER = 0x00;
- public static final byte LI_TOUCH_EVENT_DOWN = 0x01;
- public static final byte LI_TOUCH_EVENT_UP = 0x02;
- public static final byte LI_TOUCH_EVENT_MOVE = 0x03;
- public static final byte LI_TOUCH_EVENT_CANCEL = 0x04;
- public static final byte LI_TOUCH_EVENT_BUTTON_ONLY = 0x05;
- public static final byte LI_TOUCH_EVENT_HOVER_LEAVE = 0x06;
- public static final byte LI_TOUCH_EVENT_CANCEL_ALL = 0x07;
-
- public static final byte LI_TOOL_TYPE_UNKNOWN = 0x00;
- public static final byte LI_TOOL_TYPE_PEN = 0x01;
- public static final byte LI_TOOL_TYPE_ERASER = 0x02;
-
- public static final byte LI_PEN_BUTTON_PRIMARY = 0x01;
- public static final byte LI_PEN_BUTTON_SECONDARY = 0x02;
- public static final byte LI_PEN_BUTTON_TERTIARY = 0x04;
-
- public static final byte LI_TILT_UNKNOWN = (byte)0xFF;
- public static final short LI_ROT_UNKNOWN = (short)0xFFFF;
-
- public static final byte LI_CTYPE_UNKNOWN = 0x00;
- public static final byte LI_CTYPE_XBOX = 0x01;
- public static final byte LI_CTYPE_PS = 0x02;
- public static final byte LI_CTYPE_NINTENDO = 0x03;
-
- public static final short LI_CCAP_ANALOG_TRIGGERS = 0x01;
- public static final short LI_CCAP_RUMBLE = 0x02;
- public static final short LI_CCAP_TRIGGER_RUMBLE = 0x04;
- public static final short LI_CCAP_TOUCHPAD = 0x08;
- public static final short LI_CCAP_ACCEL = 0x10;
- public static final short LI_CCAP_GYRO = 0x20;
- public static final short LI_CCAP_BATTERY_STATE = 0x40;
- public static final short LI_CCAP_RGB_LED = 0x80;
-
- public static final byte LI_MOTION_TYPE_ACCEL = 0x01;
- public static final byte LI_MOTION_TYPE_GYRO = 0x02;
-
- public static final byte LI_BATTERY_STATE_UNKNOWN = 0x00;
- public static final byte LI_BATTERY_STATE_NOT_PRESENT = 0x01;
- public static final byte LI_BATTERY_STATE_DISCHARGING = 0x02;
- public static final byte LI_BATTERY_STATE_CHARGING = 0x03;
- public static final byte LI_BATTERY_STATE_NOT_CHARGING = 0x04; // Connected to power but not charging
- public static final byte LI_BATTERY_STATE_FULL = 0x05;
-
- public static final byte LI_BATTERY_PERCENTAGE_UNKNOWN = (byte)0xFF;
-
- private static AudioRenderer audioRenderer;
- private static VideoDecoderRenderer videoRenderer;
- private static NvConnectionListener connectionListener;
-
- static {
- System.loadLibrary("moonlight-core");
- init();
- }
-
- public static int CAPABILITY_SLICES_PER_FRAME(byte slices) {
- return slices << 24;
- }
-
- public static class AudioConfiguration {
- public final int channelCount;
- public final int channelMask;
-
- public AudioConfiguration(int channelCount, int channelMask) {
- this.channelCount = channelCount;
- this.channelMask = channelMask;
- }
-
- // Creates an AudioConfiguration from the integer value returned by moonlight-common-c
- // See CHANNEL_COUNT_FROM_AUDIO_CONFIGURATION() and CHANNEL_MASK_FROM_AUDIO_CONFIGURATION()
- // in Limelight.h
- private AudioConfiguration(int audioConfiguration) {
- // Check the magic byte before decoding to make sure we got something that's actually
- // a MAKE_AUDIO_CONFIGURATION()-based value and not something else like an older version
- // hardcoded AUDIO_CONFIGURATION value from an earlier version of moonlight-common-c.
- if ((audioConfiguration & 0xFF) != 0xCA) {
- throw new IllegalArgumentException("Audio configuration has invalid magic byte!");
- }
-
- this.channelCount = (audioConfiguration >> 8) & 0xFF;
- this.channelMask = (audioConfiguration >> 16) & 0xFFFF;
- }
-
- // See SURROUNDAUDIOINFO_FROM_AUDIO_CONFIGURATION() in Limelight.h
- public int getSurroundAudioInfo() {
- return channelMask << 16 | channelCount;
- }
-
- @Override
- public boolean equals(Object obj) {
- if (obj instanceof AudioConfiguration) {
- AudioConfiguration that = (AudioConfiguration)obj;
- return this.toInt() == that.toInt();
- }
-
- return false;
- }
-
- @Override
- public int hashCode() {
- return toInt();
- }
-
- // Returns the integer value expected by moonlight-common-c
- // See MAKE_AUDIO_CONFIGURATION() in Limelight.h
- public int toInt() {
- return ((channelMask) << 16) | (channelCount << 8) | 0xCA;
- }
- }
-
- public static int bridgeDrSetup(int videoFormat, int width, int height, int redrawRate) {
- if (videoRenderer != null) {
- return videoRenderer.setup(videoFormat, width, height, redrawRate);
- }
- else {
- return -1;
- }
- }
-
- public static void bridgeDrStart() {
- if (videoRenderer != null) {
- videoRenderer.start();
- }
- }
-
- public static void bridgeDrStop() {
- if (videoRenderer != null) {
- videoRenderer.stop();
- }
- }
-
- public static void bridgeDrCleanup() {
- if (videoRenderer != null) {
- videoRenderer.cleanup();
- }
- }
-
- public static int bridgeDrSubmitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength, int decodeUnitType,
- int frameNumber, int frameType, char frameHostProcessingLatency,
- long receiveTimeMs, long enqueueTimeMs) {
- if (videoRenderer != null) {
- return videoRenderer.submitDecodeUnit(decodeUnitData, decodeUnitLength,
- decodeUnitType, frameNumber, frameType, frameHostProcessingLatency, receiveTimeMs, enqueueTimeMs);
- }
- else {
- return DR_OK;
- }
- }
-
- public static int bridgeArInit(int audioConfiguration, int sampleRate, int samplesPerFrame) {
- if (audioRenderer != null) {
- return audioRenderer.setup(new AudioConfiguration(audioConfiguration), sampleRate, samplesPerFrame);
- }
- else {
- return -1;
- }
- }
-
- public static void bridgeArStart() {
- if (audioRenderer != null) {
- audioRenderer.start();
- }
- }
-
- public static void bridgeArStop() {
- if (audioRenderer != null) {
- audioRenderer.stop();
- }
- }
-
- public static void bridgeArCleanup() {
- if (audioRenderer != null) {
- audioRenderer.cleanup();
- }
- }
-
- public static void bridgeArPlaySample(short[] pcmData) {
- if (audioRenderer != null) {
- audioRenderer.playDecodedAudio(pcmData);
- }
- }
-
- public static void bridgeClStageStarting(int stage) {
- if (connectionListener != null) {
- connectionListener.stageStarting(getStageName(stage));
- }
- }
-
- public static void bridgeClStageComplete(int stage) {
- if (connectionListener != null) {
- connectionListener.stageComplete(getStageName(stage));
- }
- }
-
- public static void bridgeClStageFailed(int stage, int errorCode) {
- if (connectionListener != null) {
- connectionListener.stageFailed(getStageName(stage), getPortFlagsFromStage(stage), errorCode);
- }
- }
-
- public static void bridgeClConnectionStarted() {
- if (connectionListener != null) {
- connectionListener.connectionStarted();
- }
- }
-
- public static void bridgeClConnectionTerminated(int errorCode) {
- if (connectionListener != null) {
- connectionListener.connectionTerminated(errorCode);
- }
- }
-
- public static void bridgeClRumble(short controllerNumber, short lowFreqMotor, short highFreqMotor) {
- if (connectionListener != null) {
- connectionListener.rumble(controllerNumber, lowFreqMotor, highFreqMotor);
- }
- }
-
- public static void bridgeClConnectionStatusUpdate(int connectionStatus) {
- if (connectionListener != null) {
- connectionListener.connectionStatusUpdate(connectionStatus);
- }
- }
-
- public static void bridgeClSetHdrMode(boolean enabled, byte[] hdrMetadata) {
- if (connectionListener != null) {
- connectionListener.setHdrMode(enabled, hdrMetadata);
- }
- }
-
- public static void bridgeClRumbleTriggers(short controllerNumber, short leftTrigger, short rightTrigger) {
- if (connectionListener != null) {
- connectionListener.rumbleTriggers(controllerNumber, leftTrigger, rightTrigger);
- }
- }
-
- public static void bridgeClSetMotionEventState(short controllerNumber, byte eventType, short sampleRateHz) {
- if (connectionListener != null) {
- connectionListener.setMotionEventState(controllerNumber, eventType, sampleRateHz);
- }
- }
-
- public static void bridgeClSetControllerLED(short controllerNumber, byte r, byte g, byte b) {
- if (connectionListener != null) {
- connectionListener.setControllerLED(controllerNumber, r, g, b);
- }
- }
-
- public static void setupBridge(VideoDecoderRenderer videoRenderer, AudioRenderer audioRenderer, NvConnectionListener connectionListener) {
- MoonBridge.videoRenderer = videoRenderer;
- MoonBridge.audioRenderer = audioRenderer;
- MoonBridge.connectionListener = connectionListener;
- }
-
- public static void cleanupBridge() {
- MoonBridge.videoRenderer = null;
- MoonBridge.audioRenderer = null;
- MoonBridge.connectionListener = null;
- }
-
- public static native int startConnection(String address, String appVersion, String gfeVersion,
- String rtspSessionUrl, int serverCodecModeSupport,
- int width, int height, int fps,
- int bitrate, int packetSize, int streamingRemotely,
- int audioConfiguration, int supportedVideoFormats,
- int clientRefreshRateX100,
- byte[] riAesKey, byte[] riAesIv,
- int videoCapabilities,
- int colorSpace, int colorRange);
-
- public static native void stopConnection();
-
- public static native void interruptConnection();
-
- public static native void sendMouseMove(short deltaX, short deltaY);
-
- public static native void sendMousePosition(short x, short y, short referenceWidth, short referenceHeight);
-
- public static native void sendMouseMoveAsMousePosition(short deltaX, short deltaY, short referenceWidth, short referenceHeight);
-
- public static native void sendMouseButton(byte buttonEvent, byte mouseButton);
-
- public static native void sendMultiControllerInput(short controllerNumber,
- short activeGamepadMask, int buttonFlags,
- byte leftTrigger, byte rightTrigger,
- short leftStickX, short leftStickY,
- short rightStickX, short rightStickY);
-
- public static native int sendTouchEvent(byte eventType, int pointerId, float x, float y, float pressure,
- float contactAreaMajor, float contactAreaMinor, short rotation);
-
- public static native int sendPenEvent(byte eventType, byte toolType, byte penButtons, float x, float y,
- float pressure, float contactAreaMajor, float contactAreaMinor,
- short rotation, byte tilt);
-
- public static native int sendControllerArrivalEvent(byte controllerNumber, short activeGamepadMask, byte type, int supportedButtonFlags, short capabilities);
-
- public static native int sendControllerTouchEvent(byte controllerNumber, byte eventType, int pointerId, float x, float y, float pressure);
-
- public static native int sendControllerMotionEvent(byte controllerNumber, byte motionType, float x, float y, float z);
-
- public static native int sendControllerBatteryEvent(byte controllerNumber, byte batteryState, byte batteryPercentage);
-
- public static native void sendKeyboardInput(short keyMap, byte keyDirection, byte modifier, byte flags);
-
- public static native void sendMouseHighResScroll(short scrollAmount);
-
- public static native void sendMouseHighResHScroll(short scrollAmount);
-
- public static native void sendUtf8Text(String text);
-
- public static native String getStageName(int stage);
-
- public static native String findExternalAddressIP4(String stunHostName, int stunPort);
-
- public static native int getPendingAudioDuration();
-
- public static native int getPendingVideoFrames();
-
- public static native int testClientConnectivity(String testServerHostName, int referencePort, int testFlags);
-
- public static native int getPortFlagsFromStage(int stage);
-
- public static native int getPortFlagsFromTerminationErrorCode(int errorCode);
-
- public static native String stringifyPortFlags(int portFlags, String separator);
-
- // The RTT is in the top 32 bits, and the RTT variance is in the bottom 32 bits
- public static native long getEstimatedRttInfo();
-
- public static native String getLaunchUrlQueryParameters();
-
- public static native byte guessControllerType(int vendorId, int productId);
-
- public static native boolean guessControllerHasPaddles(int vendorId, int productId);
-
- public static native boolean guessControllerHasShareButton(int vendorId, int productId);
-
- public static native void init();
-}
+package com.limelight.nvstream.jni;
+
+import com.limelight.nvstream.NvConnectionListener;
+import com.limelight.nvstream.av.audio.AudioRenderer;
+import com.limelight.nvstream.av.video.VideoDecoderRenderer;
+
+public class MoonBridge {
+ /* See documentation in Limelight.h for information about these functions and constants */
+
+ public static final AudioConfiguration AUDIO_CONFIGURATION_STEREO = new AudioConfiguration(2, 0x3);
+ public static final AudioConfiguration AUDIO_CONFIGURATION_51_SURROUND = new AudioConfiguration(6, 0x3F);
+ public static final AudioConfiguration AUDIO_CONFIGURATION_71_SURROUND = new AudioConfiguration(8, 0x63F);
+
+ public static final int VIDEO_FORMAT_H264 = 0x0001;
+ public static final int VIDEO_FORMAT_H265 = 0x0100;
+ public static final int VIDEO_FORMAT_H265_MAIN10 = 0x0200;
+ public static final int VIDEO_FORMAT_AV1_MAIN8 = 0x1000;
+ public static final int VIDEO_FORMAT_AV1_MAIN10 = 0x2000;
+
+ public static final int VIDEO_FORMAT_MASK_H264 = 0x000F;
+ public static final int VIDEO_FORMAT_MASK_H265 = 0x0F00;
+ public static final int VIDEO_FORMAT_MASK_AV1 = 0xF000;
+ public static final int VIDEO_FORMAT_MASK_10BIT = 0x2200;
+
+ public static final int BUFFER_TYPE_PICDATA = 0;
+ public static final int BUFFER_TYPE_SPS = 1;
+ public static final int BUFFER_TYPE_PPS = 2;
+ public static final int BUFFER_TYPE_VPS = 3;
+
+ public static final int FRAME_TYPE_PFRAME = 0;
+ public static final int FRAME_TYPE_IDR = 1;
+
+ public static final int COLORSPACE_REC_601 = 0;
+ public static final int COLORSPACE_REC_709 = 1;
+ public static final int COLORSPACE_REC_2020 = 2;
+
+ public static final int COLOR_RANGE_LIMITED = 0;
+ public static final int COLOR_RANGE_FULL = 1;
+
+ public static final int CAPABILITY_DIRECT_SUBMIT = 1;
+ public static final int CAPABILITY_REFERENCE_FRAME_INVALIDATION_AVC = 2;
+ public static final int CAPABILITY_REFERENCE_FRAME_INVALIDATION_HEVC = 4;
+ public static final int CAPABILITY_REFERENCE_FRAME_INVALIDATION_AV1 = 0x40;
+
+ public static final int DR_OK = 0;
+ public static final int DR_NEED_IDR = -1;
+
+ public static final int CONN_STATUS_OKAY = 0;
+ public static final int CONN_STATUS_POOR = 1;
+
+ public static final int ML_ERROR_GRACEFUL_TERMINATION = 0;
+ public static final int ML_ERROR_NO_VIDEO_TRAFFIC = -100;
+ public static final int ML_ERROR_NO_VIDEO_FRAME = -101;
+ public static final int ML_ERROR_UNEXPECTED_EARLY_TERMINATION = -102;
+ public static final int ML_ERROR_PROTECTED_CONTENT = -103;
+ public static final int ML_ERROR_FRAME_CONVERSION = -104;
+
+ public static final int ML_PORT_INDEX_TCP_47984 = 0;
+ public static final int ML_PORT_INDEX_TCP_47989 = 1;
+ public static final int ML_PORT_INDEX_TCP_48010 = 2;
+ public static final int ML_PORT_INDEX_UDP_47998 = 8;
+ public static final int ML_PORT_INDEX_UDP_47999 = 9;
+ public static final int ML_PORT_INDEX_UDP_48000 = 10;
+ public static final int ML_PORT_INDEX_UDP_48010 = 11;
+
+ public static final int ML_PORT_FLAG_ALL = 0xFFFFFFFF;
+ public static final int ML_PORT_FLAG_TCP_47984 = 0x0001;
+ public static final int ML_PORT_FLAG_TCP_47989 = 0x0002;
+ public static final int ML_PORT_FLAG_TCP_48010 = 0x0004;
+ public static final int ML_PORT_FLAG_UDP_47998 = 0x0100;
+ public static final int ML_PORT_FLAG_UDP_47999 = 0x0200;
+ public static final int ML_PORT_FLAG_UDP_48000 = 0x0400;
+ public static final int ML_PORT_FLAG_UDP_48010 = 0x0800;
+
+ public static final int ML_TEST_RESULT_INCONCLUSIVE = 0xFFFFFFFF;
+
+ public static final byte SS_KBE_FLAG_NON_NORMALIZED = 0x01;
+
+ public static final int LI_ERR_UNSUPPORTED = -5501;
+
+ public static final byte LI_TOUCH_EVENT_HOVER = 0x00;
+ public static final byte LI_TOUCH_EVENT_DOWN = 0x01;
+ public static final byte LI_TOUCH_EVENT_UP = 0x02;
+ public static final byte LI_TOUCH_EVENT_MOVE = 0x03;
+ public static final byte LI_TOUCH_EVENT_CANCEL = 0x04;
+ public static final byte LI_TOUCH_EVENT_BUTTON_ONLY = 0x05;
+ public static final byte LI_TOUCH_EVENT_HOVER_LEAVE = 0x06;
+ public static final byte LI_TOUCH_EVENT_CANCEL_ALL = 0x07;
+
+ public static final byte LI_TOOL_TYPE_UNKNOWN = 0x00;
+ public static final byte LI_TOOL_TYPE_PEN = 0x01;
+ public static final byte LI_TOOL_TYPE_ERASER = 0x02;
+
+ public static final byte LI_PEN_BUTTON_PRIMARY = 0x01;
+ public static final byte LI_PEN_BUTTON_SECONDARY = 0x02;
+ public static final byte LI_PEN_BUTTON_TERTIARY = 0x04;
+
+ public static final byte LI_TILT_UNKNOWN = (byte)0xFF;
+ public static final short LI_ROT_UNKNOWN = (short)0xFFFF;
+
+ public static final byte LI_CTYPE_UNKNOWN = 0x00;
+ public static final byte LI_CTYPE_XBOX = 0x01;
+ public static final byte LI_CTYPE_PS = 0x02;
+ public static final byte LI_CTYPE_NINTENDO = 0x03;
+
+ public static final short LI_CCAP_ANALOG_TRIGGERS = 0x01;
+ public static final short LI_CCAP_RUMBLE = 0x02;
+ public static final short LI_CCAP_TRIGGER_RUMBLE = 0x04;
+ public static final short LI_CCAP_TOUCHPAD = 0x08;
+ public static final short LI_CCAP_ACCEL = 0x10;
+ public static final short LI_CCAP_GYRO = 0x20;
+ public static final short LI_CCAP_BATTERY_STATE = 0x40;
+ public static final short LI_CCAP_RGB_LED = 0x80;
+
+ public static final byte LI_MOTION_TYPE_ACCEL = 0x01;
+ public static final byte LI_MOTION_TYPE_GYRO = 0x02;
+
+ public static final byte LI_BATTERY_STATE_UNKNOWN = 0x00;
+ public static final byte LI_BATTERY_STATE_NOT_PRESENT = 0x01;
+ public static final byte LI_BATTERY_STATE_DISCHARGING = 0x02;
+ public static final byte LI_BATTERY_STATE_CHARGING = 0x03;
+ public static final byte LI_BATTERY_STATE_NOT_CHARGING = 0x04; // Connected to power but not charging
+ public static final byte LI_BATTERY_STATE_FULL = 0x05;
+
+ public static final byte LI_BATTERY_PERCENTAGE_UNKNOWN = (byte)0xFF;
+
+ private static AudioRenderer audioRenderer;
+ private static VideoDecoderRenderer videoRenderer;
+ private static NvConnectionListener connectionListener;
+
+ static {
+ System.loadLibrary("moonlight-core");
+ init();
+ }
+
+ public static int CAPABILITY_SLICES_PER_FRAME(byte slices) {
+ return slices << 24;
+ }
+
+ public static class AudioConfiguration {
+ public final int channelCount;
+ public final int channelMask;
+
+ public AudioConfiguration(int channelCount, int channelMask) {
+ this.channelCount = channelCount;
+ this.channelMask = channelMask;
+ }
+
+ // Creates an AudioConfiguration from the integer value returned by moonlight-common-c
+ // See CHANNEL_COUNT_FROM_AUDIO_CONFIGURATION() and CHANNEL_MASK_FROM_AUDIO_CONFIGURATION()
+ // in Limelight.h
+ private AudioConfiguration(int audioConfiguration) {
+ // Check the magic byte before decoding to make sure we got something that's actually
+ // a MAKE_AUDIO_CONFIGURATION()-based value and not something else like an older version
+ // hardcoded AUDIO_CONFIGURATION value from an earlier version of moonlight-common-c.
+ if ((audioConfiguration & 0xFF) != 0xCA) {
+ throw new IllegalArgumentException("Audio configuration has invalid magic byte!");
+ }
+
+ this.channelCount = (audioConfiguration >> 8) & 0xFF;
+ this.channelMask = (audioConfiguration >> 16) & 0xFFFF;
+ }
+
+ // See SURROUNDAUDIOINFO_FROM_AUDIO_CONFIGURATION() in Limelight.h
+ public int getSurroundAudioInfo() {
+ return channelMask << 16 | channelCount;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj instanceof AudioConfiguration) {
+ AudioConfiguration that = (AudioConfiguration)obj;
+ return this.toInt() == that.toInt();
+ }
+
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ return toInt();
+ }
+
+ // Returns the integer value expected by moonlight-common-c
+ // See MAKE_AUDIO_CONFIGURATION() in Limelight.h
+ public int toInt() {
+ return ((channelMask) << 16) | (channelCount << 8) | 0xCA;
+ }
+ }
+
+ public static int bridgeDrSetup(int videoFormat, int width, int height, int redrawRate) {
+ if (videoRenderer != null) {
+ return videoRenderer.setup(videoFormat, width, height, redrawRate);
+ }
+ else {
+ return -1;
+ }
+ }
+
+ public static void bridgeDrStart() {
+ if (videoRenderer != null) {
+ videoRenderer.start();
+ }
+ }
+
+ public static void bridgeDrStop() {
+ if (videoRenderer != null) {
+ videoRenderer.stop();
+ }
+ }
+
+ public static void bridgeDrCleanup() {
+ if (videoRenderer != null) {
+ videoRenderer.cleanup();
+ }
+ }
+
+ //todo 不显示画面
+ public static int bridgeDrSubmitDecodeUnit(byte[] decodeUnitData, int decodeUnitLength, int decodeUnitType,
+ int frameNumber, int frameType, char frameHostProcessingLatency,
+ long receiveTimeMs, long enqueueTimeMs) {
+ if (videoRenderer != null) {
+ return videoRenderer.submitDecodeUnit(decodeUnitData, decodeUnitLength,
+ decodeUnitType, frameNumber, frameType, frameHostProcessingLatency, receiveTimeMs, enqueueTimeMs);
+ }
+ else {
+ return DR_OK;
+ }
+ }
+
+ public static int bridgeArInit(int audioConfiguration, int sampleRate, int samplesPerFrame) {
+ if (audioRenderer != null) {
+ return audioRenderer.setup(new AudioConfiguration(audioConfiguration), sampleRate, samplesPerFrame);
+ }
+ else {
+ return -1;
+ }
+ }
+
+ public static void bridgeArStart() {
+ if (audioRenderer != null) {
+ audioRenderer.start();
+ }
+ }
+
+ public static void bridgeArStop() {
+ if (audioRenderer != null) {
+ audioRenderer.stop();
+ }
+ }
+
+ public static void bridgeArCleanup() {
+ if (audioRenderer != null) {
+ audioRenderer.cleanup();
+ }
+ }
+
+ //静音 todo
+ public static void bridgeArPlaySample(short[] pcmData) {
+ if (audioRenderer != null) {
+ audioRenderer.playDecodedAudio(pcmData);
+ }
+ }
+
+ public static void bridgeClStageStarting(int stage) {
+ if (connectionListener != null) {
+ connectionListener.stageStarting(getStageName(stage));
+ }
+ }
+
+ public static void bridgeClStageComplete(int stage) {
+ if (connectionListener != null) {
+ connectionListener.stageComplete(getStageName(stage));
+ }
+ }
+
+ public static void bridgeClStageFailed(int stage, int errorCode) {
+ if (connectionListener != null) {
+ connectionListener.stageFailed(getStageName(stage), getPortFlagsFromStage(stage), errorCode);
+ }
+ }
+
+ public static void bridgeClConnectionStarted() {
+ if (connectionListener != null) {
+ connectionListener.connectionStarted();
+ }
+ }
+
+ public static void bridgeClConnectionTerminated(int errorCode) {
+ if (connectionListener != null) {
+ connectionListener.connectionTerminated(errorCode);
+ }
+ }
+
+ public static void bridgeClRumble(short controllerNumber, short lowFreqMotor, short highFreqMotor) {
+ if (connectionListener != null) {
+ connectionListener.rumble(controllerNumber, lowFreqMotor, highFreqMotor);
+ }
+ }
+
+ public static void bridgeClConnectionStatusUpdate(int connectionStatus) {
+ if (connectionListener != null) {
+ connectionListener.connectionStatusUpdate(connectionStatus);
+ }
+ }
+
+ public static void bridgeClSetHdrMode(boolean enabled, byte[] hdrMetadata) {
+ if (connectionListener != null) {
+ connectionListener.setHdrMode(enabled, hdrMetadata);
+ }
+ }
+
+ public static void bridgeClRumbleTriggers(short controllerNumber, short leftTrigger, short rightTrigger) {
+ if (connectionListener != null) {
+ connectionListener.rumbleTriggers(controllerNumber, leftTrigger, rightTrigger);
+ }
+ }
+
+ public static void bridgeClSetMotionEventState(short controllerNumber, byte eventType, short sampleRateHz) {
+ if (connectionListener != null) {
+ connectionListener.setMotionEventState(controllerNumber, eventType, sampleRateHz);
+ }
+ }
+
+ public static void bridgeClSetControllerLED(short controllerNumber, byte r, byte g, byte b) {
+ if (connectionListener != null) {
+ connectionListener.setControllerLED(controllerNumber, r, g, b);
+ }
+ }
+
+ public static void setupBridge(VideoDecoderRenderer videoRenderer, AudioRenderer audioRenderer, NvConnectionListener connectionListener) {
+ MoonBridge.videoRenderer = videoRenderer;
+ MoonBridge.audioRenderer = audioRenderer;
+ MoonBridge.connectionListener = connectionListener;
+ }
+
+ public static void cleanupBridge() {
+ MoonBridge.videoRenderer = null;
+ MoonBridge.audioRenderer = null;
+ MoonBridge.connectionListener = null;
+ }
+
+ public static native int startConnection(String address, String appVersion, String gfeVersion,
+ String rtspSessionUrl, int serverCodecModeSupport,
+ int width, int height, int fps,
+ int bitrate, int packetSize, int streamingRemotely,
+ int audioConfiguration, int supportedVideoFormats,
+ int clientRefreshRateX100,
+ byte[] riAesKey, byte[] riAesIv,
+ int videoCapabilities,
+ int colorSpace, int colorRange);
+
+ public static native void stopConnection();
+
+ public static native void interruptConnection();
+
+ public static native void sendExecServerCmd(int cmdId);
+
+ public static native void sendMouseMove(short deltaX, short deltaY);
+
+ public static native void sendMousePosition(short x, short y, short referenceWidth, short referenceHeight);
+
+ public static native void sendMouseMoveAsMousePosition(short deltaX, short deltaY, short referenceWidth, short referenceHeight);
+
+ public static native void sendMouseButton(byte buttonEvent, byte mouseButton);
+
+ public static native void sendMultiControllerInput(short controllerNumber,
+ short activeGamepadMask, int buttonFlags,
+ byte leftTrigger, byte rightTrigger,
+ short leftStickX, short leftStickY,
+ short rightStickX, short rightStickY);
+
+ public static native int sendTouchEvent(byte eventType, int pointerId, float x, float y, float pressure,
+ float contactAreaMajor, float contactAreaMinor, short rotation);
+
+ public static native int sendPenEvent(byte eventType, byte toolType, byte penButtons, float x, float y,
+ float pressure, float contactAreaMajor, float contactAreaMinor,
+ short rotation, byte tilt);
+
+ public static native int sendControllerArrivalEvent(byte controllerNumber, short activeGamepadMask, byte type, int supportedButtonFlags, short capabilities);
+
+ public static native int sendControllerTouchEvent(byte controllerNumber, byte eventType, int pointerId, float x, float y, float pressure);
+
+ public static native int sendControllerMotionEvent(byte controllerNumber, byte motionType, float x, float y, float z);
+
+ public static native int sendControllerBatteryEvent(byte controllerNumber, byte batteryState, byte batteryPercentage);
+
+ public static native void sendKeyboardInput(short keyMap, byte keyDirection, byte modifier, byte flags);
+
+ public static native void sendMouseHighResScroll(short scrollAmount);
+
+ public static native void sendMouseHighResHScroll(short scrollAmount);
+
+ public static native void sendUtf8Text(String text);
+
+ public static native String getStageName(int stage);
+
+ public static native String findExternalAddressIP4(String stunHostName, int stunPort);
+
+ public static native int getPendingAudioDuration();
+
+ public static native int getPendingVideoFrames();
+
+ public static native int testClientConnectivity(String testServerHostName, int referencePort, int testFlags);
+
+ public static native int getPortFlagsFromStage(int stage);
+
+ public static native int getPortFlagsFromTerminationErrorCode(int errorCode);
+
+ public static native String stringifyPortFlags(int portFlags, String separator);
+
+ // The RTT is in the top 32 bits, and the RTT variance is in the bottom 32 bits
+ public static native long getEstimatedRttInfo();
+
+ public static native String getLaunchUrlQueryParameters();
+
+ public static native byte guessControllerType(int vendorId, int productId);
+
+ public static native boolean guessControllerHasPaddles(int vendorId, int productId);
+
+ public static native boolean guessControllerHasShareButton(int vendorId, int productId);
+
+ public static native void init();
+}
diff --git a/app/src/main/java/com/limelight/nvstream/mdns/JmDNSDiscoveryAgent.java b/app/src/main/java/com/limelight/nvstream/mdns/JmDNSDiscoveryAgent.java
old mode 100644
new mode 100755
index 466304cf1f..ee3c612b81
--- a/app/src/main/java/com/limelight/nvstream/mdns/JmDNSDiscoveryAgent.java
+++ b/app/src/main/java/com/limelight/nvstream/mdns/JmDNSDiscoveryAgent.java
@@ -1,269 +1,269 @@
-package com.limelight.nvstream.mdns;
-
-import android.content.Context;
-import android.net.wifi.WifiManager;
-
-import java.io.IOException;
-import java.io.UnsupportedEncodingException;
-import java.net.InetAddress;
-import java.net.NetworkInterface;
-import java.util.ArrayList;
-import java.util.HashSet;
-
-import javax.jmdns.JmmDNS;
-import javax.jmdns.NetworkTopologyDiscovery;
-import javax.jmdns.ServiceEvent;
-import javax.jmdns.ServiceInfo;
-import javax.jmdns.ServiceListener;
-import javax.jmdns.impl.NetworkTopologyDiscoveryImpl;
-
-import com.limelight.LimeLog;
-
-public class JmDNSDiscoveryAgent extends MdnsDiscoveryAgent implements ServiceListener {
- private static final String SERVICE_TYPE = "_nvstream._tcp.local.";
- private WifiManager.MulticastLock multicastLock;
- private Thread discoveryThread;
- private HashSet pendingResolution = new HashSet<>();
-
- // The resolver factory's instance member has a static lifetime which
- // means our ref count and listener must be static also.
- private static int resolverRefCount = 0;
- private static HashSet listeners = new HashSet<>();
- private static ServiceListener nvstreamListener = new ServiceListener() {
- @Override
- public void serviceAdded(ServiceEvent event) {
- HashSet localListeners;
-
- // Copy the listener set into a new set so we can invoke
- // the callbacks without holding the listeners monitor the
- // whole time.
- synchronized (listeners) {
- localListeners = new HashSet(listeners);
- }
-
- for (ServiceListener listener : localListeners) {
- listener.serviceAdded(event);
- }
- }
-
- @Override
- public void serviceRemoved(ServiceEvent event) {
- HashSet localListeners;
-
- // Copy the listener set into a new set so we can invoke
- // the callbacks without holding the listeners monitor the
- // whole time.
- synchronized (listeners) {
- localListeners = new HashSet(listeners);
- }
-
- for (ServiceListener listener : localListeners) {
- listener.serviceRemoved(event);
- }
- }
-
- @Override
- public void serviceResolved(ServiceEvent event) {
- HashSet localListeners;
-
- // Copy the listener set into a new set so we can invoke
- // the callbacks without holding the listeners monitor the
- // whole time.
- synchronized (listeners) {
- localListeners = new HashSet(listeners);
- }
-
- for (ServiceListener listener : localListeners) {
- listener.serviceResolved(event);
- }
- }
- };
-
- public static class MyNetworkTopologyDiscovery extends NetworkTopologyDiscoveryImpl {
- @Override
- public boolean useInetAddress(NetworkInterface networkInterface, InetAddress interfaceAddress) {
- // This is an copy of jmDNS's implementation, except we omit the multicast check, since
- // it seems at least some devices lie about interfaces not supporting multicast when they really do.
- try {
- if (!networkInterface.isUp()) {
- return false;
- }
-
- /*
- if (!networkInterface.supportsMulticast()) {
- return false;
- }
- */
-
- if (networkInterface.isLoopback()) {
- return false;
- }
-
- return true;
- } catch (Exception exception) {
- return false;
- }
- }
- }
-
- static {
- // Override jmDNS's default topology discovery class with ours
- NetworkTopologyDiscovery.Factory.setClassDelegate(new NetworkTopologyDiscovery.Factory.ClassDelegate() {
- @Override
- public NetworkTopologyDiscovery newNetworkTopologyDiscovery() {
- return new MyNetworkTopologyDiscovery();
- }
- });
- }
-
- private static JmmDNS referenceResolver() {
- synchronized (JmDNSDiscoveryAgent.class) {
- JmmDNS instance = JmmDNS.Factory.getInstance();
- if (++resolverRefCount == 1) {
- // This will cause the listener to be invoked for known hosts immediately.
- // JmDNS only supports one listener per service, so we have to do this here
- // with a static listener.
- instance.addServiceListener(SERVICE_TYPE, nvstreamListener);
- }
- return instance;
- }
- }
-
- private static void dereferenceResolver() {
- synchronized (JmDNSDiscoveryAgent.class) {
- if (--resolverRefCount == 0) {
- try {
- JmmDNS.Factory.close();
- } catch (IOException e) {}
- }
- }
- }
-
- public JmDNSDiscoveryAgent(Context context, MdnsDiscoveryListener listener) {
- super(listener);
-
- // Create the multicast lock required to receive mDNS traffic
- WifiManager wifiMgr = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
- multicastLock = wifiMgr.createMulticastLock("Limelight mDNS");
- multicastLock.setReferenceCounted(false);
- }
-
- private void handleResolvedServiceInfo(ServiceInfo info) {
- synchronized (pendingResolution) {
- pendingResolution.remove(info.getName());
- }
-
- try {
- handleServiceInfo(info);
- } catch (UnsupportedEncodingException e) {
- // Invalid DNS response
- LimeLog.info("mDNS: Invalid response for machine: "+info.getName());
- return;
- }
- }
-
- private void handleServiceInfo(ServiceInfo info) throws UnsupportedEncodingException {
- reportNewComputer(info.getName(), info.getPort(), info.getInet4Addresses(), info.getInet6Addresses());
- }
-
- public void startDiscovery(final int discoveryIntervalMs) {
- // Kill any existing discovery before starting a new one
- stopDiscovery();
-
- // Acquire the multicast lock to start receiving mDNS traffic
- multicastLock.acquire();
-
- // Add our listener to the set
- synchronized (listeners) {
- listeners.add(JmDNSDiscoveryAgent.this);
- }
-
- discoveryThread = new Thread() {
- @Override
- public void run() {
- // This may result in listener callbacks so we must register
- // our listener first.
- JmmDNS resolver = referenceResolver();
-
- try {
- while (!Thread.interrupted()) {
- // Start an mDNS request
- resolver.requestServiceInfo(SERVICE_TYPE, null, discoveryIntervalMs);
-
- // Run service resolution again for pending machines
- ArrayList pendingNames;
- synchronized (pendingResolution) {
- pendingNames = new ArrayList(pendingResolution);
- }
- for (String name : pendingNames) {
- LimeLog.info("mDNS: Retrying service resolution for machine: "+name);
- ServiceInfo[] infos = resolver.getServiceInfos(SERVICE_TYPE, name, 500);
- if (infos != null && infos.length != 0) {
- LimeLog.info("mDNS: Resolved (retry) with "+infos.length+" service entries");
- for (ServiceInfo svcinfo : infos) {
- handleResolvedServiceInfo(svcinfo);
- }
- }
- }
-
- // Wait for the next polling interval
- try {
- Thread.sleep(discoveryIntervalMs);
- } catch (InterruptedException e) {
- break;
- }
- }
- }
- finally {
- // Dereference the resolver
- dereferenceResolver();
- }
- }
- };
- discoveryThread.setName("mDNS Discovery Thread");
- discoveryThread.start();
- }
-
- public void stopDiscovery() {
- // Release the multicast lock to stop receiving mDNS traffic
- multicastLock.release();
-
- // Remove our listener from the set
- synchronized (listeners) {
- listeners.remove(JmDNSDiscoveryAgent.this);
- }
-
- // If there's already a running thread, interrupt it
- if (discoveryThread != null) {
- discoveryThread.interrupt();
- discoveryThread = null;
- }
- }
-
- @Override
- public void serviceAdded(ServiceEvent event) {
- LimeLog.info("mDNS: Machine appeared: "+event.getInfo().getName());
-
- ServiceInfo info = event.getDNS().getServiceInfo(SERVICE_TYPE, event.getInfo().getName(), 500);
- if (info == null) {
- // This machine is pending resolution
- synchronized (pendingResolution) {
- pendingResolution.add(event.getInfo().getName());
- }
- return;
- }
-
- LimeLog.info("mDNS: Resolved (blocking)");
- handleResolvedServiceInfo(info);
- }
-
- @Override
- public void serviceRemoved(ServiceEvent event) {
- LimeLog.info("mDNS: Machine disappeared: "+event.getInfo().getName());
- }
-
- @Override
- public void serviceResolved(ServiceEvent event) {
- // We handle this synchronously
- }
-}
+package com.limelight.nvstream.mdns;
+
+import android.content.Context;
+import android.net.wifi.WifiManager;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.InetAddress;
+import java.net.NetworkInterface;
+import java.util.ArrayList;
+import java.util.HashSet;
+
+import javax.jmdns.JmmDNS;
+import javax.jmdns.NetworkTopologyDiscovery;
+import javax.jmdns.ServiceEvent;
+import javax.jmdns.ServiceInfo;
+import javax.jmdns.ServiceListener;
+import javax.jmdns.impl.NetworkTopologyDiscoveryImpl;
+
+import com.limelight.LimeLog;
+
+public class JmDNSDiscoveryAgent extends MdnsDiscoveryAgent implements ServiceListener {
+ private static final String SERVICE_TYPE = "_nvstream._tcp.local.";
+ private WifiManager.MulticastLock multicastLock;
+ private Thread discoveryThread;
+ private HashSet pendingResolution = new HashSet<>();
+
+ // The resolver factory's instance member has a static lifetime which
+ // means our ref count and listener must be static also.
+ private static int resolverRefCount = 0;
+ private static HashSet listeners = new HashSet<>();
+ private static ServiceListener nvstreamListener = new ServiceListener() {
+ @Override
+ public void serviceAdded(ServiceEvent event) {
+ HashSet localListeners;
+
+ // Copy the listener set into a new set so we can invoke
+ // the callbacks without holding the listeners monitor the
+ // whole time.
+ synchronized (listeners) {
+ localListeners = new HashSet(listeners);
+ }
+
+ for (ServiceListener listener : localListeners) {
+ listener.serviceAdded(event);
+ }
+ }
+
+ @Override
+ public void serviceRemoved(ServiceEvent event) {
+ HashSet localListeners;
+
+ // Copy the listener set into a new set so we can invoke
+ // the callbacks without holding the listeners monitor the
+ // whole time.
+ synchronized (listeners) {
+ localListeners = new HashSet(listeners);
+ }
+
+ for (ServiceListener listener : localListeners) {
+ listener.serviceRemoved(event);
+ }
+ }
+
+ @Override
+ public void serviceResolved(ServiceEvent event) {
+ HashSet localListeners;
+
+ // Copy the listener set into a new set so we can invoke
+ // the callbacks without holding the listeners monitor the
+ // whole time.
+ synchronized (listeners) {
+ localListeners = new HashSet(listeners);
+ }
+
+ for (ServiceListener listener : localListeners) {
+ listener.serviceResolved(event);
+ }
+ }
+ };
+
+ public static class MyNetworkTopologyDiscovery extends NetworkTopologyDiscoveryImpl {
+ @Override
+ public boolean useInetAddress(NetworkInterface networkInterface, InetAddress interfaceAddress) {
+ // This is an copy of jmDNS's implementation, except we omit the multicast check, since
+ // it seems at least some devices lie about interfaces not supporting multicast when they really do.
+ try {
+ if (!networkInterface.isUp()) {
+ return false;
+ }
+
+ /*
+ if (!networkInterface.supportsMulticast()) {
+ return false;
+ }
+ */
+
+ if (networkInterface.isLoopback()) {
+ return false;
+ }
+
+ return true;
+ } catch (Exception exception) {
+ return false;
+ }
+ }
+ }
+
+ static {
+ // Override jmDNS's default topology discovery class with ours
+ NetworkTopologyDiscovery.Factory.setClassDelegate(new NetworkTopologyDiscovery.Factory.ClassDelegate() {
+ @Override
+ public NetworkTopologyDiscovery newNetworkTopologyDiscovery() {
+ return new MyNetworkTopologyDiscovery();
+ }
+ });
+ }
+
+ private static JmmDNS referenceResolver() {
+ synchronized (JmDNSDiscoveryAgent.class) {
+ JmmDNS instance = JmmDNS.Factory.getInstance();
+ if (++resolverRefCount == 1) {
+ // This will cause the listener to be invoked for known hosts immediately.
+ // JmDNS only supports one listener per service, so we have to do this here
+ // with a static listener.
+ instance.addServiceListener(SERVICE_TYPE, nvstreamListener);
+ }
+ return instance;
+ }
+ }
+
+ private static void dereferenceResolver() {
+ synchronized (JmDNSDiscoveryAgent.class) {
+ if (--resolverRefCount == 0) {
+ try {
+ JmmDNS.Factory.close();
+ } catch (IOException e) {}
+ }
+ }
+ }
+
+ public JmDNSDiscoveryAgent(Context context, MdnsDiscoveryListener listener) {
+ super(listener);
+
+ // Create the multicast lock required to receive mDNS traffic
+ WifiManager wifiMgr = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
+ multicastLock = wifiMgr.createMulticastLock("Limelight mDNS");
+ multicastLock.setReferenceCounted(false);
+ }
+
+ private void handleResolvedServiceInfo(ServiceInfo info) {
+ synchronized (pendingResolution) {
+ pendingResolution.remove(info.getName());
+ }
+
+ try {
+ handleServiceInfo(info);
+ } catch (UnsupportedEncodingException e) {
+ // Invalid DNS response
+ LimeLog.info("mDNS: Invalid response for machine: "+info.getName());
+ return;
+ }
+ }
+
+ private void handleServiceInfo(ServiceInfo info) throws UnsupportedEncodingException {
+ reportNewComputer(info.getName(), info.getPort(), info.getInet4Addresses(), info.getInet6Addresses());
+ }
+
+ public void startDiscovery(final int discoveryIntervalMs) {
+ // Kill any existing discovery before starting a new one
+ stopDiscovery();
+
+ // Acquire the multicast lock to start receiving mDNS traffic
+ multicastLock.acquire();
+
+ // Add our listener to the set
+ synchronized (listeners) {
+ listeners.add(JmDNSDiscoveryAgent.this);
+ }
+
+ discoveryThread = new Thread() {
+ @Override
+ public void run() {
+ // This may result in listener callbacks so we must register
+ // our listener first.
+ JmmDNS resolver = referenceResolver();
+
+ try {
+ while (!Thread.interrupted()) {
+ // Start an mDNS request
+ resolver.requestServiceInfo(SERVICE_TYPE, null, discoveryIntervalMs);
+
+ // Run service resolution again for pending machines
+ ArrayList pendingNames;
+ synchronized (pendingResolution) {
+ pendingNames = new ArrayList(pendingResolution);
+ }
+ for (String name : pendingNames) {
+ LimeLog.info("mDNS: Retrying service resolution for machine: "+name);
+ ServiceInfo[] infos = resolver.getServiceInfos(SERVICE_TYPE, name, 500);
+ if (infos != null && infos.length != 0) {
+ LimeLog.info("mDNS: Resolved (retry) with "+infos.length+" service entries");
+ for (ServiceInfo svcinfo : infos) {
+ handleResolvedServiceInfo(svcinfo);
+ }
+ }
+ }
+
+ // Wait for the next polling interval
+ try {
+ Thread.sleep(discoveryIntervalMs);
+ } catch (InterruptedException e) {
+ break;
+ }
+ }
+ }
+ finally {
+ // Dereference the resolver
+ dereferenceResolver();
+ }
+ }
+ };
+ discoveryThread.setName("mDNS Discovery Thread");
+ discoveryThread.start();
+ }
+
+ public void stopDiscovery() {
+ // Release the multicast lock to stop receiving mDNS traffic
+ multicastLock.release();
+
+ // Remove our listener from the set
+ synchronized (listeners) {
+ listeners.remove(JmDNSDiscoveryAgent.this);
+ }
+
+ // If there's already a running thread, interrupt it
+ if (discoveryThread != null) {
+ discoveryThread.interrupt();
+ discoveryThread = null;
+ }
+ }
+
+ @Override
+ public void serviceAdded(ServiceEvent event) {
+ LimeLog.info("mDNS: Machine appeared: "+event.getInfo().getName());
+
+ ServiceInfo info = event.getDNS().getServiceInfo(SERVICE_TYPE, event.getInfo().getName(), 500);
+ if (info == null) {
+ // This machine is pending resolution
+ synchronized (pendingResolution) {
+ pendingResolution.add(event.getInfo().getName());
+ }
+ return;
+ }
+
+ LimeLog.info("mDNS: Resolved (blocking)");
+ handleResolvedServiceInfo(info);
+ }
+
+ @Override
+ public void serviceRemoved(ServiceEvent event) {
+ LimeLog.info("mDNS: Machine disappeared: "+event.getInfo().getName());
+ }
+
+ @Override
+ public void serviceResolved(ServiceEvent event) {
+ // We handle this synchronously
+ }
+}
diff --git a/app/src/main/java/com/limelight/nvstream/mdns/MdnsComputer.java b/app/src/main/java/com/limelight/nvstream/mdns/MdnsComputer.java
old mode 100644
new mode 100755
index bdd7a17332..67f00b420a
--- a/app/src/main/java/com/limelight/nvstream/mdns/MdnsComputer.java
+++ b/app/src/main/java/com/limelight/nvstream/mdns/MdnsComputer.java
@@ -1,71 +1,71 @@
-package com.limelight.nvstream.mdns;
-
-import java.net.Inet6Address;
-import java.net.InetAddress;
-
-public class MdnsComputer {
- private InetAddress localAddr;
- private Inet6Address v6Addr;
- private int port;
- private String name;
-
- public MdnsComputer(String name, InetAddress localAddress, Inet6Address v6Addr, int port) {
- this.name = name;
- this.localAddr = localAddress;
- this.v6Addr = v6Addr;
- this.port = port;
- }
-
- public String getName() {
- return name;
- }
-
- public InetAddress getLocalAddress() {
- return localAddr;
- }
-
- public Inet6Address getIpv6Address() {
- return v6Addr;
- }
-
- public int getPort() {
- return port;
- }
-
- @Override
- public int hashCode() {
- return name.hashCode();
- }
-
- @Override
- public boolean equals(Object o) {
- if (o instanceof MdnsComputer) {
- MdnsComputer other = (MdnsComputer)o;
-
- if (!other.name.equals(name) || other.port != port) {
- return false;
- }
-
- if ((other.localAddr != null && localAddr == null) ||
- (other.localAddr == null && localAddr != null) ||
- (other.localAddr != null && !other.localAddr.equals(localAddr))) {
- return false;
- }
-
- if ((other.v6Addr != null && v6Addr == null) ||
- (other.v6Addr == null && v6Addr != null) ||
- (other.v6Addr != null && !other.v6Addr.equals(v6Addr))) {
- return false;
- }
-
- return true;
- }
-
- return false;
- }
-
- @Override
- public String toString() {
- return "["+name+" - "+localAddr+" - "+v6Addr+"]";
- }
-}
+package com.limelight.nvstream.mdns;
+
+import java.net.Inet6Address;
+import java.net.InetAddress;
+
+public class MdnsComputer {
+ private InetAddress localAddr;
+ private Inet6Address v6Addr;
+ private int port;
+ private String name;
+
+ public MdnsComputer(String name, InetAddress localAddress, Inet6Address v6Addr, int port) {
+ this.name = name;
+ this.localAddr = localAddress;
+ this.v6Addr = v6Addr;
+ this.port = port;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public InetAddress getLocalAddress() {
+ return localAddr;
+ }
+
+ public Inet6Address getIpv6Address() {
+ return v6Addr;
+ }
+
+ public int getPort() {
+ return port;
+ }
+
+ @Override
+ public int hashCode() {
+ return name.hashCode();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o instanceof MdnsComputer) {
+ MdnsComputer other = (MdnsComputer)o;
+
+ if (!other.name.equals(name) || other.port != port) {
+ return false;
+ }
+
+ if ((other.localAddr != null && localAddr == null) ||
+ (other.localAddr == null && localAddr != null) ||
+ (other.localAddr != null && !other.localAddr.equals(localAddr))) {
+ return false;
+ }
+
+ if ((other.v6Addr != null && v6Addr == null) ||
+ (other.v6Addr == null && v6Addr != null) ||
+ (other.v6Addr != null && !other.v6Addr.equals(v6Addr))) {
+ return false;
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ public String toString() {
+ return "["+name+" - "+localAddr+" - "+v6Addr+"]";
+ }
+}
diff --git a/app/src/main/java/com/limelight/nvstream/mdns/MdnsDiscoveryAgent.java b/app/src/main/java/com/limelight/nvstream/mdns/MdnsDiscoveryAgent.java
old mode 100644
new mode 100755
index 661578b6ff..825c9a7fd6
--- a/app/src/main/java/com/limelight/nvstream/mdns/MdnsDiscoveryAgent.java
+++ b/app/src/main/java/com/limelight/nvstream/mdns/MdnsDiscoveryAgent.java
@@ -1,148 +1,148 @@
-package com.limelight.nvstream.mdns;
-
-import com.limelight.LimeLog;
-
-import java.net.Inet4Address;
-import java.net.Inet6Address;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-
-public abstract class MdnsDiscoveryAgent {
- protected MdnsDiscoveryListener listener;
-
- protected HashSet computers = new HashSet<>();
-
- public MdnsDiscoveryAgent(MdnsDiscoveryListener listener) {
- this.listener = listener;
- }
-
- public abstract void startDiscovery(final int discoveryIntervalMs);
-
- public abstract void stopDiscovery();
-
- protected void reportNewComputer(String name, int port, Inet4Address[] v4Addrs, Inet6Address[] v6Addrs) {
- LimeLog.info("mDNS: "+name+" has "+v4Addrs.length+" IPv4 addresses");
- LimeLog.info("mDNS: "+name+" has "+v6Addrs.length+" IPv6 addresses");
-
- Inet6Address v6GlobalAddr = getBestIpv6Address(v6Addrs);
-
- // Add a computer object for each IPv4 address reported by the PC
- for (Inet4Address v4Addr : v4Addrs) {
- synchronized (computers) {
- MdnsComputer computer = new MdnsComputer(name, v4Addr, v6GlobalAddr, port);
- if (computers.add(computer)) {
- // This was a new entry
- listener.notifyComputerAdded(computer);
- }
- }
- }
-
- // If there were no IPv4 addresses, use IPv6 for registration
- if (v4Addrs.length == 0) {
- Inet6Address v6LocalAddr = getLocalAddress(v6Addrs);
-
- if (v6LocalAddr != null || v6GlobalAddr != null) {
- MdnsComputer computer = new MdnsComputer(name, v6LocalAddr, v6GlobalAddr, port);
- if (computers.add(computer)) {
- // This was a new entry
- listener.notifyComputerAdded(computer);
- }
- }
- }
- }
-
- public List getComputerSet() {
- synchronized (computers) {
- return new ArrayList<>(computers);
- }
- }
-
- protected static Inet6Address getLocalAddress(Inet6Address[] addresses) {
- for (Inet6Address addr : addresses) {
- if (addr.isLinkLocalAddress() || addr.isSiteLocalAddress()) {
- return addr;
- }
- // fc00::/7 - ULAs
- else if ((addr.getAddress()[0] & 0xfe) == 0xfc) {
- return addr;
- }
- }
-
- return null;
- }
-
- protected static Inet6Address getLinkLocalAddress(Inet6Address[] addresses) {
- for (Inet6Address addr : addresses) {
- if (addr.isLinkLocalAddress()) {
- LimeLog.info("Found link-local address: "+addr.getHostAddress());
- return addr;
- }
- }
-
- return null;
- }
-
- protected static Inet6Address getBestIpv6Address(Inet6Address[] addresses) {
- // First try to find a link local address, so we can match the interface identifier
- // with a global address (this will work for SLAAC but not DHCPv6).
- Inet6Address linkLocalAddr = getLinkLocalAddress(addresses);
-
- // We will try once to match a SLAAC interface suffix, then
- // pick the first matching address
- for (int tries = 0; tries < 2; tries++) {
- // We assume the addresses are already sorted in descending order
- // of preference from Bonjour.
- for (Inet6Address addr : addresses) {
- if (addr.isLinkLocalAddress() || addr.isSiteLocalAddress() || addr.isLoopbackAddress()) {
- // Link-local, site-local, and loopback aren't global
- LimeLog.info("Ignoring non-global address: "+addr.getHostAddress());
- continue;
- }
-
- byte[] addrBytes = addr.getAddress();
-
- // 2002::/16
- if (addrBytes[0] == 0x20 && addrBytes[1] == 0x02) {
- // 6to4 has horrible performance
- LimeLog.info("Ignoring 6to4 address: "+addr.getHostAddress());
- continue;
- }
- // 2001::/32
- else if (addrBytes[0] == 0x20 && addrBytes[1] == 0x01 && addrBytes[2] == 0x00 && addrBytes[3] == 0x00) {
- // Teredo also has horrible performance
- LimeLog.info("Ignoring Teredo address: "+addr.getHostAddress());
- continue;
- }
- // fc00::/7
- else if ((addrBytes[0] & 0xfe) == 0xfc) {
- // ULAs aren't global
- LimeLog.info("Ignoring ULA: "+addr.getHostAddress());
- continue;
- }
-
- // Compare the final 64-bit interface identifier and skip the address
- // if it doesn't match our link-local address.
- if (linkLocalAddr != null && tries == 0) {
- boolean matched = true;
-
- for (int i = 8; i < 16; i++) {
- if (linkLocalAddr.getAddress()[i] != addr.getAddress()[i]) {
- matched = false;
- break;
- }
- }
-
- if (!matched) {
- LimeLog.info("Ignoring non-matching global address: "+addr.getHostAddress());
- continue;
- }
- }
-
- return addr;
- }
- }
-
- return null;
- }
-}
+package com.limelight.nvstream.mdns;
+
+import com.limelight.LimeLog;
+
+import java.net.Inet4Address;
+import java.net.Inet6Address;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+
+public abstract class MdnsDiscoveryAgent {
+ protected MdnsDiscoveryListener listener;
+
+ protected HashSet computers = new HashSet<>();
+
+ public MdnsDiscoveryAgent(MdnsDiscoveryListener listener) {
+ this.listener = listener;
+ }
+
+ public abstract void startDiscovery(final int discoveryIntervalMs);
+
+ public abstract void stopDiscovery();
+
+ protected void reportNewComputer(String name, int port, Inet4Address[] v4Addrs, Inet6Address[] v6Addrs) {
+ LimeLog.info("mDNS: "+name+" has "+v4Addrs.length+" IPv4 addresses");
+ LimeLog.info("mDNS: "+name+" has "+v6Addrs.length+" IPv6 addresses");
+
+ Inet6Address v6GlobalAddr = getBestIpv6Address(v6Addrs);
+
+ // Add a computer object for each IPv4 address reported by the PC
+ for (Inet4Address v4Addr : v4Addrs) {
+ synchronized (computers) {
+ MdnsComputer computer = new MdnsComputer(name, v4Addr, v6GlobalAddr, port);
+ if (computers.add(computer)) {
+ // This was a new entry
+ listener.notifyComputerAdded(computer);
+ }
+ }
+ }
+
+ // If there were no IPv4 addresses, use IPv6 for registration
+ if (v4Addrs.length == 0) {
+ Inet6Address v6LocalAddr = getLocalAddress(v6Addrs);
+
+ if (v6LocalAddr != null || v6GlobalAddr != null) {
+ MdnsComputer computer = new MdnsComputer(name, v6LocalAddr, v6GlobalAddr, port);
+ if (computers.add(computer)) {
+ // This was a new entry
+ listener.notifyComputerAdded(computer);
+ }
+ }
+ }
+ }
+
+ public List getComputerSet() {
+ synchronized (computers) {
+ return new ArrayList<>(computers);
+ }
+ }
+
+ protected static Inet6Address getLocalAddress(Inet6Address[] addresses) {
+ for (Inet6Address addr : addresses) {
+ if (addr.isLinkLocalAddress() || addr.isSiteLocalAddress()) {
+ return addr;
+ }
+ // fc00::/7 - ULAs
+ else if ((addr.getAddress()[0] & 0xfe) == 0xfc) {
+ return addr;
+ }
+ }
+
+ return null;
+ }
+
+ protected static Inet6Address getLinkLocalAddress(Inet6Address[] addresses) {
+ for (Inet6Address addr : addresses) {
+ if (addr.isLinkLocalAddress()) {
+ LimeLog.info("Found link-local address: "+addr.getHostAddress());
+ return addr;
+ }
+ }
+
+ return null;
+ }
+
+ protected static Inet6Address getBestIpv6Address(Inet6Address[] addresses) {
+ // First try to find a link local address, so we can match the interface identifier
+ // with a global address (this will work for SLAAC but not DHCPv6).
+ Inet6Address linkLocalAddr = getLinkLocalAddress(addresses);
+
+ // We will try once to match a SLAAC interface suffix, then
+ // pick the first matching address
+ for (int tries = 0; tries < 2; tries++) {
+ // We assume the addresses are already sorted in descending order
+ // of preference from Bonjour.
+ for (Inet6Address addr : addresses) {
+ if (addr.isLinkLocalAddress() || addr.isSiteLocalAddress() || addr.isLoopbackAddress()) {
+ // Link-local, site-local, and loopback aren't global
+ LimeLog.info("Ignoring non-global address: "+addr.getHostAddress());
+ continue;
+ }
+
+ byte[] addrBytes = addr.getAddress();
+
+ // 2002::/16
+ if (addrBytes[0] == 0x20 && addrBytes[1] == 0x02) {
+ // 6to4 has horrible performance
+ LimeLog.info("Ignoring 6to4 address: "+addr.getHostAddress());
+ continue;
+ }
+ // 2001::/32
+ else if (addrBytes[0] == 0x20 && addrBytes[1] == 0x01 && addrBytes[2] == 0x00 && addrBytes[3] == 0x00) {
+ // Teredo also has horrible performance
+ LimeLog.info("Ignoring Teredo address: "+addr.getHostAddress());
+ continue;
+ }
+ // fc00::/7
+ else if ((addrBytes[0] & 0xfe) == 0xfc) {
+ // ULAs aren't global
+ LimeLog.info("Ignoring ULA: "+addr.getHostAddress());
+ continue;
+ }
+
+ // Compare the final 64-bit interface identifier and skip the address
+ // if it doesn't match our link-local address.
+ if (linkLocalAddr != null && tries == 0) {
+ boolean matched = true;
+
+ for (int i = 8; i < 16; i++) {
+ if (linkLocalAddr.getAddress()[i] != addr.getAddress()[i]) {
+ matched = false;
+ break;
+ }
+ }
+
+ if (!matched) {
+ LimeLog.info("Ignoring non-matching global address: "+addr.getHostAddress());
+ continue;
+ }
+ }
+
+ return addr;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/app/src/main/java/com/limelight/nvstream/mdns/MdnsDiscoveryListener.java b/app/src/main/java/com/limelight/nvstream/mdns/MdnsDiscoveryListener.java
old mode 100644
new mode 100755
index fa4ce10c5a..8a85f0cc09
--- a/app/src/main/java/com/limelight/nvstream/mdns/MdnsDiscoveryListener.java
+++ b/app/src/main/java/com/limelight/nvstream/mdns/MdnsDiscoveryListener.java
@@ -1,6 +1,6 @@
-package com.limelight.nvstream.mdns;
-
-public interface MdnsDiscoveryListener {
- void notifyComputerAdded(MdnsComputer computer);
- void notifyDiscoveryFailure(Exception e);
-}
+package com.limelight.nvstream.mdns;
+
+public interface MdnsDiscoveryListener {
+ void notifyComputerAdded(MdnsComputer computer);
+ void notifyDiscoveryFailure(Exception e);
+}
diff --git a/app/src/main/java/com/limelight/nvstream/mdns/NsdManagerDiscoveryAgent.java b/app/src/main/java/com/limelight/nvstream/mdns/NsdManagerDiscoveryAgent.java
old mode 100644
new mode 100755
index d94c94382c..b948ed78cd
--- a/app/src/main/java/com/limelight/nvstream/mdns/NsdManagerDiscoveryAgent.java
+++ b/app/src/main/java/com/limelight/nvstream/mdns/NsdManagerDiscoveryAgent.java
@@ -1,234 +1,234 @@
-package com.limelight.nvstream.mdns;
-
-import android.annotation.TargetApi;
-import android.content.Context;
-import android.net.nsd.NsdManager;
-import android.net.nsd.NsdServiceInfo;
-import android.os.Build;
-
-import com.limelight.LimeLog;
-
-import java.net.Inet4Address;
-import java.net.Inet6Address;
-import java.net.InetAddress;
-import java.util.HashMap;
-import java.util.List;
-import java.util.concurrent.LinkedBlockingQueue;
-import java.util.concurrent.ThreadPoolExecutor;
-import java.util.concurrent.TimeUnit;
-
-@TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
-public class NsdManagerDiscoveryAgent extends MdnsDiscoveryAgent {
- private static final String SERVICE_TYPE = "_nvstream._tcp";
- private final NsdManager nsdManager;
- private final Object listenerLock = new Object();
- private NsdManager.DiscoveryListener pendingListener;
- private NsdManager.DiscoveryListener activeListener;
- private final HashMap serviceCallbacks = new HashMap<>();
- private final ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
-
- private NsdManager.DiscoveryListener createDiscoveryListener() {
- return new NsdManager.DiscoveryListener() {
- @Override
- public void onStartDiscoveryFailed(String serviceType, int errorCode) {
- LimeLog.severe("NSD: Service discovery start failed: " + errorCode);
-
- // This listener is no longer pending after this failure
- synchronized (listenerLock) {
- if (pendingListener != this) {
- return;
- }
-
- pendingListener = null;
- }
-
- listener.notifyDiscoveryFailure(new RuntimeException("onStartDiscoveryFailed(): " + errorCode));
- }
-
- @Override
- public void onStopDiscoveryFailed(String serviceType, int errorCode) {
- LimeLog.severe("NSD: Service discovery stop failed: " + errorCode);
-
- // This listener is no longer active after this failure
- synchronized (listenerLock) {
- if (activeListener != this) {
- return;
- }
-
- activeListener = null;
- }
- }
-
- @Override
- public void onDiscoveryStarted(String serviceType) {
- LimeLog.info("NSD: Service discovery started");
-
- synchronized (listenerLock) {
- if (pendingListener != this) {
- // If we registered another discovery listener in the meantime, stop this one
- nsdManager.stopServiceDiscovery(this);
- return;
- }
-
- pendingListener = null;
- activeListener = this;
- }
- }
-
- @Override
- public void onDiscoveryStopped(String serviceType) {
- LimeLog.info("NSD: Service discovery stopped");
-
- synchronized (listenerLock) {
- if (activeListener != this) {
- return;
- }
-
- activeListener = null;
- }
- }
-
- @Override
- public void onServiceFound(NsdServiceInfo nsdServiceInfo) {
- // Protect against racing stopDiscovery() call
- synchronized (listenerLock) {
- // Ignore callbacks if we're not the active listener
- if (activeListener != this) {
- return;
- }
-
- LimeLog.info("NSD: Machine appeared: " + nsdServiceInfo.getServiceName());
-
- NsdManager.ServiceInfoCallback serviceInfoCallback = new NsdManager.ServiceInfoCallback() {
- @Override
- public void onServiceInfoCallbackRegistrationFailed(int errorCode) {
- LimeLog.severe("NSD: Service info callback registration failed: " + errorCode);
- listener.notifyDiscoveryFailure(new RuntimeException("onServiceInfoCallbackRegistrationFailed(): " + errorCode));
- }
-
- @Override
- public void onServiceUpdated(NsdServiceInfo nsdServiceInfo) {
- LimeLog.info("NSD: Machine resolved: " + nsdServiceInfo.getServiceName());
- reportNewComputer(nsdServiceInfo.getServiceName(), nsdServiceInfo.getPort(),
- getV4Addrs(nsdServiceInfo.getHostAddresses()),
- getV6Addrs(nsdServiceInfo.getHostAddresses()));
- }
-
- @Override
- public void onServiceLost() {
- }
-
- @Override
- public void onServiceInfoCallbackUnregistered() {
- }
- };
-
- nsdManager.registerServiceInfoCallback(nsdServiceInfo, executor, serviceInfoCallback);
- serviceCallbacks.put(nsdServiceInfo.getServiceName(), serviceInfoCallback);
- }
- }
-
- @Override
- public void onServiceLost(NsdServiceInfo nsdServiceInfo) {
- // Protect against racing stopDiscovery() call
- synchronized (listenerLock) {
- // Ignore callbacks if we're not the active listener
- if (activeListener != this) {
- return;
- }
-
- LimeLog.info("NSD: Machine lost: " + nsdServiceInfo.getServiceName());
-
- NsdManager.ServiceInfoCallback serviceInfoCallback = serviceCallbacks.remove(nsdServiceInfo.getServiceName());
- if (serviceInfoCallback != null) {
- nsdManager.unregisterServiceInfoCallback(serviceInfoCallback);
- }
- }
- }
- };
- }
-
- public NsdManagerDiscoveryAgent(Context context, MdnsDiscoveryListener listener) {
- super(listener);
- this.nsdManager = context.getSystemService(NsdManager.class);
- }
-
- @Override
- public void startDiscovery(int discoveryIntervalMs) {
- synchronized (listenerLock) {
- // Register a new service discovery listener if there's not already one starting or running
- if (pendingListener == null && activeListener == null) {
- pendingListener = createDiscoveryListener();
- nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, pendingListener);
- }
- }
- }
-
- @Override
- public void stopDiscovery() {
- // Protect against racing ServiceInfoCallback and DiscoveryListener callbacks
- synchronized (listenerLock) {
- // Clear any pending listener to ensure the discoverStarted() callback
- // will realize it's gone and stop itself.
- pendingListener = null;
-
- // Unregister the service discovery listener
- if (activeListener != null) {
- nsdManager.stopServiceDiscovery(activeListener);
-
- // Even though listener stoppage is asynchronous, the listener is gone as far as
- // we're concerned. We null this right now to ensure pending callbacks know it's
- // stopped and startDiscovery() can immediately create a new listener. If we left
- // it until onDiscoveryStopped() was called, startDiscovery() would get confused
- // and assume a listener was already running, even though it's stopping.
- activeListener = null;
- }
-
- // Unregister all service info callbacks
- for (NsdManager.ServiceInfoCallback callback : serviceCallbacks.values()) {
- nsdManager.unregisterServiceInfoCallback(callback);
- }
- serviceCallbacks.clear();
- }
- }
-
- private static Inet4Address[] getV4Addrs(List addrs) {
- int matchCount = 0;
- for (InetAddress addr : addrs) {
- if (addr instanceof Inet4Address) {
- matchCount++;
- }
- }
-
- Inet4Address[] matching = new Inet4Address[matchCount];
-
- int i = 0;
- for (InetAddress addr : addrs) {
- if (addr instanceof Inet4Address) {
- matching[i++] = (Inet4Address) addr;
- }
- }
-
- return matching;
- }
-
- private static Inet6Address[] getV6Addrs(List addrs) {
- int matchCount = 0;
- for (InetAddress addr : addrs) {
- if (addr instanceof Inet6Address) {
- matchCount++;
- }
- }
-
- Inet6Address[] matching = new Inet6Address[matchCount];
-
- int i = 0;
- for (InetAddress addr : addrs) {
- if (addr instanceof Inet6Address) {
- matching[i++] = (Inet6Address) addr;
- }
- }
-
- return matching;
- }
-}
+package com.limelight.nvstream.mdns;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.net.nsd.NsdManager;
+import android.net.nsd.NsdServiceInfo;
+import android.os.Build;
+
+import com.limelight.LimeLog;
+
+import java.net.Inet4Address;
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.util.HashMap;
+import java.util.List;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+@TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+public class NsdManagerDiscoveryAgent extends MdnsDiscoveryAgent {
+ private static final String SERVICE_TYPE = "_nvstream._tcp";
+ private final NsdManager nsdManager;
+ private final Object listenerLock = new Object();
+ private NsdManager.DiscoveryListener pendingListener;
+ private NsdManager.DiscoveryListener activeListener;
+ private final HashMap serviceCallbacks = new HashMap<>();
+ private final ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
+
+ private NsdManager.DiscoveryListener createDiscoveryListener() {
+ return new NsdManager.DiscoveryListener() {
+ @Override
+ public void onStartDiscoveryFailed(String serviceType, int errorCode) {
+ LimeLog.severe("NSD: Service discovery start failed: " + errorCode);
+
+ // This listener is no longer pending after this failure
+ synchronized (listenerLock) {
+ if (pendingListener != this) {
+ return;
+ }
+
+ pendingListener = null;
+ }
+
+ listener.notifyDiscoveryFailure(new RuntimeException("onStartDiscoveryFailed(): " + errorCode));
+ }
+
+ @Override
+ public void onStopDiscoveryFailed(String serviceType, int errorCode) {
+ LimeLog.severe("NSD: Service discovery stop failed: " + errorCode);
+
+ // This listener is no longer active after this failure
+ synchronized (listenerLock) {
+ if (activeListener != this) {
+ return;
+ }
+
+ activeListener = null;
+ }
+ }
+
+ @Override
+ public void onDiscoveryStarted(String serviceType) {
+ LimeLog.info("NSD: Service discovery started");
+
+ synchronized (listenerLock) {
+ if (pendingListener != this) {
+ // If we registered another discovery listener in the meantime, stop this one
+ nsdManager.stopServiceDiscovery(this);
+ return;
+ }
+
+ pendingListener = null;
+ activeListener = this;
+ }
+ }
+
+ @Override
+ public void onDiscoveryStopped(String serviceType) {
+ LimeLog.info("NSD: Service discovery stopped");
+
+ synchronized (listenerLock) {
+ if (activeListener != this) {
+ return;
+ }
+
+ activeListener = null;
+ }
+ }
+
+ @Override
+ public void onServiceFound(NsdServiceInfo nsdServiceInfo) {
+ // Protect against racing stopDiscovery() call
+ synchronized (listenerLock) {
+ // Ignore callbacks if we're not the active listener
+ if (activeListener != this) {
+ return;
+ }
+
+ LimeLog.info("NSD: Machine appeared: " + nsdServiceInfo.getServiceName());
+
+ NsdManager.ServiceInfoCallback serviceInfoCallback = new NsdManager.ServiceInfoCallback() {
+ @Override
+ public void onServiceInfoCallbackRegistrationFailed(int errorCode) {
+ LimeLog.severe("NSD: Service info callback registration failed: " + errorCode);
+ listener.notifyDiscoveryFailure(new RuntimeException("onServiceInfoCallbackRegistrationFailed(): " + errorCode));
+ }
+
+ @Override
+ public void onServiceUpdated(NsdServiceInfo nsdServiceInfo) {
+ LimeLog.info("NSD: Machine resolved: " + nsdServiceInfo.getServiceName());
+ reportNewComputer(nsdServiceInfo.getServiceName(), nsdServiceInfo.getPort(),
+ getV4Addrs(nsdServiceInfo.getHostAddresses()),
+ getV6Addrs(nsdServiceInfo.getHostAddresses()));
+ }
+
+ @Override
+ public void onServiceLost() {
+ }
+
+ @Override
+ public void onServiceInfoCallbackUnregistered() {
+ }
+ };
+
+ nsdManager.registerServiceInfoCallback(nsdServiceInfo, executor, serviceInfoCallback);
+ serviceCallbacks.put(nsdServiceInfo.getServiceName(), serviceInfoCallback);
+ }
+ }
+
+ @Override
+ public void onServiceLost(NsdServiceInfo nsdServiceInfo) {
+ // Protect against racing stopDiscovery() call
+ synchronized (listenerLock) {
+ // Ignore callbacks if we're not the active listener
+ if (activeListener != this) {
+ return;
+ }
+
+ LimeLog.info("NSD: Machine lost: " + nsdServiceInfo.getServiceName());
+
+ NsdManager.ServiceInfoCallback serviceInfoCallback = serviceCallbacks.remove(nsdServiceInfo.getServiceName());
+ if (serviceInfoCallback != null) {
+ nsdManager.unregisterServiceInfoCallback(serviceInfoCallback);
+ }
+ }
+ }
+ };
+ }
+
+ public NsdManagerDiscoveryAgent(Context context, MdnsDiscoveryListener listener) {
+ super(listener);
+ this.nsdManager = context.getSystemService(NsdManager.class);
+ }
+
+ @Override
+ public void startDiscovery(int discoveryIntervalMs) {
+ synchronized (listenerLock) {
+ // Register a new service discovery listener if there's not already one starting or running
+ if (pendingListener == null && activeListener == null) {
+ pendingListener = createDiscoveryListener();
+ nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, pendingListener);
+ }
+ }
+ }
+
+ @Override
+ public void stopDiscovery() {
+ // Protect against racing ServiceInfoCallback and DiscoveryListener callbacks
+ synchronized (listenerLock) {
+ // Clear any pending listener to ensure the discoverStarted() callback
+ // will realize it's gone and stop itself.
+ pendingListener = null;
+
+ // Unregister the service discovery listener
+ if (activeListener != null) {
+ nsdManager.stopServiceDiscovery(activeListener);
+
+ // Even though listener stoppage is asynchronous, the listener is gone as far as
+ // we're concerned. We null this right now to ensure pending callbacks know it's
+ // stopped and startDiscovery() can immediately create a new listener. If we left
+ // it until onDiscoveryStopped() was called, startDiscovery() would get confused
+ // and assume a listener was already running, even though it's stopping.
+ activeListener = null;
+ }
+
+ // Unregister all service info callbacks
+ for (NsdManager.ServiceInfoCallback callback : serviceCallbacks.values()) {
+ nsdManager.unregisterServiceInfoCallback(callback);
+ }
+ serviceCallbacks.clear();
+ }
+ }
+
+ private static Inet4Address[] getV4Addrs(List addrs) {
+ int matchCount = 0;
+ for (InetAddress addr : addrs) {
+ if (addr instanceof Inet4Address) {
+ matchCount++;
+ }
+ }
+
+ Inet4Address[] matching = new Inet4Address[matchCount];
+
+ int i = 0;
+ for (InetAddress addr : addrs) {
+ if (addr instanceof Inet4Address) {
+ matching[i++] = (Inet4Address) addr;
+ }
+ }
+
+ return matching;
+ }
+
+ private static Inet6Address[] getV6Addrs(List addrs) {
+ int matchCount = 0;
+ for (InetAddress addr : addrs) {
+ if (addr instanceof Inet6Address) {
+ matchCount++;
+ }
+ }
+
+ Inet6Address[] matching = new Inet6Address[matchCount];
+
+ int i = 0;
+ for (InetAddress addr : addrs) {
+ if (addr instanceof Inet6Address) {
+ matching[i++] = (Inet6Address) addr;
+ }
+ }
+
+ return matching;
+ }
+}
diff --git a/app/src/main/java/com/limelight/nvstream/wol/WakeOnLanSender.java b/app/src/main/java/com/limelight/nvstream/wol/WakeOnLanSender.java
old mode 100644
new mode 100755
index 945f114b5b..0dcbc3fc65
--- a/app/src/main/java/com/limelight/nvstream/wol/WakeOnLanSender.java
+++ b/app/src/main/java/com/limelight/nvstream/wol/WakeOnLanSender.java
@@ -1,149 +1,149 @@
-package com.limelight.nvstream.wol;
-
-import java.io.IOException;
-import java.net.DatagramPacket;
-import java.net.DatagramSocket;
-import java.net.InetAddress;
-import java.util.Scanner;
-
-import com.limelight.LimeLog;
-import com.limelight.nvstream.http.ComputerDetails;
-
-public class WakeOnLanSender {
- // These ports will always be tried as-is.
- private static final int[] STATIC_PORTS_TO_TRY = new int[] {
- 9, // Standard WOL port (privileged port)
- 47009, // Port opened by Moonlight Internet Hosting Tool for WoL (non-privileged port)
- };
-
- // These ports will be offset by the base port number (47989) to support alternate ports.
- private static final int[] DYNAMIC_PORTS_TO_TRY = new int[] {
- 47998, 47999, 48000, 48002, 48010, // Ports opened by GFE
- };
-
- private static void sendPacketsForAddress(InetAddress address, int httpPort, DatagramSocket sock, byte[] payload) throws IOException {
- IOException lastException = null;
- boolean sentWolPacket = false;
-
- // Try the static ports
- for (int port : STATIC_PORTS_TO_TRY) {
- try {
- DatagramPacket dp = new DatagramPacket(payload, payload.length);
- dp.setAddress(address);
- dp.setPort(port);
- sock.send(dp);
- sentWolPacket = true;
- } catch (IOException e) {
- e.printStackTrace();
- lastException = e;
- }
- }
-
- // Try the dynamic ports
- for (int port : DYNAMIC_PORTS_TO_TRY) {
- try {
- DatagramPacket dp = new DatagramPacket(payload, payload.length);
- dp.setAddress(address);
- dp.setPort((port - 47989) + httpPort);
- sock.send(dp);
- sentWolPacket = true;
- } catch (IOException e) {
- e.printStackTrace();
- lastException = e;
- }
- }
-
- if (!sentWolPacket) {
- throw lastException;
- }
- }
-
- public static void sendWolPacket(ComputerDetails computer) throws IOException {
- byte[] payload = createWolPayload(computer);
- IOException lastException = null;
- boolean sentWolPacket = false;
-
- try (final DatagramSocket sock = new DatagramSocket(0)) {
- // Try all resolved remote and local addresses and broadcast addresses.
- // The broadcast address is required to avoid stale ARP cache entries
- // making the sleeping machine unreachable.
- for (ComputerDetails.AddressTuple address : new ComputerDetails.AddressTuple[] {
- computer.localAddress, computer.remoteAddress,
- computer.manualAddress, computer.ipv6Address,
- }) {
- if (address == null) {
- continue;
- }
-
- try {
- sendPacketsForAddress(InetAddress.getByName("255.255.255.255"), address.port, sock, payload);
- sentWolPacket = true;
- } catch (IOException e) {
- e.printStackTrace();
- lastException = e;
- }
-
- try {
- for (InetAddress resolvedAddress : InetAddress.getAllByName(address.address)) {
- try {
- sendPacketsForAddress(resolvedAddress, address.port, sock, payload);
- sentWolPacket = true;
- } catch (IOException e) {
- e.printStackTrace();
- lastException = e;
- }
- }
- } catch (IOException e) {
- // We may have addresses that don't resolve on this subnet,
- // but don't throw and exit the whole function if that happens.
- // We'll throw it at the end if we didn't send a single packet.
- e.printStackTrace();
- lastException = e;
- }
- }
- }
-
- // Propagate the DNS resolution exception if we didn't
- // manage to get a single packet out to the host.
- if (!sentWolPacket && lastException != null) {
- throw lastException;
- }
- }
-
- private static byte[] macStringToBytes(String macAddress) {
- byte[] macBytes = new byte[6];
-
- try (@SuppressWarnings("resource")
- final Scanner scan = new Scanner(macAddress).useDelimiter(":")
- ) {
- for (int i = 0; i < macBytes.length && scan.hasNext(); i++) {
- try {
- macBytes[i] = (byte) Integer.parseInt(scan.next(), 16);
- } catch (NumberFormatException e) {
- LimeLog.warning("Malformed MAC address: " + macAddress + " (index: " + i + ")");
- break;
- }
- }
- return macBytes;
- }
- }
-
- private static byte[] createWolPayload(ComputerDetails computer) {
- byte[] payload = new byte[102];
- byte[] macAddress = macStringToBytes(computer.macAddress);
- int i;
-
- // 6 bytes of FF
- for (i = 0; i < 6; i++) {
- payload[i] = (byte)0xFF;
- }
-
- // 16 repetitions of the MAC address
- for (int j = 0; j < 16; j++) {
- System.arraycopy(macAddress, 0, payload, i, macAddress.length);
- i += macAddress.length;
- }
-
- return payload;
- }
-}
+package com.limelight.nvstream.wol;
+
+import java.io.IOException;
+import java.net.DatagramPacket;
+import java.net.DatagramSocket;
+import java.net.InetAddress;
+import java.util.Scanner;
+
+import com.limelight.LimeLog;
+import com.limelight.nvstream.http.ComputerDetails;
+
+public class WakeOnLanSender {
+ // These ports will always be tried as-is.
+ private static final int[] STATIC_PORTS_TO_TRY = new int[] {
+ 9, // Standard WOL port (privileged port)
+ 47009, // Port opened by Moonlight Internet Hosting Tool for WoL (non-privileged port)
+ };
+
+ // These ports will be offset by the base port number (47989) to support alternate ports.
+ private static final int[] DYNAMIC_PORTS_TO_TRY = new int[] {
+ 47998, 47999, 48000, 48002, 48010, // Ports opened by GFE
+ };
+
+ private static void sendPacketsForAddress(InetAddress address, int httpPort, DatagramSocket sock, byte[] payload) throws IOException {
+ IOException lastException = null;
+ boolean sentWolPacket = false;
+
+ // Try the static ports
+ for (int port : STATIC_PORTS_TO_TRY) {
+ try {
+ DatagramPacket dp = new DatagramPacket(payload, payload.length);
+ dp.setAddress(address);
+ dp.setPort(port);
+ sock.send(dp);
+ sentWolPacket = true;
+ } catch (IOException e) {
+ e.printStackTrace();
+ lastException = e;
+ }
+ }
+
+ // Try the dynamic ports
+ for (int port : DYNAMIC_PORTS_TO_TRY) {
+ try {
+ DatagramPacket dp = new DatagramPacket(payload, payload.length);
+ dp.setAddress(address);
+ dp.setPort((port - 47989) + httpPort);
+ sock.send(dp);
+ sentWolPacket = true;
+ } catch (IOException e) {
+ e.printStackTrace();
+ lastException = e;
+ }
+ }
+
+ if (!sentWolPacket) {
+ throw lastException;
+ }
+ }
+
+ public static void sendWolPacket(ComputerDetails computer) throws IOException {
+ byte[] payload = createWolPayload(computer);
+ IOException lastException = null;
+ boolean sentWolPacket = false;
+
+ try (final DatagramSocket sock = new DatagramSocket(0)) {
+ // Try all resolved remote and local addresses and broadcast addresses.
+ // The broadcast address is required to avoid stale ARP cache entries
+ // making the sleeping machine unreachable.
+ for (ComputerDetails.AddressTuple address : new ComputerDetails.AddressTuple[] {
+ computer.localAddress, computer.remoteAddress,
+ computer.manualAddress, computer.ipv6Address,
+ }) {
+ if (address == null) {
+ continue;
+ }
+
+ try {
+ sendPacketsForAddress(InetAddress.getByName("255.255.255.255"), address.port, sock, payload);
+ sentWolPacket = true;
+ } catch (IOException e) {
+ e.printStackTrace();
+ lastException = e;
+ }
+
+ try {
+ for (InetAddress resolvedAddress : InetAddress.getAllByName(address.address)) {
+ try {
+ sendPacketsForAddress(resolvedAddress, address.port, sock, payload);
+ sentWolPacket = true;
+ } catch (IOException e) {
+ e.printStackTrace();
+ lastException = e;
+ }
+ }
+ } catch (IOException e) {
+ // We may have addresses that don't resolve on this subnet,
+ // but don't throw and exit the whole function if that happens.
+ // We'll throw it at the end if we didn't send a single packet.
+ e.printStackTrace();
+ lastException = e;
+ }
+ }
+ }
+
+ // Propagate the DNS resolution exception if we didn't
+ // manage to get a single packet out to the host.
+ if (!sentWolPacket && lastException != null) {
+ throw lastException;
+ }
+ }
+
+ private static byte[] macStringToBytes(String macAddress) {
+ byte[] macBytes = new byte[6];
+
+ try (@SuppressWarnings("resource")
+ final Scanner scan = new Scanner(macAddress).useDelimiter(":")
+ ) {
+ for (int i = 0; i < macBytes.length && scan.hasNext(); i++) {
+ try {
+ macBytes[i] = (byte) Integer.parseInt(scan.next(), 16);
+ } catch (NumberFormatException e) {
+ LimeLog.warning("Malformed MAC address: " + macAddress + " (index: " + i + ")");
+ break;
+ }
+ }
+ return macBytes;
+ }
+ }
+
+ private static byte[] createWolPayload(ComputerDetails computer) {
+ byte[] payload = new byte[102];
+ byte[] macAddress = macStringToBytes(computer.macAddress);
+ int i;
+
+ // 6 bytes of FF
+ for (i = 0; i < 6; i++) {
+ payload[i] = (byte)0xFF;
+ }
+
+ // 16 repetitions of the MAC address
+ for (int j = 0; j < 16; j++) {
+ System.arraycopy(macAddress, 0, payload, i, macAddress.length);
+ i += macAddress.length;
+ }
+
+ return payload;
+ }
+}
diff --git a/app/src/main/java/com/limelight/preferences/AddComputerManually.java b/app/src/main/java/com/limelight/preferences/AddComputerManually.java
old mode 100644
new mode 100755
index febb655892..05b021bddc
--- a/app/src/main/java/com/limelight/preferences/AddComputerManually.java
+++ b/app/src/main/java/com/limelight/preferences/AddComputerManually.java
@@ -1,323 +1,373 @@
-package com.limelight.preferences;
-
-import java.io.IOException;
-import java.net.Inet4Address;
-import java.net.InetAddress;
-import java.net.InterfaceAddress;
-import java.net.NetworkInterface;
-import java.net.SocketException;
-import java.net.URI;
-import java.net.URISyntaxException;
-import java.net.UnknownHostException;
-import java.util.Collections;
-import java.util.concurrent.LinkedBlockingQueue;
-
-import com.limelight.binding.PlatformBinding;
-import com.limelight.computers.ComputerManagerService;
-import com.limelight.R;
-import com.limelight.nvstream.http.ComputerDetails;
-import com.limelight.nvstream.http.NvHTTP;
-import com.limelight.nvstream.jni.MoonBridge;
-import com.limelight.utils.Dialog;
-import com.limelight.utils.ServerHelper;
-import com.limelight.utils.SpinnerDialog;
-import com.limelight.utils.UiHelper;
-
-import android.app.Activity;
-import android.app.Service;
-import android.content.ComponentName;
-import android.content.Context;
-import android.content.Intent;
-import android.content.ServiceConnection;
-import android.os.Bundle;
-import android.os.IBinder;
-import android.view.KeyEvent;
-import android.view.View;
-import android.view.inputmethod.EditorInfo;
-import android.view.inputmethod.InputMethodManager;
-import android.widget.TextView;
-import android.widget.Toast;
-
-public class AddComputerManually extends Activity {
- private TextView hostText;
- private ComputerManagerService.ComputerManagerBinder managerBinder;
- private final LinkedBlockingQueue computersToAdd = new LinkedBlockingQueue<>();
- private Thread addThread;
- private final ServiceConnection serviceConnection = new ServiceConnection() {
- public void onServiceConnected(ComponentName className, final IBinder binder) {
- managerBinder = ((ComputerManagerService.ComputerManagerBinder)binder);
- startAddThread();
- }
-
- public void onServiceDisconnected(ComponentName className) {
- joinAddThread();
- managerBinder = null;
- }
- };
-
- private boolean isWrongSubnetSiteLocalAddress(String address) {
- try {
- InetAddress targetAddress = InetAddress.getByName(address);
- if (!(targetAddress instanceof Inet4Address) || !targetAddress.isSiteLocalAddress()) {
- return false;
- }
-
- // We have a site-local address. Look for a matching local interface.
- for (NetworkInterface iface : Collections.list(NetworkInterface.getNetworkInterfaces())) {
- for (InterfaceAddress addr : iface.getInterfaceAddresses()) {
- if (!(addr.getAddress() instanceof Inet4Address) || !addr.getAddress().isSiteLocalAddress()) {
- // Skip non-site-local or non-IPv4 addresses
- continue;
- }
-
- byte[] targetAddrBytes = targetAddress.getAddress();
- byte[] ifaceAddrBytes = addr.getAddress().getAddress();
-
- // Compare prefix to ensure it's the same
- boolean addressMatches = true;
- for (int i = 0; i < addr.getNetworkPrefixLength(); i++) {
- if ((ifaceAddrBytes[i / 8] & (1 << (i % 8))) != (targetAddrBytes[i / 8] & (1 << (i % 8)))) {
- addressMatches = false;
- break;
- }
- }
-
- if (addressMatches) {
- return false;
- }
- }
- }
-
- // Couldn't find a matching interface
- return true;
- } catch (Exception e) {
- // Catch all exceptions because some broken Android devices
- // will throw an NPE from inside getNetworkInterfaces().
- e.printStackTrace();
- return false;
- }
- }
-
- private URI parseRawUserInputToUri(String rawUserInput) {
- try {
- // Try adding a scheme and parsing the remaining input.
- // This handles input like 127.0.0.1:47989, [::1], [::1]:47989, and 127.0.0.1.
- URI uri = new URI("moonlight://" + rawUserInput);
- if (uri.getHost() != null && !uri.getHost().isEmpty()) {
- return uri;
- }
- } catch (URISyntaxException ignored) {}
-
- try {
- // Attempt to escape the input as an IPv6 literal.
- // This handles input like ::1.
- URI uri = new URI("moonlight://[" + rawUserInput + "]");
- if (uri.getHost() != null && !uri.getHost().isEmpty()) {
- return uri;
- }
- } catch (URISyntaxException ignored) {}
-
- return null;
- }
-
- private void doAddPc(String rawUserInput) throws InterruptedException {
- boolean wrongSiteLocal = false;
- boolean invalidInput = false;
- boolean success;
- int portTestResult;
-
- SpinnerDialog dialog = SpinnerDialog.displayDialog(this, getResources().getString(R.string.title_add_pc),
- getResources().getString(R.string.msg_add_pc), false);
-
- try {
- ComputerDetails details = new ComputerDetails();
-
- // Check if we parsed a host address successfully
- URI uri = parseRawUserInputToUri(rawUserInput);
- if (uri != null && uri.getHost() != null && !uri.getHost().isEmpty()) {
- String host = uri.getHost();
- int port = uri.getPort();
-
- // If a port was not specified, use the default
- if (port == -1) {
- port = NvHTTP.DEFAULT_HTTP_PORT;
- }
-
- details.manualAddress = new ComputerDetails.AddressTuple(host, port);
- success = managerBinder.addComputerBlocking(details);
- if (!success){
- wrongSiteLocal = isWrongSubnetSiteLocalAddress(host);
- }
- } else {
- // Invalid user input
- success = false;
- invalidInput = true;
- }
- } catch (InterruptedException e) {
- // Propagate the InterruptedException to the caller for proper handling
- dialog.dismiss();
- throw e;
- } catch (IllegalArgumentException e) {
- // This can be thrown from OkHttp if the host fails to canonicalize to a valid name.
- // https://github.com/square/okhttp/blob/okhttp_27/okhttp/src/main/java/com/squareup/okhttp/HttpUrl.java#L705
- e.printStackTrace();
- success = false;
- invalidInput = true;
- }
-
- // Keep the SpinnerDialog open while testing connectivity
- if (!success && !wrongSiteLocal && !invalidInput) {
- // Run the test before dismissing the spinner because it can take a few seconds.
- portTestResult = MoonBridge.testClientConnectivity(ServerHelper.CONNECTION_TEST_SERVER, 443,
- MoonBridge.ML_PORT_FLAG_TCP_47984 | MoonBridge.ML_PORT_FLAG_TCP_47989);
- } else {
- // Don't bother with the test if we succeeded or the IP address was bogus
- portTestResult = MoonBridge.ML_TEST_RESULT_INCONCLUSIVE;
- }
-
- dialog.dismiss();
-
- if (invalidInput) {
- Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), getResources().getString(R.string.addpc_unknown_host), false);
- }
- else if (wrongSiteLocal) {
- Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), getResources().getString(R.string.addpc_wrong_sitelocal), false);
- }
- else if (!success) {
- String dialogText;
- if (portTestResult != MoonBridge.ML_TEST_RESULT_INCONCLUSIVE && portTestResult != 0) {
- dialogText = getResources().getString(R.string.nettest_text_blocked);
- }
- else {
- dialogText = getResources().getString(R.string.addpc_fail);
- }
- Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), dialogText, false);
- }
- else {
- AddComputerManually.this.runOnUiThread(new Runnable() {
- @Override
- public void run() {
- Toast.makeText(AddComputerManually.this, getResources().getString(R.string.addpc_success), Toast.LENGTH_LONG).show();
-
- if (!isFinishing()) {
- // Close the activity
- AddComputerManually.this.finish();
- }
- }
- });
- }
-
- }
-
- private void startAddThread() {
- addThread = new Thread() {
- @Override
- public void run() {
- while (!isInterrupted()) {
- try {
- String computer = computersToAdd.take();
- doAddPc(computer);
- } catch (InterruptedException e) {
- return;
- }
- }
- }
- };
- addThread.setName("UI - AddComputerManually");
- addThread.start();
- }
-
- private void joinAddThread() {
- if (addThread != null) {
- addThread.interrupt();
-
- try {
- addThread.join();
- } catch (InterruptedException e) {
- e.printStackTrace();
-
- // InterruptedException clears the thread's interrupt status. Since we can't
- // handle that here, we will re-interrupt the thread to set the interrupt
- // status back to true.
- Thread.currentThread().interrupt();
- }
-
- addThread = null;
- }
- }
-
- @Override
- protected void onStop() {
- super.onStop();
-
- Dialog.closeDialogs();
- SpinnerDialog.closeDialogs(this);
- }
-
- @Override
- protected void onDestroy() {
- super.onDestroy();
-
- if (managerBinder != null) {
- joinAddThread();
- unbindService(serviceConnection);
- }
- }
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
-
- UiHelper.setLocale(this);
-
- setContentView(R.layout.activity_add_computer_manually);
-
- UiHelper.notifyNewRootView(this);
-
- this.hostText = findViewById(R.id.hostTextView);
- hostText.setImeOptions(EditorInfo.IME_ACTION_DONE);
- hostText.setOnEditorActionListener(new TextView.OnEditorActionListener() {
- @Override
- public boolean onEditorAction(TextView textView, int actionId, KeyEvent keyEvent) {
- if (actionId == EditorInfo.IME_ACTION_DONE ||
- (keyEvent != null &&
- keyEvent.getAction() == KeyEvent.ACTION_DOWN &&
- keyEvent.getKeyCode() == KeyEvent.KEYCODE_ENTER)) {
- return handleDoneEvent();
- }
- else if (actionId == EditorInfo.IME_ACTION_PREVIOUS) {
- // This is how the Fire TV dismisses the keyboard
- InputMethodManager imm = (InputMethodManager)getSystemService(Context.INPUT_METHOD_SERVICE);
- imm.hideSoftInputFromWindow(hostText.getWindowToken(), 0);
- return false;
- }
-
- return false;
- }
- });
-
- findViewById(R.id.addPcButton).setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View view) {
- handleDoneEvent();
- }
- });
-
- // Bind to the ComputerManager service
- bindService(new Intent(AddComputerManually.this,
- ComputerManagerService.class), serviceConnection, Service.BIND_AUTO_CREATE);
- }
-
- // Returns true if the event should be eaten
- private boolean handleDoneEvent() {
- String hostAddress = hostText.getText().toString().trim();
-
- if (hostAddress.length() == 0) {
- Toast.makeText(AddComputerManually.this, getResources().getString(R.string.addpc_enter_ip), Toast.LENGTH_LONG).show();
- return true;
- }
-
- computersToAdd.add(hostAddress);
- return false;
- }
-}
+package com.limelight.preferences;
+
+import java.io.IOException;
+import java.net.Inet4Address;
+import java.net.InetAddress;
+import java.net.InterfaceAddress;
+import java.net.NetworkInterface;
+import java.net.SocketException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.UnknownHostException;
+import java.util.Collections;
+import java.util.concurrent.LinkedBlockingQueue;
+
+import com.limelight.LimeLog;
+import com.limelight.PcView;
+import com.limelight.binding.PlatformBinding;
+import com.limelight.computers.ComputerManagerService;
+import com.limelight.R;
+import com.limelight.nvstream.http.ComputerDetails;
+import com.limelight.nvstream.http.NvHTTP;
+import com.limelight.nvstream.jni.MoonBridge;
+import com.limelight.utils.Dialog;
+import com.limelight.utils.ServerHelper;
+import com.limelight.utils.SpinnerDialog;
+import com.limelight.utils.UiHelper;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Service;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.TextView;
+import android.widget.Toast;
+
+public class AddComputerManually extends Activity {
+ private TextView hostText;
+ private ComputerManagerService.ComputerManagerBinder managerBinder;
+ private final LinkedBlockingQueue computersToAdd = new LinkedBlockingQueue<>();
+ private Thread addThread;
+ private final ServiceConnection serviceConnection = new ServiceConnection() {
+ public void onServiceConnected(ComponentName className, final IBinder binder) {
+ managerBinder = ((ComputerManagerService.ComputerManagerBinder)binder);
+ startAddThread();
+ }
+
+ public void onServiceDisconnected(ComponentName className) {
+ joinAddThread();
+ managerBinder = null;
+ }
+ };
+
+ private boolean isWrongSubnetSiteLocalAddress(String address) {
+ try {
+ InetAddress targetAddress = InetAddress.getByName(address);
+ if (!(targetAddress instanceof Inet4Address) || !targetAddress.isSiteLocalAddress()) {
+ return false;
+ }
+
+ // We have a site-local address. Look for a matching local interface.
+ for (NetworkInterface iface : Collections.list(NetworkInterface.getNetworkInterfaces())) {
+ for (InterfaceAddress addr : iface.getInterfaceAddresses()) {
+ if (!(addr.getAddress() instanceof Inet4Address) || !addr.getAddress().isSiteLocalAddress()) {
+ // Skip non-site-local or non-IPv4 addresses
+ continue;
+ }
+
+ byte[] targetAddrBytes = targetAddress.getAddress();
+ byte[] ifaceAddrBytes = addr.getAddress().getAddress();
+
+ // Compare prefix to ensure it's the same
+ boolean addressMatches = true;
+ for (int i = 0; i < addr.getNetworkPrefixLength(); i++) {
+ if ((ifaceAddrBytes[i / 8] & (1 << (i % 8))) != (targetAddrBytes[i / 8] & (1 << (i % 8)))) {
+ addressMatches = false;
+ break;
+ }
+ }
+
+ if (addressMatches) {
+ return false;
+ }
+ }
+ }
+
+ // Couldn't find a matching interface
+ return true;
+ } catch (Exception e) {
+ // Catch all exceptions because some broken Android devices
+ // will throw an NPE from inside getNetworkInterfaces().
+ e.printStackTrace();
+ return false;
+ }
+ }
+
+ private Uri parseRawUserInputToUri(String rawUserInput) {
+ // Try adding a scheme and parsing the remaining input.
+ // This handles input like 127.0.0.1:47989, [::1], [::1]:47989, and 127.0.0.1.
+ Uri uri = Uri.parse("art://" + rawUserInput);
+ if (uri.getHost() != null && !uri.getHost().isEmpty()) {
+ return uri;
+ }
+
+ // Attempt to escape the input as an IPv6 literal.
+ // This handles input like ::1.
+ uri = Uri.parse("art://[" + rawUserInput + "]");
+ if (uri.getHost() != null && !uri.getHost().isEmpty()) {
+ return uri;
+ }
+
+ return null;
+ }
+
+ private void doAddPc(String rawUserInput) throws InterruptedException {
+ boolean wrongSiteLocal = false;
+ boolean invalidInput = false;
+ boolean success;
+ int portTestResult;
+
+ SpinnerDialog dialog = SpinnerDialog.displayDialog(this, getResources().getString(R.string.title_add_pc),
+ getResources().getString(R.string.msg_add_pc), false);
+
+ Uri uri = parseRawUserInputToUri(rawUserInput);
+ try {
+ ComputerDetails details = new ComputerDetails();
+
+ // Check if we parsed a host address successfully
+ if (uri != null && uri.getHost() != null && !uri.getHost().isEmpty()) {
+ String host = uri.getHost();
+ int port = uri.getPort();
+
+ // If a port was not specified, use the default
+ if (port == -1) {
+ port = NvHTTP.DEFAULT_HTTP_PORT;
+ }
+
+ details.manualAddress = new ComputerDetails.AddressTuple(host, port);
+ success = managerBinder.addComputerBlocking(details);
+ if (!success){
+ wrongSiteLocal = isWrongSubnetSiteLocalAddress(host);
+ }
+ } else {
+ // Invalid user input
+ success = false;
+ invalidInput = true;
+ }
+ } catch (InterruptedException e) {
+ // Propagate the InterruptedException to the caller for proper handling
+ dialog.dismiss();
+ throw e;
+ } catch (IllegalArgumentException e) {
+ // This can be thrown from OkHttp if the host fails to canonicalize to a valid name.
+ // https://github.com/square/okhttp/blob/okhttp_27/okhttp/src/main/java/com/squareup/okhttp/HttpUrl.java#L705
+ e.printStackTrace();
+ success = false;
+ invalidInput = true;
+ }
+
+ // Keep the SpinnerDialog open while testing connectivity
+ if (!success && !wrongSiteLocal && !invalidInput) {
+ // Run the test before dismissing the spinner because it can take a few seconds.
+ portTestResult = MoonBridge.testClientConnectivity(ServerHelper.CONNECTION_TEST_SERVER, 443,
+ MoonBridge.ML_PORT_FLAG_TCP_47984 | MoonBridge.ML_PORT_FLAG_TCP_47989);
+ } else {
+ // Don't bother with the test if we succeeded or the IP address was bogus
+ portTestResult = MoonBridge.ML_TEST_RESULT_INCONCLUSIVE;
+ }
+
+ dialog.dismiss();
+
+ if (invalidInput) {
+ Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), getResources().getString(R.string.addpc_unknown_host), false);
+ }
+ else if (wrongSiteLocal) {
+ Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), getResources().getString(R.string.addpc_wrong_sitelocal), false);
+ }
+ else if (!success) {
+ String dialogText;
+ if (portTestResult != MoonBridge.ML_TEST_RESULT_INCONCLUSIVE && portTestResult != 0) {
+ dialogText = getResources().getString(R.string.nettest_text_blocked);
+ }
+ else {
+ dialogText = getResources().getString(R.string.addpc_fail);
+ }
+ Dialog.displayDialog(this, getResources().getString(R.string.conn_error_title), dialogText, false);
+ }
+ else {
+ AddComputerManually.this.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ Toast.makeText(AddComputerManually.this, getResources().getString(R.string.addpc_success), Toast.LENGTH_LONG).show();
+
+ if (!isFinishing()) {
+ // Close the activity
+ AddComputerManually.this.finish();
+ }
+
+ String pin = uri.getQueryParameter("pin");
+ String passphrase = uri.getQueryParameter("passphrase");
+ if (pin != null && passphrase != null) {
+ Intent intent = new Intent(AddComputerManually.this, PcView.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK);
+ intent.putExtra("hostname", uri.getHost());
+ intent.putExtra("port", uri.getPort());
+ intent.putExtra("pin", pin);
+ intent.putExtra("passphrase", passphrase);
+
+ startActivity(intent);
+ }
+
+ }
+ });
+ }
+
+ }
+
+ private void startAddThread() {
+ addThread = new Thread() {
+ @Override
+ public void run() {
+ while (!isInterrupted()) {
+ try {
+ String computer = computersToAdd.take();
+ doAddPc(computer);
+ } catch (InterruptedException e) {
+ return;
+ }
+ }
+ }
+ };
+ addThread.setName("UI - AddComputerManually");
+ addThread.start();
+ }
+
+ private void joinAddThread() {
+ if (addThread != null) {
+ addThread.interrupt();
+
+ try {
+ addThread.join();
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+
+ // InterruptedException clears the thread's interrupt status. Since we can't
+ // handle that here, we will re-interrupt the thread to set the interrupt
+ // status back to true.
+ Thread.currentThread().interrupt();
+ }
+
+ addThread = null;
+ }
+ }
+
+ @Override
+ protected void onStop() {
+ super.onStop();
+
+ Dialog.closeDialogs();
+ SpinnerDialog.closeDialogs(this);
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+
+ if (managerBinder != null) {
+ joinAddThread();
+ unbindService(serviceConnection);
+ }
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ UiHelper.setLocale(this);
+
+ setContentView(R.layout.activity_add_computer_manually);
+
+ UiHelper.notifyNewRootView(this);
+
+ this.hostText = findViewById(R.id.hostTextView);
+ hostText.setImeOptions(EditorInfo.IME_ACTION_DONE);
+ hostText.setOnEditorActionListener(new TextView.OnEditorActionListener() {
+ @Override
+ public boolean onEditorAction(TextView textView, int actionId, KeyEvent keyEvent) {
+ if (actionId == EditorInfo.IME_ACTION_DONE ||
+ (keyEvent != null &&
+ keyEvent.getAction() == KeyEvent.ACTION_DOWN &&
+ keyEvent.getKeyCode() == KeyEvent.KEYCODE_ENTER)) {
+ return handleDoneEvent();
+ }
+ else if (actionId == EditorInfo.IME_ACTION_PREVIOUS) {
+ // This is how the Fire TV dismisses the keyboard
+ InputMethodManager imm = (InputMethodManager)getSystemService(Context.INPUT_METHOD_SERVICE);
+ imm.hideSoftInputFromWindow(hostText.getWindowToken(), 0);
+ return false;
+ }
+
+ return false;
+ }
+ });
+
+ findViewById(R.id.addPcButton).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ handleDoneEvent();
+ }
+ });
+
+ // Bind to the ComputerManager service
+ bindService(new Intent(AddComputerManually.this,
+ ComputerManagerService.class), serviceConnection, Service.BIND_AUTO_CREATE);
+
+
+ // Check if we have been called from deep link
+ Uri data = getIntent().getData();
+ if (data == null) {
+ return;
+ }
+
+ String server = data.getAuthority();
+ String query = data.getQuery();
+
+ hostText.setText(server);
+
+ if (query != null && !query.isEmpty()) {
+ String hostName = data.getQueryParameter("name");
+ if (hostName != null && !hostName.isEmpty()) {
+ hostName = hostName + " (" + server + ")";
+ } else {
+ hostName = server;
+ }
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setTitle(R.string.pair_pc_confirm_title);
+ builder.setMessage(getString(R.string.pair_pc_confirm_message, hostName));
+
+ builder.setPositiveButton(getString(R.string.proceed), (dialog, which) -> {
+ dialog.dismiss();
+ finish();
+ computersToAdd.add(server + '?' + query);
+ });
+
+ builder.setNegativeButton(getString(R.string.cancel), (dialog, which) -> dialog.dismiss());
+
+ AlertDialog dialog = builder.create();
+ dialog.show();
+ }
+ }
+
+ // Returns true if the event should be eaten
+ private boolean handleDoneEvent() {
+ String hostAddress = hostText.getText().toString().trim();
+
+ if (hostAddress.length() == 0) {
+ Toast.makeText(AddComputerManually.this, getResources().getString(R.string.addpc_enter_ip), Toast.LENGTH_LONG).show();
+ return true;
+ }
+
+ computersToAdd.add(hostAddress);
+ return false;
+ }
+}
diff --git a/app/src/main/java/com/limelight/preferences/ConfirmDeleteKeyboardPreference.java b/app/src/main/java/com/limelight/preferences/ConfirmDeleteKeyboardPreference.java
new file mode 100755
index 0000000000..809b9384ee
--- /dev/null
+++ b/app/src/main/java/com/limelight/preferences/ConfirmDeleteKeyboardPreference.java
@@ -0,0 +1,59 @@
+package com.limelight.preferences;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.Build;
+import android.os.Bundle;
+import android.util.AttributeSet;
+import android.widget.Toast;
+
+import com.limelight.R;
+import com.limelight.binding.input.virtual_controller.VirtualControllerConfigurationLoader;
+
+import static com.limelight.binding.input.virtual_controller.keyboard.KeyBoardControllerConfigurationLoader.OSC_PREFERENCE;
+import static com.limelight.binding.input.virtual_controller.keyboard.KeyBoardControllerConfigurationLoader.OSC_PREFERENCE_VALUE;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.preference.DialogPreference;
+import androidx.preference.PreferenceDialogFragmentCompat;
+import androidx.preference.PreferenceManager;
+
+public class ConfirmDeleteKeyboardPreference extends DialogPreference {
+
+ public ConfirmDeleteKeyboardPreference(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ public ConfirmDeleteKeyboardPreference(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public ConfirmDeleteKeyboardPreference(@NonNull Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public ConfirmDeleteKeyboardPreference(@NonNull Context context) {
+ super(context);
+ }
+
+ public static class DialogFragmentCompat extends PreferenceDialogFragmentCompat {
+ public static DialogFragmentCompat newInstance(String key) {
+ final DialogFragmentCompat fragment = new DialogFragmentCompat();
+ final Bundle bundle = new Bundle(1);
+ bundle.putString(ARG_KEY, key);
+ fragment.setArguments(bundle);
+ return fragment;
+ }
+
+ @Override
+ public void onDialogClosed(boolean positiveResult) {
+ if (positiveResult) {
+ String name= PreferenceManager.getDefaultSharedPreferences(getContext()).getString(OSC_PREFERENCE,OSC_PREFERENCE_VALUE);
+ getContext().getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear().apply();
+ Toast.makeText(getContext(), R.string.toast_reset_osc_success, Toast.LENGTH_SHORT).show();
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/preferences/ConfirmDeleteOscPreference.java b/app/src/main/java/com/limelight/preferences/ConfirmDeleteOscPreference.java
old mode 100644
new mode 100755
index 01f5c34073..fefeaa694e
--- a/app/src/main/java/com/limelight/preferences/ConfirmDeleteOscPreference.java
+++ b/app/src/main/java/com/limelight/preferences/ConfirmDeleteOscPreference.java
@@ -1,38 +1,51 @@
-package com.limelight.preferences;
-
-import android.annotation.TargetApi;
-import android.content.Context;
-import android.content.DialogInterface;
-import android.os.Build;
-import android.preference.DialogPreference;
-import android.util.AttributeSet;
-import android.widget.Toast;
-
-import com.limelight.R;
-
-import static com.limelight.binding.input.virtual_controller.VirtualControllerConfigurationLoader.OSC_PREFERENCE;
-
-public class ConfirmDeleteOscPreference extends DialogPreference {
- public ConfirmDeleteOscPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
- super(context, attrs, defStyleAttr, defStyleRes);
- }
-
- public ConfirmDeleteOscPreference(Context context, AttributeSet attrs, int defStyleAttr) {
- super(context, attrs, defStyleAttr);
- }
-
- public ConfirmDeleteOscPreference(Context context, AttributeSet attrs) {
- super(context, attrs);
- }
-
- public ConfirmDeleteOscPreference(Context context) {
- super(context);
- }
-
- public void onClick(DialogInterface dialog, int which) {
- if (which == DialogInterface.BUTTON_POSITIVE) {
- getContext().getSharedPreferences(OSC_PREFERENCE, Context.MODE_PRIVATE).edit().clear().apply();
- Toast.makeText(getContext(), R.string.toast_reset_osc_success, Toast.LENGTH_SHORT).show();
- }
- }
-}
+package com.limelight.preferences;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.util.AttributeSet;
+import android.widget.Toast;
+
+import com.limelight.R;
+
+import static com.limelight.binding.input.virtual_controller.VirtualControllerConfigurationLoader.OSC_PREFERENCE;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.preference.DialogPreference;
+import androidx.preference.PreferenceDialogFragmentCompat;
+
+public class ConfirmDeleteOscPreference extends DialogPreference {
+ public ConfirmDeleteOscPreference(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ public ConfirmDeleteOscPreference(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public ConfirmDeleteOscPreference(@NonNull Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public ConfirmDeleteOscPreference(@NonNull Context context) {
+ super(context);
+ }
+
+ public static class DialogFragmentCompat extends PreferenceDialogFragmentCompat {
+ public static DialogFragmentCompat newInstance(String key) {
+ final DialogFragmentCompat fragment = new DialogFragmentCompat();
+ final Bundle bundle = new Bundle(1);
+ bundle.putString(ARG_KEY, key);
+ fragment.setArguments(bundle);
+ return fragment;
+ }
+
+ @Override
+ public void onDialogClosed(boolean positiveResult) {
+ if (positiveResult) {
+ getContext().getSharedPreferences(OSC_PREFERENCE, Context.MODE_PRIVATE).edit().clear().apply();
+ Toast.makeText(getContext(), R.string.toast_reset_osc_success, Toast.LENGTH_SHORT).show();
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/limelight/preferences/GlPreferences.java b/app/src/main/java/com/limelight/preferences/GlPreferences.java
old mode 100644
new mode 100755
index d245f4cc8a..41f2eb6f83
--- a/app/src/main/java/com/limelight/preferences/GlPreferences.java
+++ b/app/src/main/java/com/limelight/preferences/GlPreferences.java
@@ -1,37 +1,37 @@
-package com.limelight.preferences;
-
-
-import android.content.Context;
-import android.content.SharedPreferences;
-
-public class GlPreferences {
- private static final String PREF_NAME = "GlPreferences";
-
- private static final String FINGERPRINT_PREF_STRING = "Fingerprint";
- private static final String GL_RENDERER_PREF_STRING = "Renderer";
-
- private SharedPreferences prefs;
- public String glRenderer;
- public String savedFingerprint;
-
- private GlPreferences(SharedPreferences prefs) {
- this.prefs = prefs;
- }
-
- public static GlPreferences readPreferences(Context context) {
- SharedPreferences prefs = context.getSharedPreferences(PREF_NAME, 0);
- GlPreferences glPrefs = new GlPreferences(prefs);
-
- glPrefs.glRenderer = prefs.getString(GL_RENDERER_PREF_STRING, "");
- glPrefs.savedFingerprint = prefs.getString(FINGERPRINT_PREF_STRING, "");
-
- return glPrefs;
- }
-
- public boolean writePreferences() {
- return prefs.edit()
- .putString(GL_RENDERER_PREF_STRING, glRenderer)
- .putString(FINGERPRINT_PREF_STRING, savedFingerprint)
- .commit();
- }
-}
+package com.limelight.preferences;
+
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+public class GlPreferences {
+ private static final String PREF_NAME = "GlPreferences";
+
+ private static final String FINGERPRINT_PREF_STRING = "Fingerprint";
+ private static final String GL_RENDERER_PREF_STRING = "Renderer";
+
+ private SharedPreferences prefs;
+ public String glRenderer;
+ public String savedFingerprint;
+
+ private GlPreferences(SharedPreferences prefs) {
+ this.prefs = prefs;
+ }
+
+ public static GlPreferences readPreferences(Context context) {
+ SharedPreferences prefs = context.getSharedPreferences(PREF_NAME, 0);
+ GlPreferences glPrefs = new GlPreferences(prefs);
+
+ glPrefs.glRenderer = prefs.getString(GL_RENDERER_PREF_STRING, "");
+ glPrefs.savedFingerprint = prefs.getString(FINGERPRINT_PREF_STRING, "");
+
+ return glPrefs;
+ }
+
+ public boolean writePreferences() {
+ return prefs.edit()
+ .putString(GL_RENDERER_PREF_STRING, glRenderer)
+ .putString(FINGERPRINT_PREF_STRING, savedFingerprint)
+ .commit();
+ }
+}
diff --git a/app/src/main/java/com/limelight/preferences/LanguagePreference.java b/app/src/main/java/com/limelight/preferences/LanguagePreference.java
old mode 100644
new mode 100755
index 6549b01512..588fb9088b
--- a/app/src/main/java/com/limelight/preferences/LanguagePreference.java
+++ b/app/src/main/java/com/limelight/preferences/LanguagePreference.java
@@ -1,49 +1,50 @@
-package com.limelight.preferences;
-
-import android.annotation.TargetApi;
-import android.content.ActivityNotFoundException;
-import android.content.Context;
-import android.content.Intent;
-import android.net.Uri;
-import android.os.Build;
-import android.preference.ListPreference;
-import android.provider.Settings;
-import android.util.AttributeSet;
-
-public class LanguagePreference extends ListPreference {
- public LanguagePreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
- super(context, attrs, defStyleAttr, defStyleRes);
- }
-
- public LanguagePreference(Context context, AttributeSet attrs, int defStyleAttr) {
- super(context, attrs, defStyleAttr);
- }
-
- public LanguagePreference(Context context, AttributeSet attrs) {
- super(context, attrs);
- }
-
- public LanguagePreference(Context context) {
- super(context);
- }
-
- @Override
- protected void onClick() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- try {
- // Launch the Android native app locale settings page
- Intent intent = new Intent(Settings.ACTION_APP_LOCALE_SETTINGS);
- intent.addCategory(Intent.CATEGORY_DEFAULT);
- intent.setData(Uri.parse("package:" + getContext().getPackageName()));
- getContext().startActivity(intent, null);
- return;
- } catch (ActivityNotFoundException e) {
- // App locale settings should be present on all Android 13 devices,
- // but if not, we'll launch the old language chooser.
- }
- }
-
- // If we don't have native app locale settings, launch the normal dialog
- super.onClick();
- }
-}
+package com.limelight.preferences;
+
+import android.app.Activity;
+import android.content.Context;
+import android.util.AttributeSet;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.preference.ListPreference;
+
+import com.limelight.utils.UiHelper;
+
+public class LanguagePreference extends ListPreference {
+
+ public LanguagePreference(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ public LanguagePreference(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public LanguagePreference(@NonNull Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public LanguagePreference(@NonNull Context context) {
+ super(context);
+ }
+
+// @Override
+// protected void onClick() {
+// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+// try {
+// // Launch the Android native app locale settings page
+// Intent intent = new Intent(Settings.ACTION_APP_LOCALE_SETTINGS);
+// intent.addCategory(Intent.CATEGORY_DEFAULT);
+// intent.setData(Uri.parse("package:" + getContext().getPackageName()));
+// getContext().startActivity(intent, null);
+// return;
+// } catch (ActivityNotFoundException e) {
+// // App locale settings should be present on all Android 13 devices,
+// // but if not, we'll launch the old language chooser.
+// }
+// }
+//
+// // If we don't have native app locale settings, launch the normal dialog
+// super.onClick();
+// }
+}
diff --git a/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java b/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java
old mode 100644
new mode 100755
index 8ed01e3610..67f669e060
--- a/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java
+++ b/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java
@@ -4,12 +4,19 @@
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.os.Build;
-import android.preference.PreferenceManager;
import android.view.Display;
+import androidx.preference.PreferenceManager;
+
import com.limelight.nvstream.jni.MoonBridge;
public class PreferenceConfiguration {
+ public enum ScaleMode {
+ FIT,
+ FILL,
+ STRETCH
+ }
+
public enum FormatOption {
AUTO,
FORCE_AV1,
@@ -23,14 +30,27 @@ public enum AnalogStickForScrolling {
LEFT
}
+ public static final String CUSTOM_BITRATE_PREF_STRING = "edit_diy_bitrate";
+ public static final String CUSTOM_REFRESH_RATE_PREF_STRING = "custom_refresh_rate";
+ public static final String CUSTOM_RESOLUTION_PREF_STRING = "edit_diy_w_h";
+
private static final String LEGACY_RES_FPS_PREF_STRING = "list_resolution_fps";
private static final String LEGACY_ENABLE_51_SURROUND_PREF_STRING = "checkbox_51_surround";
+ private static final String LEGACY_STRETCH_PREF_STRING = "checkbox_stretch_video";
+ private static final String LEGACY_ENFORCE_REFRESH_RATE_STRING = "checkbox_enforce_refresh_rate";
static final String RESOLUTION_PREF_STRING = "list_resolution";
static final String FPS_PREF_STRING = "list_fps";
static final String BITRATE_PREF_STRING = "seekbar_bitrate_kbps";
private static final String BITRATE_PREF_OLD_STRING = "seekbar_bitrate";
- private static final String STRETCH_PREF_STRING = "checkbox_stretch_video";
+ private static final String METERED_BITRATE_PREF_STRING = "seekbar_metered_bitrate_kbps";
+ private static final String ENABLE_ULTRA_LOW_LATENCY_PREF_STRING = "checkbox_ultra_low_latency";
+ private static final String ENFORCE_DISPLAY_MODE_PREF_STRING = "checkbox_enforce_display_mode";
+ private static final String USE_VIRTUAL_DISPLAY_PREF_STRING = "checkbox_use_virtual_display";
+ private static final String AUTO_INVERT_VIDEO_RESOLUTION_PREF_STRING = "checkbox_auto_invert_video_resolution";
+ private static final String RESOLUTION_SCALE_FACTOR_PREF_STRING = "seekbar_resolution_scale_factor";
+ private static final String RESUME_WITHOUT_CONFIRM_PREF_STRING = "checkbox_resume_without_confirm";
+ private static final String VIDEO_SCALE_MODE_PREF_STRING = "list_video_scale_mode";
private static final String SOPS_PREF_STRING = "checkbox_enable_sops";
private static final String DISABLE_TOASTS_PREF_STRING = "checkbox_disable_warnings";
private static final String HOST_AUDIO_PREF_STRING = "checkbox_host_audio";
@@ -43,6 +63,7 @@ public enum AnalogStickForScrolling {
private static final String USB_DRIVER_PREF_SRING = "checkbox_usb_driver";
private static final String VIDEO_FORMAT_PREF_STRING = "video_format";
private static final String ONSCREEN_CONTROLLER_PREF_STRING = "checkbox_show_onscreen_controls";
+ private static final String CHECKBOX_HIDE_OSC_WHEN_HAS_GAMEPAD = "checkbox_hide_osc_when_has_gamepad";
private static final String ONLY_L3_R3_PREF_STRING = "checkbox_only_show_L3R3";
private static final String SHOW_GUIDE_BUTTON_PREF_STRING = "checkbox_show_guide_button";
private static final String LEGACY_DISABLE_FRAME_DROP_PREF_STRING = "checkbox_disable_frame_drop";
@@ -58,7 +79,7 @@ public enum AnalogStickForScrolling {
private static final String VIBRATE_FALLBACK_PREF_STRING = "checkbox_vibrate_fallback";
private static final String VIBRATE_FALLBACK_STRENGTH_PREF_STRING = "seekbar_vibrate_fallback_strength";
private static final String FLIP_FACE_BUTTONS_PREF_STRING = "checkbox_flip_face_buttons";
- private static final String TOUCHSCREEN_TRACKPAD_PREF_STRING = "checkbox_touchscreen_trackpad";
+// static final String TOUCHSCREEN_TRACKPAD_PREF_STRING = "checkbox_touchscreen_trackpad";
private static final String LATENCY_TOAST_PREF_STRING = "checkbox_enable_post_stream_toast";
private static final String FRAME_PACING_PREF_STRING = "frame_pacing";
private static final String ABSOLUTE_MOUSE_MODE_PREF_STRING = "checkbox_absolute_mouse_mode";
@@ -68,21 +89,64 @@ public enum AnalogStickForScrolling {
private static final String GAMEPAD_TOUCHPAD_AS_MOUSE_PREF_STRING = "checkbox_gamepad_touchpad_as_mouse";
private static final String GAMEPAD_MOTION_SENSORS_PREF_STRING = "checkbox_gamepad_motion_sensors";
private static final String GAMEPAD_MOTION_FALLBACK_PREF_STRING = "checkbox_gamepad_motion_fallback";
+ private static final String FORCE_MOTION_SENSORS_FALLBACK_PREF_STRING = "checkbox_force_device_motion";
+
+ private static final String LIST_ONSCREEN_KEYBOARD_ALIGN_MODE = "list_onscreen_keyboard_align_mode";
+
+ private static final String CHECKBOX_ENABLE_BATTERY_REPORT = "checkbox_gamepad_enable_battery_report";
+ private static final String CHECKBOX_FORCE_QWERTY = "checkbox_force_qwerty";
+ private static final String CHECKBOX_BACK_AS_META = "checkbox_back_as_meta";
+ private static final String CHECKBOX_IGNORE_SYNTH_EVENTS = "checkbox_ignore_synth_events";
+ private static final String CHECKBOX_BACK_AS_GUIDE = "checkbox_back_as_guide";
+ private static final String CHECKBOX_SMART_CLIPBOARD_SYNC = "checkbox_smart_clipboard_sync";
+ private static final String CHECKBOX_SMART_CLIPBOARD_SYNC_TOAST = "checkbox_smart_clipboard_sync_toast";
+ private static final String CHECKBOX_HIDE_CLIPBOARD_CONTENT = "checkbox_hide_clipboard_content";
+
+ private static final String CHECKBOX_ENABLE_STICKY_MODIFIER_KEY_VIRTUAL_KEYBOARD = "checkbox_enable_sticky_modifier_key_virtual_keyboard";
+
+ //是否弹出软键盘
+ private static final String CHECKBOX_ENABLE_QUIT_DIALOG = "checkbox_enable_quit_dialog";
+
+ //竖屏模式
+ private static final String CHECKBOX_AUTO_ORIENTATION = "checkbox_auto_orientation";
+ //屏幕特殊按键
+ private static final String CHECKBOX_ENABLE_KEYBOARD = "checkbox_enable_keyboard";
+
+ //屏幕特殊按键 震动
+ private static final String CHECKBOX_ENABLE_KEYBOARD_VIBRATE = "checkbox_vibrate_keyboard";
+
+ //自动摇杆
+ private static final String CHECKBOX_CHECKBOX_ENABLE_ANALOG_STICK_NEW = "checkbox_enable_analog_stick_new";
+
+ //触控屏幕灵敏度
+ private static final String SEEKBAR_TOUCH_SENSITIVITY = "seekbar_touch_sensitivity_opacity_x";
+ private static final String SEEKBAR_TRACKPAD_SENSITIVITY_X = "seekbar_trackpad_sensitivity_x";
+ private static final String SEEKBAR_TRACKPAD_SENSITIVITY_Y = "seekbar_trackpad_sensitivity_y";
+ private static final String CHECKBOX_TRACKPAD_DRAG_DROP_VIBRATION = "checkbox_trackpad_drag_drop_vibration";
+ private static final String SEEKBAR_TRACKPAD_DRAG_DROP_THRESHOLD = "seekbar_trackpad_drag_drop_threshold";
+ private static final String CHECKBOX_TRACKPAD_SWAP_AXIS = "checkbox_trackpad_swap_axis";
static final String DEFAULT_RESOLUTION = "1280x720";
static final String DEFAULT_FPS = "60";
- private static final boolean DEFAULT_STRETCH = false;
+ private static final boolean DEFAULT_ENABLE_ULTRA_LOW_LATENCY = false;
+ private static final boolean DEFAULT_ENFORCE_DISPLAY_MODE = false;
+ private static final boolean DEFAULT_USE_VIRTUAL_DISPLAY = false;
+ private static final String DEFAULT_VIDEO_SCALE_MODE = "fit";
+ private static final boolean DEFAULT_AUTO_INVERT_VIDEO_RESOLUTION = true;
+ private static final int DEFAULT_RESOLUTION_SCALE_FACTOR = 100;
+ private static final boolean DEFAULT_RESUME_WITHOUT_CONFIRM = false;
private static final boolean DEFAULT_SOPS = true;
private static final boolean DEFAULT_DISABLE_TOASTS = false;
private static final boolean DEFAULT_HOST_AUDIO = false;
- private static final int DEFAULT_DEADZONE = 7;
+ private static final int DEFAULT_DEADZONE = 5;
private static final int DEFAULT_OPACITY = 90;
public static final String DEFAULT_LANGUAGE = "default";
private static final boolean DEFAULT_MULTI_CONTROLLER = true;
private static final boolean DEFAULT_USB_DRIVER = true;
private static final String DEFAULT_VIDEO_FORMAT = "auto";
- private static final boolean ONSCREEN_CONTROLLER_DEFAULT = false;
+ private static final boolean DEFAULT_ONSCREEN_CONTROLLER = false;
+ private static final boolean DEFAULT_HIDE_OSC_WHEN_HAS_GAMEPAD = true;
private static final boolean ONLY_L3_R3_DEFAULT = false;
private static final boolean SHOW_GUIDE_BUTTON_DEFAULT = true;
private static final boolean DEFAULT_ENABLE_HDR = false;
@@ -108,6 +172,22 @@ public enum AnalogStickForScrolling {
private static final boolean DEFAULT_GAMEPAD_TOUCHPAD_AS_MOUSE = false;
private static final boolean DEFAULT_GAMEPAD_MOTION_SENSORS = true;
private static final boolean DEFAULT_GAMEPAD_MOTION_FALLBACK = false;
+ private static final boolean DEFAULT_FORCE_MOTION_SENSORS_FALLBACK = false;
+ private static final boolean DEFAULT_GAMEPAD_ENABLE_BATTERY_REPORT = true;
+ private static final boolean DEFAULT_FORCE_QWERTY = true;
+ private static final boolean DEFAULT_SEND_META_ON_PHYSICAL_BACK = false;
+ private static final boolean DEFAULT_IGNORE_SYNTH_EVENTS = false;
+ private static final boolean DEFAULT_BACK_AS_GUIDE = false;
+ private static final boolean DEFAULT_SMART_CLIPBOARD_SYNC = false;
+ private static final boolean DEFAULT_SMART_CLIPBOARD_SYNC_TOAST = true;
+ private static final boolean DEFAULT_HIDE_CLIPBOARD_CONTENT = true;
+ private static final boolean DEFAULT_ENABLE_STICKY_MODIFIER_KEY_VIRTUAL_KEYBOARD = true;
+ private static final int DEFAULT_TRACKPAD_SENSITIVITY_X = 100;
+ private static final int DEFAULT_TRACKPAD_SENSITIVITY_Y = 100;
+ private static final boolean DEFAULT_TRACKPAD_DRAG_DROP_VIBRATION = false;
+ private static final int DEFAULT_TRACKPAD_DRAG_DROP_THRESHOLD = 250;
+ private static final boolean DEFAULT_TRACKPAD_SWAP_AXIS = false;
+ private static final String DEFAULT_ONSCREEN_KEYBOARD_ALIGN_MODE = "center";
public static final int FRAME_PACING_MIN_LATENCY = 0;
public static final int FRAME_PACING_BALANCED = 1;
@@ -122,21 +202,113 @@ public enum AnalogStickForScrolling {
public static final String RES_4K = "3840x2160";
public static final String RES_NATIVE = "Native";
- public int width, height, fps;
- public int bitrate;
+ public int width, height, bitrate;
+ public float fps;
+// public String customBitrate;
+ public boolean enableUltraLowLatency;
+ public String customResolution;
+ public String customRefreshRate;
+ public int meteredBitrate;
public FormatOption videoFormat;
+ public int framePacingWarpFactor = 0;
public int deadzonePercentage;
public int oscOpacity;
- public boolean stretchVideo, enableSops, playHostAudio, disableWarnings;
+ public int oscKeyboardOpacity;
+ public int onscreenKeyboardHeight;
+ public int onscreenKeyboardWidth;
+ public String onscreenKeyboardAlignMode;
+ public boolean enforceDisplayMode, useVirtualDisplay, enableSops, playHostAudio, disableWarnings;
+ public ScaleMode videoScaleMode;
public String language;
public boolean smallIconMode, multiController, usbDriver, flipFaceButtons;
public boolean onscreenController;
+ public boolean hideOSCWhenHasGamepad;
+ public boolean enableBatteryReport;
+ public boolean forceQwerty;
+ public boolean backAsMeta;
+ public boolean ignoreSynthEvents;
+ public boolean backAsGuide;
+ public boolean smartClipboardSync;
+ public boolean smartClipboardSyncToast;
+ public boolean hideClipboardContent;
+ public boolean stickyModifierKey;
public boolean onlyL3R3;
public boolean showGuideButton;
public boolean enableHdr;
public boolean enablePip;
public boolean enablePerfOverlay;
+ //简化版性能信息
+ public boolean enablePerfOverlayLite;
+
+ public boolean enablePerfOverlayLiteDialog;
+
public boolean enableLatencyToast;
+ //软键盘
+ public boolean enableBackMenu;
+ //Invert video width/height
+ public boolean autoInvertVideoResolution;
+ public int resolutionScaleFactor;
+ public boolean resumeWithoutConfirm;
+ //竖屏模式
+ public boolean autoOrientation;
+ //虚拟屏幕键盘按键
+ public boolean enableKeyboard;
+ //修复JoyCon十字键
+ public boolean enableJoyConFix;
+
+ //自由摇杆啊
+ public boolean enableNewAnalogStick;
+
+ public boolean enableExDisplay;
+
+ //串流画面顶部居中显示
+ public boolean alignDisplayTopCenter;
+
+ //触控屏幕灵敏度
+ public int touchSensitivityX;
+ public int touchSensitivityY;
+ //超出边界自动回中心点
+ public boolean touchSensitivityRotationAuto;
+
+ //触控灵敏度调节范围
+ public boolean touchSensitivityGlobal;
+
+ //多点触控灵敏度调节
+ public boolean enableTouchSensitivity;
+
+ //触控板模式灵敏度
+ public int touchPadSensitivity;
+
+ public int touchPadYSensitity;
+
+ //多点触控模式
+ public boolean enableMultiTouchScreen;
+
+ //物理光标捕获
+ public boolean enableMouseLocalCursor;
+
+ //禁用内置的特殊指令
+ public boolean enableClearDefaultSpecial;
+
+ //强制使用设备自身的震动马达
+ public boolean enableDeviceRumble;
+
+ public boolean enableKeyboardVibrate;
+
+ public boolean enableKeyboardSquare;
+
+ //官方虚拟按钮风格
+ public boolean enableOnScreenStyleOfficial;
+
+ //自由摇杆背景透明度
+ public int enableNewAnalogStickOpacity;
+
+ public int trackpadSensitivityX;
+ public int trackpadSensitivityY;
+ public boolean trackpadDragDropVibration;
+ public int trackpadDragDropThreshold;
+ public boolean trackpadSwapAxis;
+
public boolean bindAllUsb;
public boolean mouseEmulation;
public AnalogStickForScrolling analogStickForScrolling;
@@ -155,6 +327,7 @@ public enum AnalogStickForScrolling {
public boolean gamepadMotionSensors;
public boolean gamepadTouchpadAsMouse;
public boolean gamepadMotionSensorsFallbackToDevice;
+ public boolean forceMotionSensorsFallbackToDevice;
public static boolean isNativeResolution(int width, int height) {
// It's not a native resolution if it matches an existing resolution option
@@ -259,7 +432,7 @@ private static String getResolutionString(int width, int height) {
public static int getDefaultBitrate(String resString, String fpsString) {
int width = getWidthFromResolutionString(resString);
int height = getHeightFromResolutionString(resString);
- int fps = Integer.parseInt(fpsString);
+ int fps = Math.round(Float.parseFloat(fpsString));
// This logic is shamelessly stolen from Moonlight Qt:
// https://github.com/moonlight-stream/moonlight-qt/blob/master/app/settings/streamingpreferences.cpp
@@ -368,6 +541,25 @@ else if (str.equals("neverh265")) {
}
}
+ private static ScaleMode getVideoScaleMode(Context context) {
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
+
+ String str = prefs.getString(VIDEO_SCALE_MODE_PREF_STRING, DEFAULT_VIDEO_SCALE_MODE);
+ if (str.equals("fit")) {
+ return ScaleMode.FIT;
+ }
+ else if (str.equals("fill")) {
+ return ScaleMode.FILL;
+ }
+ else if (str.equals("stretch")) {
+ return ScaleMode.STRETCH;
+ }
+ else {
+ // Should never get here
+ return ScaleMode.FIT;
+ }
+ }
+
private static int getFramePacingValue(Context context) {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
@@ -430,11 +622,11 @@ public static void resetStreamingSettings(Context context) {
.apply();
}
- public static void completeLanguagePreferenceMigration(Context context) {
- // Put our language option back to default which tells us that we've already migrated it
- SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
- prefs.edit().putString(LANGUAGE_PREF_STRING, DEFAULT_LANGUAGE).apply();
- }
+// public static void completeLanguagePreferenceMigration(Context context) {
+// // Put our language option back to default which tells us that we've already migrated it
+// SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
+// prefs.edit().putString(LANGUAGE_PREF_STRING, DEFAULT_LANGUAGE).apply();
+// }
public static boolean isShieldAtvFirmwareWithBrokenHdr() {
// This particular Shield TV firmware crashes when using HDR
@@ -524,7 +716,23 @@ else if (str.equals("4K60")) {
config.width = PreferenceConfiguration.getWidthFromResolutionString(resStr);
config.height = PreferenceConfiguration.getHeightFromResolutionString(resStr);
- config.fps = Integer.parseInt(prefs.getString(FPS_PREF_STRING, PreferenceConfiguration.DEFAULT_FPS));
+ config.fps = Float.parseFloat(prefs.getString(FPS_PREF_STRING, PreferenceConfiguration.DEFAULT_FPS));
+ }
+
+ if (prefs.contains(LEGACY_STRETCH_PREF_STRING)) {
+ boolean stretch = prefs.getBoolean(LEGACY_STRETCH_PREF_STRING, false);
+ prefs.edit()
+ .remove(LEGACY_STRETCH_PREF_STRING)
+ .putString(VIDEO_SCALE_MODE_PREF_STRING, stretch ? "stretch" : "fit")
+ .apply();
+ }
+
+ if (prefs.contains(LEGACY_ENFORCE_REFRESH_RATE_STRING)) {
+ boolean enforce = prefs.getBoolean(LEGACY_ENFORCE_REFRESH_RATE_STRING, false);
+ prefs.edit()
+ .remove(LEGACY_ENFORCE_REFRESH_RATE_STRING)
+ .putBoolean(ENFORCE_DISPLAY_MODE_PREF_STRING, enforce)
+ .apply();
}
if (!prefs.contains(SMALL_ICONS_PREF_STRING)) {
@@ -548,6 +756,12 @@ else if (str.equals("4K60")) {
config.bitrate = getDefaultBitrate(context);
}
+ config.meteredBitrate = prefs.getInt((METERED_BITRATE_PREF_STRING), 0);
+ if (config.meteredBitrate == 0) {
+ config.meteredBitrate = config.bitrate / 4;
+ prefs.edit().putInt(METERED_BITRATE_PREF_STRING, 0).apply();
+ }
+
String audioConfig = prefs.getString(AUDIO_CONFIG_PREF_STRING, DEFAULT_AUDIO_CONFIG);
if (audioConfig.equals("71")) {
config.audioConfiguration = MoonBridge.AUDIO_CONFIGURATION_71_SURROUND;
@@ -559,9 +773,18 @@ else if (audioConfig.equals("51")) {
config.audioConfiguration = MoonBridge.AUDIO_CONFIGURATION_STEREO;
}
+ config.videoScaleMode = getVideoScaleMode(context);
+
config.videoFormat = getVideoFormatValue(context);
config.framePacing = getFramePacingValue(context);
+ String warpFactorStr = prefs.getString(FRAME_PACING_PREF_STRING, "");
+ if (warpFactorStr.equals("warp")) {
+ config.framePacingWarpFactor = 2;
+ } else if (warpFactorStr.equals("warp2")) {
+ config.framePacingWarpFactor = 4;
+ }
+
config.analogStickForScrolling = getAnalogStickForScrollingValue(context);
config.deadzonePercentage = prefs.getInt(DEADZONE_PREF_STRING, DEFAULT_DEADZONE);
@@ -572,18 +795,22 @@ else if (audioConfig.equals("51")) {
// Checkbox preferences
config.disableWarnings = prefs.getBoolean(DISABLE_TOASTS_PREF_STRING, DEFAULT_DISABLE_TOASTS);
+ config.enforceDisplayMode = prefs.getBoolean(ENFORCE_DISPLAY_MODE_PREF_STRING, DEFAULT_ENFORCE_DISPLAY_MODE);
+ config.useVirtualDisplay = prefs.getBoolean(USE_VIRTUAL_DISPLAY_PREF_STRING, DEFAULT_USE_VIRTUAL_DISPLAY);
+ config.enableUltraLowLatency = prefs.getBoolean(ENABLE_ULTRA_LOW_LATENCY_PREF_STRING, DEFAULT_ENABLE_ULTRA_LOW_LATENCY);
config.enableSops = prefs.getBoolean(SOPS_PREF_STRING, DEFAULT_SOPS);
- config.stretchVideo = prefs.getBoolean(STRETCH_PREF_STRING, DEFAULT_STRETCH);
config.playHostAudio = prefs.getBoolean(HOST_AUDIO_PREF_STRING, DEFAULT_HOST_AUDIO);
config.smallIconMode = prefs.getBoolean(SMALL_ICONS_PREF_STRING, getDefaultSmallMode(context));
config.multiController = prefs.getBoolean(MULTI_CONTROLLER_PREF_STRING, DEFAULT_MULTI_CONTROLLER);
config.usbDriver = prefs.getBoolean(USB_DRIVER_PREF_SRING, DEFAULT_USB_DRIVER);
- config.onscreenController = prefs.getBoolean(ONSCREEN_CONTROLLER_PREF_STRING, ONSCREEN_CONTROLLER_DEFAULT);
+ config.onscreenController = prefs.getBoolean(ONSCREEN_CONTROLLER_PREF_STRING, DEFAULT_ONSCREEN_CONTROLLER);
+ config.hideOSCWhenHasGamepad = prefs.getBoolean(CHECKBOX_HIDE_OSC_WHEN_HAS_GAMEPAD, DEFAULT_HIDE_OSC_WHEN_HAS_GAMEPAD);
config.onlyL3R3 = prefs.getBoolean(ONLY_L3_R3_PREF_STRING, ONLY_L3_R3_DEFAULT);
config.showGuideButton = prefs.getBoolean(SHOW_GUIDE_BUTTON_PREF_STRING, SHOW_GUIDE_BUTTON_DEFAULT);
config.enableHdr = prefs.getBoolean(ENABLE_HDR_PREF_STRING, DEFAULT_ENABLE_HDR) && !isShieldAtvFirmwareWithBrokenHdr();
config.enablePip = prefs.getBoolean(ENABLE_PIP_PREF_STRING, DEFAULT_ENABLE_PIP);
config.enablePerfOverlay = prefs.getBoolean(ENABLE_PERF_OVERLAY_STRING, DEFAULT_ENABLE_PERF_OVERLAY);
+ config.enablePerfOverlayLite=prefs.getBoolean("checkbox_enable_perf_overlay_lite",DEFAULT_ENABLE_PERF_OVERLAY);
config.bindAllUsb = prefs.getBoolean(BIND_ALL_USB_STRING, DEFAULT_BIND_ALL_USB);
config.mouseEmulation = prefs.getBoolean(MOUSE_EMULATION_STRING, DEFAULT_MOUSE_EMULATION);
config.mouseNavButtons = prefs.getBoolean(MOUSE_NAV_BUTTONS_STRING, DEFAULT_MOUSE_NAV_BUTTONS);
@@ -592,15 +819,90 @@ else if (audioConfig.equals("51")) {
config.vibrateFallbackToDevice = prefs.getBoolean(VIBRATE_FALLBACK_PREF_STRING, DEFAULT_VIBRATE_FALLBACK);
config.vibrateFallbackToDeviceStrength = prefs.getInt(VIBRATE_FALLBACK_STRENGTH_PREF_STRING, DEFAULT_VIBRATE_FALLBACK_STRENGTH);
config.flipFaceButtons = prefs.getBoolean(FLIP_FACE_BUTTONS_PREF_STRING, DEFAULT_FLIP_FACE_BUTTONS);
- config.touchscreenTrackpad = prefs.getBoolean(TOUCHSCREEN_TRACKPAD_PREF_STRING, DEFAULT_TOUCHSCREEN_TRACKPAD);
+// config.touchscreenTrackpad = prefs.getBoolean(TOUCHSCREEN_TRACKPAD_PREF_STRING, DEFAULT_TOUCHSCREEN_TRACKPAD);
config.enableLatencyToast = prefs.getBoolean(LATENCY_TOAST_PREF_STRING, DEFAULT_LATENCY_TOAST);
+ //软键盘
+ config.enableBackMenu = prefs.getBoolean(CHECKBOX_ENABLE_QUIT_DIALOG,false);
+ config.autoOrientation = prefs.getBoolean(CHECKBOX_AUTO_ORIENTATION,false);
+ config.autoInvertVideoResolution = prefs.getBoolean(AUTO_INVERT_VIDEO_RESOLUTION_PREF_STRING, DEFAULT_AUTO_INVERT_VIDEO_RESOLUTION);
+ config.resolutionScaleFactor = prefs.getInt(RESOLUTION_SCALE_FACTOR_PREF_STRING, DEFAULT_RESOLUTION_SCALE_FACTOR);
+
+ config.resumeWithoutConfirm = prefs.getBoolean(RESUME_WITHOUT_CONFIRM_PREF_STRING, DEFAULT_RESUME_WITHOUT_CONFIRM);
+
+ config.enableKeyboard = prefs.getBoolean(CHECKBOX_ENABLE_KEYBOARD,false);
+
+ config.enableKeyboardVibrate = prefs.getBoolean(CHECKBOX_ENABLE_KEYBOARD_VIBRATE,false);
+ //兼容joycon手柄
+ config.enableJoyConFix = prefs.getBoolean("checkbox_enable_joyconfix",false);
+ //全键盘透明度
+ config.oscKeyboardOpacity = prefs.getInt("seekbar_keyboard_axi_opacity",DEFAULT_OPACITY);
+
+ config.enableOnScreenStyleOfficial = prefs.getBoolean("checkbox_onscreen_style_official",false);
+
+ config.enableNewAnalogStickOpacity = prefs.getInt("seekbar_osc_free_analog_stick_opacity",20);
+
+ config.onscreenKeyboardHeight = prefs.getInt("seekbar_onscreen_keyboard_height",200);
+ config.onscreenKeyboardWidth = prefs.getInt("seekbar_onscreen_keyboard_width",1000);
+ config.onscreenKeyboardAlignMode = prefs.getString(LIST_ONSCREEN_KEYBOARD_ALIGN_MODE, DEFAULT_ONSCREEN_KEYBOARD_ALIGN_MODE);
+
+ config.enableNewAnalogStick=prefs.getBoolean(CHECKBOX_CHECKBOX_ENABLE_ANALOG_STICK_NEW,false);
+
+ config.enableExDisplay=prefs.getBoolean("checkbox_enable_exdisplay",false);
+
+ config.alignDisplayTopCenter =prefs.getBoolean("checkbox_enable_view_top_center",false);
+
+ config.touchSensitivityX =prefs.getInt(SEEKBAR_TOUCH_SENSITIVITY,100);
+
+ config.touchSensitivityY=prefs.getInt("seekbar_touch_sensitivity_opacity_y",100);
+
+ config.touchSensitivityRotationAuto=prefs.getBoolean("checkbox_enable_touch_sensitivity_rotation_auto",true);
+
+ config.touchSensitivityGlobal=prefs.getBoolean("checkbox_enable_global_touch_sensitivity",false);
+
+ config.enableTouchSensitivity=prefs.getBoolean("checkbox_enable_touch_sensitivity",false);
+
+ config.enableMouseLocalCursor=prefs.getBoolean("checkbox_mouse_local_cursor",false);
+
+ config.enablePerfOverlayLiteDialog=prefs.getBoolean("checkbox_enable_perf_overlay_lite_dialog",false);
+
+ config.enableClearDefaultSpecial=prefs.getBoolean("checkbox_enable_clear_default_special_button", false);
+
+ config.enableDeviceRumble=prefs.getBoolean("checkbox_enable_device_rumble", false);
+
+ config.enableKeyboardSquare=prefs.getBoolean("checkbox_enable_keyboard_square",false);
+
+ config.touchPadSensitivity=prefs.getInt("seekbar_touchpad_sensitivity_opacity",100);
+
+ config.touchPadYSensitity=prefs.getInt("seekbar_touchpad_sensitivity_y_opacity",100);
+
+ config.trackpadSensitivityX = prefs.getInt(SEEKBAR_TRACKPAD_SENSITIVITY_X, DEFAULT_TRACKPAD_SENSITIVITY_X);
+ config.trackpadSensitivityY = prefs.getInt(SEEKBAR_TRACKPAD_SENSITIVITY_Y, DEFAULT_TRACKPAD_SENSITIVITY_Y);
+ config.trackpadDragDropVibration = prefs.getBoolean(CHECKBOX_TRACKPAD_DRAG_DROP_VIBRATION, DEFAULT_TRACKPAD_DRAG_DROP_VIBRATION);
+ config.trackpadDragDropThreshold = prefs.getInt(SEEKBAR_TRACKPAD_DRAG_DROP_THRESHOLD, DEFAULT_TRACKPAD_DRAG_DROP_THRESHOLD);
+ config.trackpadSwapAxis = prefs.getBoolean(CHECKBOX_TRACKPAD_SWAP_AXIS, DEFAULT_TRACKPAD_SWAP_AXIS);
+
config.absoluteMouseMode = prefs.getBoolean(ABSOLUTE_MOUSE_MODE_PREF_STRING, DEFAULT_ABSOLUTE_MOUSE_MODE);
+ config.enableBatteryReport = prefs.getBoolean(CHECKBOX_ENABLE_BATTERY_REPORT, DEFAULT_GAMEPAD_ENABLE_BATTERY_REPORT);
+ config.forceQwerty = prefs.getBoolean(CHECKBOX_FORCE_QWERTY, DEFAULT_FORCE_QWERTY);
+ config.backAsMeta = prefs.getBoolean(CHECKBOX_BACK_AS_META, DEFAULT_SEND_META_ON_PHYSICAL_BACK);
+ config.ignoreSynthEvents = prefs.getBoolean(CHECKBOX_IGNORE_SYNTH_EVENTS, DEFAULT_IGNORE_SYNTH_EVENTS);
+ config.backAsGuide = prefs.getBoolean(CHECKBOX_BACK_AS_GUIDE, DEFAULT_BACK_AS_GUIDE);
+ config.smartClipboardSync = prefs.getBoolean(CHECKBOX_SMART_CLIPBOARD_SYNC, DEFAULT_SMART_CLIPBOARD_SYNC);
+ config.smartClipboardSyncToast = prefs.getBoolean(CHECKBOX_SMART_CLIPBOARD_SYNC_TOAST, DEFAULT_SMART_CLIPBOARD_SYNC_TOAST);
+ config.hideClipboardContent = prefs.getBoolean(CHECKBOX_HIDE_CLIPBOARD_CONTENT, DEFAULT_HIDE_CLIPBOARD_CONTENT);
+ config.stickyModifierKey = prefs.getBoolean(CHECKBOX_ENABLE_STICKY_MODIFIER_KEY_VIRTUAL_KEYBOARD, DEFAULT_ENABLE_STICKY_MODIFIER_KEY_VIRTUAL_KEYBOARD);
config.enableAudioFx = prefs.getBoolean(ENABLE_AUDIO_FX_PREF_STRING, DEFAULT_ENABLE_AUDIO_FX);
config.reduceRefreshRate = prefs.getBoolean(REDUCE_REFRESH_RATE_PREF_STRING, DEFAULT_REDUCE_REFRESH_RATE);
config.fullRange = prefs.getBoolean(FULL_RANGE_PREF_STRING, DEFAULT_FULL_RANGE);
config.gamepadTouchpadAsMouse = prefs.getBoolean(GAMEPAD_TOUCHPAD_AS_MOUSE_PREF_STRING, DEFAULT_GAMEPAD_TOUCHPAD_AS_MOUSE);
config.gamepadMotionSensors = prefs.getBoolean(GAMEPAD_MOTION_SENSORS_PREF_STRING, DEFAULT_GAMEPAD_MOTION_SENSORS);
config.gamepadMotionSensorsFallbackToDevice = prefs.getBoolean(GAMEPAD_MOTION_FALLBACK_PREF_STRING, DEFAULT_GAMEPAD_MOTION_FALLBACK);
+ config.forceMotionSensorsFallbackToDevice = prefs.getBoolean(FORCE_MOTION_SENSORS_FALLBACK_PREF_STRING, DEFAULT_FORCE_MOTION_SENSORS_FALLBACK);
+
+ // Read custom values
+ config.customResolution = prefs.getString(CUSTOM_RESOLUTION_PREF_STRING, null);
+ config.customRefreshRate = prefs.getString(CUSTOM_REFRESH_RATE_PREF_STRING, null);
+// config.customBitrate = prefs.getString(CUSTOM_BITRATE_PREF_STRING, null);
return config;
}
diff --git a/app/src/main/java/com/limelight/preferences/SeekBarPreference.java b/app/src/main/java/com/limelight/preferences/SeekBarPreference.java
old mode 100644
new mode 100755
index 360ef29c0a..2b1f71e93a
--- a/app/src/main/java/com/limelight/preferences/SeekBarPreference.java
+++ b/app/src/main/java/com/limelight/preferences/SeekBarPreference.java
@@ -1,193 +1,210 @@
-package com.limelight.preferences;
-
-import android.app.AlertDialog;
-import android.content.Context;
-import android.os.Bundle;
-import android.preference.DialogPreference;
-import android.util.AttributeSet;
-import android.util.Log;
-import android.view.Gravity;
-import android.view.View;
-import android.view.View.OnClickListener;
-import android.widget.Button;
-import android.widget.LinearLayout;
-import android.widget.SeekBar;
-import android.widget.TextView;
-
-import java.util.Locale;
-
-// Based on a Stack Overflow example: http://stackoverflow.com/questions/1974193/slider-on-my-preferencescreen
-public class SeekBarPreference extends DialogPreference
-{
- private static final String ANDROID_SCHEMA_URL = "http://schemas.android.com/apk/res/android";
- private static final String SEEKBAR_SCHEMA_URL = "http://schemas.moonlight-stream.com/apk/res/seekbar";
-
- private SeekBar seekBar;
- private TextView valueText;
- private final Context context;
-
- private final String dialogMessage;
- private final String suffix;
- private final int defaultValue;
- private final int maxValue;
- private final int minValue;
- private final int stepSize;
- private final int keyStepSize;
- private final int divisor;
- private int currentValue;
-
- public SeekBarPreference(Context context, AttributeSet attrs) {
- super(context, attrs);
- this.context = context;
-
- // Read the message from XML
- int dialogMessageId = attrs.getAttributeResourceValue(ANDROID_SCHEMA_URL, "dialogMessage", 0);
- if (dialogMessageId == 0) {
- dialogMessage = attrs.getAttributeValue(ANDROID_SCHEMA_URL, "dialogMessage");
- }
- else {
- dialogMessage = context.getString(dialogMessageId);
- }
-
- // Get the suffix for the number displayed in the dialog
- int suffixId = attrs.getAttributeResourceValue(ANDROID_SCHEMA_URL, "text", 0);
- if (suffixId == 0) {
- suffix = attrs.getAttributeValue(ANDROID_SCHEMA_URL, "text");
- }
- else {
- suffix = context.getString(suffixId);
- }
-
- // Get default, min, and max seekbar values
- defaultValue = attrs.getAttributeIntValue(ANDROID_SCHEMA_URL, "defaultValue", PreferenceConfiguration.getDefaultBitrate(context));
- maxValue = attrs.getAttributeIntValue(ANDROID_SCHEMA_URL, "max", 100);
- minValue = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "min", 1);
- stepSize = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "step", 1);
- divisor = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "divisor", 1);
- keyStepSize = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "keyStep", 0);
- }
-
- @Override
- protected View onCreateDialogView() {
-
- LinearLayout.LayoutParams params;
- LinearLayout layout = new LinearLayout(context);
- layout.setOrientation(LinearLayout.VERTICAL);
- layout.setPadding(6, 6, 6, 6);
-
- TextView splashText = new TextView(context);
- splashText.setPadding(30, 10, 30, 10);
- if (dialogMessage != null) {
- splashText.setText(dialogMessage);
- }
- layout.addView(splashText);
-
- valueText = new TextView(context);
- valueText.setGravity(Gravity.CENTER_HORIZONTAL);
- valueText.setTextSize(32);
- // Default text for value; hides bug where OnSeekBarChangeListener isn't called when opacity is 0%
- valueText.setText("0%");
- params = new LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT,
- LinearLayout.LayoutParams.WRAP_CONTENT);
- layout.addView(valueText, params);
-
- seekBar = new SeekBar(context);
- seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
- @Override
- public void onProgressChanged(SeekBar seekBar, int value, boolean b) {
- if (value < minValue) {
- seekBar.setProgress(minValue);
- return;
- }
-
- int roundedValue = ((value + (stepSize - 1))/stepSize)*stepSize;
- if (roundedValue != value) {
- seekBar.setProgress(roundedValue);
- return;
- }
-
- String t;
- if (divisor != 1) {
- float floatValue = roundedValue / (float)divisor;
- t = String.format((Locale)null, "%.1f", floatValue);
- }
- else {
- t = String.valueOf(value);
- }
- valueText.setText(suffix == null ? t : t.concat(suffix.length() > 1 ? " "+suffix : suffix));
- }
-
- @Override
- public void onStartTrackingTouch(SeekBar seekBar) {}
-
- @Override
- public void onStopTrackingTouch(SeekBar seekBar) {}
- });
-
- layout.addView(seekBar, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT));
-
- if (shouldPersist()) {
- currentValue = getPersistedInt(defaultValue);
- }
-
- seekBar.setMax(maxValue);
- if (keyStepSize != 0) {
- seekBar.setKeyProgressIncrement(keyStepSize);
- }
- seekBar.setProgress(currentValue);
-
- return layout;
- }
-
- @Override
- protected void onBindDialogView(View v) {
- super.onBindDialogView(v);
- seekBar.setMax(maxValue);
- if (keyStepSize != 0) {
- seekBar.setKeyProgressIncrement(keyStepSize);
- }
- seekBar.setProgress(currentValue);
- }
-
- @Override
- protected void onSetInitialValue(boolean restore, Object defaultValue)
- {
- super.onSetInitialValue(restore, defaultValue);
- if (restore) {
- currentValue = shouldPersist() ? getPersistedInt(this.defaultValue) : 0;
- }
- else {
- currentValue = (Integer) defaultValue;
- }
- }
-
- public void setProgress(int progress) {
- this.currentValue = progress;
- if (seekBar != null) {
- seekBar.setProgress(progress);
- }
- }
- public int getProgress() {
- return currentValue;
- }
-
- @Override
- public void showDialog(Bundle state) {
- super.showDialog(state);
-
- Button positiveButton = ((AlertDialog) getDialog()).getButton(AlertDialog.BUTTON_POSITIVE);
- positiveButton.setOnClickListener(new OnClickListener() {
- @Override
- public void onClick(View view) {
- if (shouldPersist()) {
- currentValue = seekBar.getProgress();
- persistInt(seekBar.getProgress());
- callChangeListener(seekBar.getProgress());
- }
-
- getDialog().dismiss();
- }
- });
- }
-}
+package com.limelight.preferences;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.util.AttributeSet;
+import android.view.Gravity;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.Button;
+import android.widget.LinearLayout;
+import android.widget.SeekBar;
+import android.widget.TextView;
+
+import androidx.preference.DialogPreference;
+import androidx.preference.Preference;
+
+import com.limelight.R;
+
+import java.util.Locale;
+
+// Based on a Stack Overflow example: http://stackoverflow.com/questions/1974193/slider-on-my-preferencescreen
+public class SeekBarPreference extends Preference
+{
+ private static final String ANDROID_SCHEMA_URL = "http://schemas.android.com/apk/res/android";
+ private static final String SEEKBAR_SCHEMA_URL = "http://schemas.moonlight-stream.com/apk/res/seekbar";
+
+ private AlertDialog dialog;
+ private SeekBar seekBar;
+ private TextView valueText;
+ private final Context context;
+
+ private final String dialogMessage;
+ private final String suffix;
+ private final int defaultValue;
+ private final int maxValue;
+ private final int minValue;
+ private final int stepSize;
+ private final int keyStepSize;
+ private final int divisor;
+ private int currentValue;
+
+ private final int seekbarMax;
+
+ public SeekBarPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ this.context = context;
+
+ // Read the message from XML
+ int dialogMessageId = attrs.getAttributeResourceValue(ANDROID_SCHEMA_URL, "dialogMessage", 0);
+ if (dialogMessageId == 0) {
+ dialogMessage = attrs.getAttributeValue(ANDROID_SCHEMA_URL, "dialogMessage");
+ }
+ else {
+ dialogMessage = context.getString(dialogMessageId);
+ }
+
+ // Get the suffix for the number displayed in the dialog
+ int suffixId = attrs.getAttributeResourceValue(ANDROID_SCHEMA_URL, "text", 0);
+ if (suffixId == 0) {
+ suffix = attrs.getAttributeValue(ANDROID_SCHEMA_URL, "text");
+ }
+ else {
+ suffix = context.getString(suffixId);
+ }
+
+ // Get default, min, and max seekbar values
+ defaultValue = attrs.getAttributeIntValue(ANDROID_SCHEMA_URL, "defaultValue", PreferenceConfiguration.getDefaultBitrate(context));
+ maxValue = attrs.getAttributeIntValue(ANDROID_SCHEMA_URL, "max", 100);
+ minValue = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "min", 1);
+ stepSize = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "step", 1);
+ divisor = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "divisor", 1);
+ keyStepSize = attrs.getAttributeIntValue(SEEKBAR_SCHEMA_URL, "keyStep", 0);
+ seekbarMax = maxValue - minValue;
+ }
+
+ protected AlertDialog getDialog() {
+ if (dialog != null) {
+ return dialog;
+ }
+
+ LinearLayout.LayoutParams params;
+ LinearLayout layout = new LinearLayout(context);
+ layout.setOrientation(LinearLayout.VERTICAL);
+ layout.setPadding(6, 6, 6, 6);
+
+ TextView splashText = new TextView(context);
+ splashText.setPadding(30, 10, 30, 10);
+ if (dialogMessage != null) {
+ splashText.setText(dialogMessage);
+ }
+ layout.addView(splashText);
+
+ valueText = new TextView(context);
+ valueText.setGravity(Gravity.CENTER_HORIZONTAL);
+ valueText.setTextSize(32);
+ // Default text for value; hides bug where OnSeekBarChangeListener isn't called when opacity is 0%
+ valueText.setText("0%");
+ params = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT);
+ layout.addView(valueText, params);
+
+ seekBar = new SeekBar(context);
+ seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
+ @Override
+ public void onProgressChanged(SeekBar seekBar, int value, boolean b) {
+ value += minValue;
+ if (value < minValue) {
+ seekBar.setProgress(0);
+ return;
+ }
+
+ int roundedValue = Math.round((float)value / stepSize) * stepSize;
+ if (roundedValue != value) {
+ seekBar.setProgress(roundedValue - minValue);
+ return;
+ }
+
+ String t;
+ if (divisor != 1) {
+ float floatValue = roundedValue / (float)divisor;
+ t = String.format((Locale)null, "%.1f", floatValue);
+ }
+ else {
+ t = String.valueOf(value);
+ }
+ valueText.setText(suffix == null ? t : t.concat(suffix.length() > 1 ? " "+suffix : suffix));
+ }
+
+ @Override
+ public void onStartTrackingTouch(SeekBar seekBar) {}
+
+ @Override
+ public void onStopTrackingTouch(SeekBar seekBar) {}
+ });
+
+ layout.addView(seekBar, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT));
+
+ if (shouldPersist()) {
+ currentValue = getPersistedInt(defaultValue);
+ }
+
+ seekBar.setMax(seekbarMax);
+ if (keyStepSize != 0) {
+ seekBar.setKeyProgressIncrement(keyStepSize);
+ }
+ seekBar.setProgress(currentValue - minValue);
+
+ AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(context);
+ dialogBuilder.setTitle(getTitle());
+ dialogBuilder.setView(layout);
+
+ dialogBuilder.setPositiveButton("OK", (dialog, which) -> {
+ if (shouldPersist()) {
+ currentValue = seekBar.getProgress() + minValue;
+ persistInt(currentValue);
+ callChangeListener(currentValue);
+ }
+
+ dialog.dismiss();
+ });
+ dialogBuilder.setNegativeButton(context.getString(R.string.cancel), (dialog, which) -> dialog.dismiss());
+
+ dialog = dialogBuilder.create();
+ return dialog;
+ }
+
+ protected void updateSeekbar() {
+ seekBar.setMax(seekbarMax);
+ if (keyStepSize != 0) {
+ seekBar.setKeyProgressIncrement(keyStepSize);
+ }
+ seekBar.setProgress(currentValue - minValue);
+ }
+
+ @Override
+ protected void onSetInitialValue(boolean restore, Object defaultValue)
+ {
+ super.onSetInitialValue(restore, defaultValue);
+ if (restore) {
+ currentValue = shouldPersist() ? getPersistedInt(this.defaultValue) : 0;
+ }
+ else {
+ currentValue = (Integer) defaultValue;
+ }
+ }
+
+ public void setProgress(int progress) {
+ this.currentValue = progress;
+ if (seekBar != null) {
+ seekBar.setProgress(progress - minValue);
+ }
+ }
+ public int getProgress() {
+ return currentValue + minValue;
+ }
+
+ public void showDialog() {
+ AlertDialog dialog = getDialog();
+ updateSeekbar();
+ dialog.show();
+ }
+
+ @Override
+ protected void onClick() {
+ super.onClick();
+ showDialog();
+ }
+}
diff --git a/app/src/main/java/com/limelight/preferences/SmallIconCheckboxPreference.java b/app/src/main/java/com/limelight/preferences/SmallIconCheckboxPreference.java
old mode 100644
new mode 100755
index c216b74909..d22cd38aa6
--- a/app/src/main/java/com/limelight/preferences/SmallIconCheckboxPreference.java
+++ b/app/src/main/java/com/limelight/preferences/SmallIconCheckboxPreference.java
@@ -1,21 +1,32 @@
-package com.limelight.preferences;
-
-import android.content.Context;
-import android.content.res.TypedArray;
-import android.preference.CheckBoxPreference;
-import android.util.AttributeSet;
-
-public class SmallIconCheckboxPreference extends CheckBoxPreference {
- public SmallIconCheckboxPreference(Context context, AttributeSet attrs, int defStyleAttr) {
- super(context, attrs, defStyleAttr);
- }
-
- public SmallIconCheckboxPreference(Context context, AttributeSet attrs) {
- super(context, attrs);
- }
-
- @Override
- protected Object onGetDefaultValue(TypedArray a, int index) {
- return PreferenceConfiguration.getDefaultSmallMode(getContext());
- }
-}
+package com.limelight.preferences;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.preference.CheckBoxPreference;
+
+public class SmallIconCheckboxPreference extends CheckBoxPreference {
+ public SmallIconCheckboxPreference(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public SmallIconCheckboxPreference(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ public SmallIconCheckboxPreference(@NonNull Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public SmallIconCheckboxPreference(@NonNull Context context) {
+ super(context);
+ }
+
+ @Override
+ protected Object onGetDefaultValue(TypedArray a, int index) {
+ return PreferenceConfiguration.getDefaultSmallMode(getContext());
+ }
+}
diff --git a/app/src/main/java/com/limelight/preferences/StreamSettings.java b/app/src/main/java/com/limelight/preferences/StreamSettings.java
old mode 100644
new mode 100755
index 7070104168..3fb7ab42dc
--- a/app/src/main/java/com/limelight/preferences/StreamSettings.java
+++ b/app/src/main/java/com/limelight/preferences/StreamSettings.java
@@ -1,672 +1,1066 @@
-package com.limelight.preferences;
-
-import android.content.Context;
-import android.content.Intent;
-import android.content.SharedPreferences;
-import android.content.pm.PackageManager;
-import android.content.res.Configuration;
-import android.media.MediaCodecInfo;
-import android.os.Build;
-import android.os.Bundle;
-import android.app.Activity;
-import android.os.Handler;
-import android.os.Vibrator;
-import android.preference.CheckBoxPreference;
-import android.preference.ListPreference;
-import android.preference.Preference;
-import android.preference.PreferenceCategory;
-import android.preference.PreferenceFragment;
-import android.preference.PreferenceManager;
-import android.preference.PreferenceScreen;
-import android.util.DisplayMetrics;
-import android.util.Range;
-import android.view.Display;
-import android.view.DisplayCutout;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.view.WindowInsets;
-
-import com.limelight.LimeLog;
-import com.limelight.PcView;
-import com.limelight.R;
-import com.limelight.binding.video.MediaCodecHelper;
-import com.limelight.utils.Dialog;
-import com.limelight.utils.UiHelper;
-
-import java.lang.reflect.Method;
-import java.util.Arrays;
-
-public class StreamSettings extends Activity {
- private PreferenceConfiguration previousPrefs;
- private int previousDisplayPixelCount;
-
- // HACK for Android 9
- static DisplayCutout displayCutoutP;
-
- void reloadSettings() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- Display.Mode mode = getWindowManager().getDefaultDisplay().getMode();
- previousDisplayPixelCount = mode.getPhysicalWidth() * mode.getPhysicalHeight();
- }
- getFragmentManager().beginTransaction().replace(
- R.id.stream_settings, new SettingsFragment()
- ).commitAllowingStateLoss();
- }
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
-
- previousPrefs = PreferenceConfiguration.readPreferences(this);
-
- UiHelper.setLocale(this);
-
- setContentView(R.layout.activity_stream_settings);
-
- UiHelper.notifyNewRootView(this);
- }
-
- @Override
- public void onAttachedToWindow() {
- super.onAttachedToWindow();
-
- // We have to use this hack on Android 9 because we don't have Display.getCutout()
- // which was added in Android 10.
- if (Build.VERSION.SDK_INT == Build.VERSION_CODES.P) {
- // Insets can be null when the activity is recreated on screen rotation
- // https://stackoverflow.com/questions/61241255/windowinsets-getdisplaycutout-is-null-everywhere-except-within-onattachedtowindo
- WindowInsets insets = getWindow().getDecorView().getRootWindowInsets();
- if (insets != null) {
- displayCutoutP = insets.getDisplayCutout();
- }
- }
-
- reloadSettings();
- }
-
- @Override
- public void onConfigurationChanged(Configuration newConfig) {
- super.onConfigurationChanged(newConfig);
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- Display.Mode mode = getWindowManager().getDefaultDisplay().getMode();
-
- // If the display's physical pixel count has changed, we consider that it's a new display
- // and we should reload our settings (which include display-dependent values).
- //
- // NB: We aren't using displayId here because that stays the same (DEFAULT_DISPLAY) when
- // switching between screens on a foldable device.
- if (mode.getPhysicalWidth() * mode.getPhysicalHeight() != previousDisplayPixelCount) {
- reloadSettings();
- }
- }
- }
-
- @Override
- // NOTE: This will NOT be called on Android 13+ with android:enableOnBackInvokedCallback="true"
- public void onBackPressed() {
- finish();
-
- // Language changes are handled via configuration changes in Android 13+,
- // so manual activity relaunching is no longer required.
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
- PreferenceConfiguration newPrefs = PreferenceConfiguration.readPreferences(this);
- if (!newPrefs.language.equals(previousPrefs.language)) {
- // Restart the PC view to apply UI changes
- Intent intent = new Intent(this, PcView.class);
- intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK);
- startActivity(intent, null);
- }
- }
- }
-
- public static class SettingsFragment extends PreferenceFragment {
- private int nativeResolutionStartIndex = Integer.MAX_VALUE;
- private boolean nativeFramerateShown = false;
-
- private void setValue(String preferenceKey, String value) {
- ListPreference pref = (ListPreference) findPreference(preferenceKey);
-
- pref.setValue(value);
- }
-
- private void appendPreferenceEntry(ListPreference pref, String newEntryName, String newEntryValue) {
- CharSequence[] newEntries = Arrays.copyOf(pref.getEntries(), pref.getEntries().length + 1);
- CharSequence[] newValues = Arrays.copyOf(pref.getEntryValues(), pref.getEntryValues().length + 1);
-
- // Add the new option
- newEntries[newEntries.length - 1] = newEntryName;
- newValues[newValues.length - 1] = newEntryValue;
-
- pref.setEntries(newEntries);
- pref.setEntryValues(newValues);
- }
-
- private void addNativeResolutionEntry(int nativeWidth, int nativeHeight, boolean insetsRemoved, boolean portrait) {
- ListPreference pref = (ListPreference) findPreference(PreferenceConfiguration.RESOLUTION_PREF_STRING);
-
- String newName;
-
- if (insetsRemoved) {
- newName = getResources().getString(R.string.resolution_prefix_native_fullscreen);
- }
- else {
- newName = getResources().getString(R.string.resolution_prefix_native);
- }
-
- if (PreferenceConfiguration.isSquarishScreen(nativeWidth, nativeHeight)) {
- if (portrait) {
- newName += " " + getResources().getString(R.string.resolution_prefix_native_portrait);
- }
- else {
- newName += " " + getResources().getString(R.string.resolution_prefix_native_landscape);
- }
- }
-
- newName += " ("+nativeWidth+"x"+nativeHeight+")";
-
- String newValue = nativeWidth+"x"+nativeHeight;
-
- // Check if the native resolution is already present
- for (CharSequence value : pref.getEntryValues()) {
- if (newValue.equals(value.toString())) {
- // It is present in the default list, so don't add it again
- return;
- }
- }
-
- if (pref.getEntryValues().length < nativeResolutionStartIndex) {
- nativeResolutionStartIndex = pref.getEntryValues().length;
- }
- appendPreferenceEntry(pref, newName, newValue);
- }
-
- private void addNativeResolutionEntries(int nativeWidth, int nativeHeight, boolean insetsRemoved) {
- if (PreferenceConfiguration.isSquarishScreen(nativeWidth, nativeHeight)) {
- addNativeResolutionEntry(nativeHeight, nativeWidth, insetsRemoved, true);
- }
- addNativeResolutionEntry(nativeWidth, nativeHeight, insetsRemoved, false);
- }
-
- private void addNativeFrameRateEntry(float framerate) {
- int frameRateRounded = Math.round(framerate);
- if (frameRateRounded == 0) {
- return;
- }
-
- ListPreference pref = (ListPreference) findPreference(PreferenceConfiguration.FPS_PREF_STRING);
- String fpsValue = Integer.toString(frameRateRounded);
- String fpsName = getResources().getString(R.string.resolution_prefix_native) +
- " (" + fpsValue + " " + getResources().getString(R.string.fps_suffix_fps) + ")";
-
- // Check if the native frame rate is already present
- for (CharSequence value : pref.getEntryValues()) {
- if (fpsValue.equals(value.toString())) {
- // It is present in the default list, so don't add it again
- nativeFramerateShown = false;
- return;
- }
- }
-
- appendPreferenceEntry(pref, fpsName, fpsValue);
- nativeFramerateShown = true;
- }
-
- private void removeValue(String preferenceKey, String value, Runnable onMatched) {
- int matchingCount = 0;
-
- ListPreference pref = (ListPreference) findPreference(preferenceKey);
-
- // Count the number of matching entries we'll be removing
- for (CharSequence seq : pref.getEntryValues()) {
- if (seq.toString().equalsIgnoreCase(value)) {
- matchingCount++;
- }
- }
-
- // Create the new arrays
- CharSequence[] entries = new CharSequence[pref.getEntries().length-matchingCount];
- CharSequence[] entryValues = new CharSequence[pref.getEntryValues().length-matchingCount];
- int outIndex = 0;
- for (int i = 0; i < pref.getEntryValues().length; i++) {
- if (pref.getEntryValues()[i].toString().equalsIgnoreCase(value)) {
- // Skip matching values
- continue;
- }
-
- entries[outIndex] = pref.getEntries()[i];
- entryValues[outIndex] = pref.getEntryValues()[i];
- outIndex++;
- }
-
- if (pref.getValue().equalsIgnoreCase(value)) {
- onMatched.run();
- }
-
- // Update the preference with the new list
- pref.setEntries(entries);
- pref.setEntryValues(entryValues);
- }
-
- private void resetBitrateToDefault(SharedPreferences prefs, String res, String fps) {
- if (res == null) {
- res = prefs.getString(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.DEFAULT_RESOLUTION);
- }
- if (fps == null) {
- fps = prefs.getString(PreferenceConfiguration.FPS_PREF_STRING, PreferenceConfiguration.DEFAULT_FPS);
- }
-
- prefs.edit()
- .putInt(PreferenceConfiguration.BITRATE_PREF_STRING,
- PreferenceConfiguration.getDefaultBitrate(res, fps))
- .apply();
- }
-
- @Override
- public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
- View view = super.onCreateView(inflater, container, savedInstanceState);
- UiHelper.applyStatusBarPadding(view);
- return view;
- }
-
- @Override
- public void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
-
- addPreferencesFromResource(R.xml.preferences);
- PreferenceScreen screen = getPreferenceScreen();
-
- // hide on-screen controls category on non touch screen devices
- if (!getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)) {
- PreferenceCategory category =
- (PreferenceCategory) findPreference("category_onscreen_controls");
- screen.removePreference(category);
- }
-
- // Hide remote desktop mouse mode on pre-Oreo (which doesn't have pointer capture)
- // and NVIDIA SHIELD devices (which support raw mouse input in pointer capture mode)
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
- getActivity().getPackageManager().hasSystemFeature("com.nvidia.feature.shield")) {
- PreferenceCategory category =
- (PreferenceCategory) findPreference("category_input_settings");
- category.removePreference(findPreference("checkbox_absolute_mouse_mode"));
- }
-
- // Hide gamepad motion sensor option when running on OSes before Android 12.
- // Support for motion, LED, battery, and other extensions were introduced in S.
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
- PreferenceCategory category =
- (PreferenceCategory) findPreference("category_gamepad_settings");
- category.removePreference(findPreference("checkbox_gamepad_motion_sensors"));
- }
-
- // Hide gamepad motion sensor fallback option if the device has no gyro or accelerometer
- if (!getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_SENSOR_ACCELEROMETER) &&
- !getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_SENSOR_GYROSCOPE)) {
- PreferenceCategory category =
- (PreferenceCategory) findPreference("category_gamepad_settings");
- category.removePreference(findPreference("checkbox_gamepad_motion_fallback"));
- }
-
- // Hide USB driver options on devices without USB host support
- if (!getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_USB_HOST)) {
- PreferenceCategory category =
- (PreferenceCategory) findPreference("category_gamepad_settings");
- category.removePreference(findPreference("checkbox_usb_bind_all"));
- category.removePreference(findPreference("checkbox_usb_driver"));
- }
-
- // Remove PiP mode on devices pre-Oreo, where the feature is not available (some low RAM devices),
- // and on Fire OS where it violates the Amazon App Store guidelines for some reason.
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
- !getActivity().getPackageManager().hasSystemFeature("android.software.picture_in_picture") ||
- getActivity().getPackageManager().hasSystemFeature("com.amazon.software.fireos")) {
- PreferenceCategory category =
- (PreferenceCategory) findPreference("category_ui_settings");
- category.removePreference(findPreference("checkbox_enable_pip"));
- }
-
- // Fire TV apps are not allowed to use WebViews or browsers, so hide the Help category
- /*if (getActivity().getPackageManager().hasSystemFeature("amazon.hardware.fire_tv")) {
- PreferenceCategory category =
- (PreferenceCategory) findPreference("category_help");
- screen.removePreference(category);
- }*/
- PreferenceCategory category_gamepad_settings =
- (PreferenceCategory) findPreference("category_gamepad_settings");
- // Remove the vibration options if the device can't vibrate
- if (!((Vibrator)getActivity().getSystemService(Context.VIBRATOR_SERVICE)).hasVibrator()) {
- category_gamepad_settings.removePreference(findPreference("checkbox_vibrate_fallback"));
- category_gamepad_settings.removePreference(findPreference("seekbar_vibrate_fallback_strength"));
- // The entire OSC category may have already been removed by the touchscreen check above
- PreferenceCategory category = (PreferenceCategory) findPreference("category_onscreen_controls");
- if (category != null) {
- category.removePreference(findPreference("checkbox_vibrate_osc"));
- }
- }
- else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
- !((Vibrator)getActivity().getSystemService(Context.VIBRATOR_SERVICE)).hasAmplitudeControl() ) {
- // Remove the vibration strength selector of the device doesn't have amplitude control
- category_gamepad_settings.removePreference(findPreference("seekbar_vibrate_fallback_strength"));
- }
-
- Display display = getActivity().getWindowManager().getDefaultDisplay();
- float maxSupportedFps = display.getRefreshRate();
-
- // Hide non-supported resolution/FPS combinations
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- int maxSupportedResW = 0;
-
- // Add a native resolution with any insets included for users that don't want content
- // behind the notch of their display
- boolean hasInsets = false;
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- DisplayCutout cutout;
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- // Use the much nicer Display.getCutout() API on Android 10+
- cutout = display.getCutout();
- }
- else {
- // Android 9 only
- cutout = displayCutoutP;
- }
-
- if (cutout != null) {
- int widthInsets = cutout.getSafeInsetLeft() + cutout.getSafeInsetRight();
- int heightInsets = cutout.getSafeInsetBottom() + cutout.getSafeInsetTop();
-
- if (widthInsets != 0 || heightInsets != 0) {
- DisplayMetrics metrics = new DisplayMetrics();
- display.getRealMetrics(metrics);
-
- int width = Math.max(metrics.widthPixels - widthInsets, metrics.heightPixels - heightInsets);
- int height = Math.min(metrics.widthPixels - widthInsets, metrics.heightPixels - heightInsets);
-
- addNativeResolutionEntries(width, height, false);
- hasInsets = true;
- }
- }
- }
-
- // Always allow resolutions that are smaller or equal to the active
- // display resolution because decoders can report total non-sense to us.
- // For example, a p201 device reports:
- // AVC Decoder: OMX.amlogic.avc.decoder.awesome
- // HEVC Decoder: OMX.amlogic.hevc.decoder.awesome
- // AVC supported width range: 64 - 384
- // HEVC supported width range: 64 - 544
- for (Display.Mode candidate : display.getSupportedModes()) {
- // Some devices report their dimensions in the portrait orientation
- // where height > width. Normalize these to the conventional width > height
- // arrangement before we process them.
-
- int width = Math.max(candidate.getPhysicalWidth(), candidate.getPhysicalHeight());
- int height = Math.min(candidate.getPhysicalWidth(), candidate.getPhysicalHeight());
-
- // Some TVs report strange values here, so let's avoid native resolutions on a TV
- // unless they report greater than 4K resolutions.
- if (!getActivity().getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEVISION) ||
- (width > 3840 || height > 2160)) {
- addNativeResolutionEntries(width, height, hasInsets);
- }
-
- if ((width >= 3840 || height >= 2160) && maxSupportedResW < 3840) {
- maxSupportedResW = 3840;
- }
- else if ((width >= 2560 || height >= 1440) && maxSupportedResW < 2560) {
- maxSupportedResW = 2560;
- }
- else if ((width >= 1920 || height >= 1080) && maxSupportedResW < 1920) {
- maxSupportedResW = 1920;
- }
-
- if (candidate.getRefreshRate() > maxSupportedFps) {
- maxSupportedFps = candidate.getRefreshRate();
- }
- }
-
- // This must be called to do runtime initialization before calling functions that evaluate
- // decoder lists.
- MediaCodecHelper.initialize(getContext(), GlPreferences.readPreferences(getContext()).glRenderer);
-
- MediaCodecInfo avcDecoder = MediaCodecHelper.findProbableSafeDecoder("video/avc", -1);
- MediaCodecInfo hevcDecoder = MediaCodecHelper.findProbableSafeDecoder("video/hevc", -1);
-
- if (avcDecoder != null) {
- Range