diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 0900666ba8..c73ddd2ef9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -7,11 +7,10 @@ body: attributes: value: | Thank you for taking the time to fill out this bug form! - + **READ ME FIRST!** If you're here because something basic is not working (like gamepad input, video, or similar), it's probably something specific to your setup, so make sure you've gone through the Troubleshooting Guide first: https://github.com/moonlight-stream/moonlight-docs/wiki/Troubleshooting - - If you still have trouble with basic functionality after following the guide, join our Discord server where there are many other volunteers who can help (or direct you back here if it looks like a Moonlight bug after all). https://moonlight-stream.org/discord + - type: textarea id: describe-bug attributes: @@ -32,7 +31,7 @@ body: label: Affected games description: List the games you've tried that exhibit the issue. To see if the issue is game-specific, try streaming Steam Big Picture with Moonlight and see if the issue persists there. validations: - required: true + required: false - type: dropdown id: other-clients attributes: @@ -42,7 +41,7 @@ body: - "PC" - "iOS" validations: - required: true + required: false - type: dropdown id: settings-adjusted attributes: @@ -52,14 +51,14 @@ body: - "Yes" - "No" validations: - required: true + required: false - type: textarea id: settings-adjusted-settings attributes: label: Moonlight adjusted settings (please complete the following information) description: If the settings have been adjusted, which settings have been changed? validations: - required: true + required: false - type: dropdown id: settings-default attributes: @@ -95,7 +94,7 @@ body: attributes: label: Gamepad-related streaming issue description: | - Does the problem still remain if you stream the desktop and use https://html5gamepad.com to test your gamepad? + Does the problem still remain if you stream the desktop and use https://html5gamepad.com to test your gamepad? Instructions for streaming the desktop can be found here: https://github.com/moonlight-stream/moonlight-docs/wiki/Setup-Guide options: - "Yes" @@ -109,7 +108,7 @@ body: description: What is the Android version? placeholder: e.g. Android 10 validations: - required: true + required: true - type: input id: device attributes: @@ -133,7 +132,7 @@ body: description: What is the GeForce Experience version? placeholder: e.g. 3.16.0.140 validations: - required: true + required: false - type: input id: server-driver attributes: @@ -141,7 +140,7 @@ body: description: What is the Nvidia GPU driver version? placeholder: e.g. 417.35 validations: - required: true + required: false - type: input id: server-antivirus attributes: @@ -149,7 +148,7 @@ body: description: Which antivirus and firewall software are installed on the Server PC? placeholder: e.g. Windows Defender and Windows Firewall validations: - required: true + required: false - type: textarea id: screenshots attributes: @@ -165,7 +164,7 @@ body: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. render: Shell validations: - required: false + required: false - type: textarea id: additional attributes: @@ -173,4 +172,3 @@ body: description: Anything else you think may be relevant to the issue or special about your specific setup. validations: required: false - \ No newline at end of file diff --git a/.github/auto-comment.yml b/.github/auto-comment.yml deleted file mode 100644 index e6985d222f..0000000000 --- a/.github/auto-comment.yml +++ /dev/null @@ -1,4 +0,0 @@ -issuesOpened: > - If this is a question about Moonlight or you need help troubleshooting a streaming problem, please use the help channels on our [Discord server](https://moonlight-stream.org/discord) instead of GitHub issues. There are many more people available on Discord to help you and answer your questions.

- This issue tracker should only be used for specific bugs or feature requests.

- Thank you, and happy streaming! diff --git a/.github/no-response.yml b/.github/no-response.yml deleted file mode 100644 index 156bc9e083..0000000000 --- a/.github/no-response.yml +++ /dev/null @@ -1,8 +0,0 @@ -# ProBot No Response (https://probot.github.io/apps/no-response/) - -daysUntilClose: 7 -responseRequiredLabel: 'need more info' -closeComment: > - This issue has been automatically closed because there was no response to a - request for more information from the issue opener. Please leave a comment or - open a new issue if you have additional information related to this issue. diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index df9115c27b..0000000000 --- a/.github/stale.yml +++ /dev/null @@ -1,14 +0,0 @@ -# ProBot Stale (https://probot.github.io/apps/stale/) - -daysUntilStale: 90 -daysUntilClose: 7 -exemptLabels: - - accepted - - bug - - enhancement - - meta -staleLabel: stale -markComment: > - This issue has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs. -closeComment: false diff --git a/.gitignore b/.gitignore index e0ab95a517..50b1067396 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ output.json output-metadata.json out/ +baselineProfiles/ # files for the dex VM *.dex diff --git a/.gitmodules b/.gitmodules index 577509631d..4282988a49 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "app/src/main/jni/moonlight-core/moonlight-common-c"] path = app/src/main/jni/moonlight-core/moonlight-common-c - url = https://github.com/moonlight-stream/moonlight-common-c.git + url = https://github.com/ClassicOldSong/moonlight-common-c diff --git a/README.md b/README.md index 0b1bbe780e..c2913588b6 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,70 @@ -# Moonlight Android +# Artemis Android -[![AppVeyor Build Status](https://ci.appveyor.com/api/projects/status/232a8tadrrn8jv0k/branch/master?svg=true)](https://ci.appveyor.com/project/cgutman/moonlight-android/branch/master) -[![Translation Status](https://hosted.weblate.org/widgets/moonlight/-/moonlight-android/svg-badge.svg)](https://hosted.weblate.org/projects/moonlight/moonlight-android/) +Previously named Moonlight Noir -[Moonlight for Android](https://moonlight-stream.org) is an open source client for NVIDIA GameStream and [Sunshine](https://github.com/LizardByte/Sunshine). +An open source client for [Apollo](https://github.com/ClassicOldSong/Apollo)/[Sunshine](https://github.com/LizardByte/Sunshine). -Moonlight for Android will allow you to stream your full collection of games from your Windows PC to your Android device, +Artemis Android will allow you to stream your collection of games from your Windows PC to your Android device, whether in your own home or over the internet. -Moonlight also has a [PC client](https://github.com/moonlight-stream/moonlight-qt) and [iOS/tvOS client](https://github.com/moonlight-stream/moonlight-ios). +Artemis is currently the best fork of Moonlight with loads of optimizations for office usage. -You can follow development on our [Discord server](https://moonlight-stream.org/discord) and help translate Moonlight into your language on [Weblate](https://hosted.weblate.org/projects/moonlight/moonlight-android/). +A more seamless experience with virtual display will be Artemis paired with [Apollo](https://github.com/ClassicOldSong/Apollo). + +# Features + +If you switch back to the main stream version, you'll be missing the following awesome features which are very unlikely to be added there: + +1. Custom virtual buttons with import and export support. +2. [Custom resolutions](https://github.com/moonlight-stream/moonlight-android/pull/1349). +3. Custom bitrates. +4. [Multiple mouse mode switching](https://github.com/moonlight-stream/moonlight-android/pull/1304) (normal mouse, [multi-touch](https://github.com/moonlight-stream/moonlight-android/pull/1364), touchpad, disabled, local cursor mode). +5. Optimized virtual gamepad skins and free joystick. +6. External monitor mode. +7. Joycon D-pad support. +8. Simplified performance information display. +9. [Game back menu](https://github.com/moonlight-stream/moonlight-android/pull/1171). +10. Custom shortcut commands. +11. Easy soft keyboard switching. +12. Portrait mode. +13. Display on top mode, useful for foldable phones. +14. [Virtual touchpad space and sensitivity adjustment](https://github.com/moonlight-stream/moonlight-android/issues/1348#issuecomment-2236344729) for playing right-click view games, such as Warcraft. +15. Force use device's own vibration motor (in case your gamepad's vibration is not effective). +16. Gamepad debugging page to view gamepad vibration and gyroscope information, as well as Android kernel version information. +17. Trackpad tap/scrolling support +18. Natural track pad mode with touch screen +19. Non-QWERTY keyboard layout support +20. Quick Meta key with physical BACK button +21. Frame rate lock fix for some devices +22. Video scale mode: Fit/Fill/Stretch +23. View pan/zoom support +24. Rotate screen in-game +25. Add option to quit app directly +26. Samsung DeX scrolling support +27. Proper click/scroll/right-click for trackpad on generic Android tablet when using local cursor +28. Virtual Display integration with [Apollo](https://github.com/ClassicOldSong/Apollo) +29. Server Command integration with [Apollo](https://github.com/ClassicOldSong/Apollo) +30. Clipboard sync (requires Apollo) + +# Disclaimer + +This is the `go away` version of Moonlight Android. + +I got kicked from Moonlight and Sunshine's Discord server literally for helping people out. + +This is what I got for finding a bug, opened an issue, getting no response, troubleshoot myself, fixed the issue myself, shared it by PR to the main repo hoping my efforts can help someone else during the maintainance gap. + +Yes, I'm going away. Fixes and improvements on this fork are not necessarily be merged to the main repo either. I have also started [a fork of Sunshine called Apollo](https://github.com/ClassicOldSong/Apollo) and will add useful features that will never get merged by the main repo shortly. [Apollo](https://github.com/ClassicOldSong/Apollo) and [Moonlight Noir](https://github.com/ClassicOldSong/moonlight-android) will no longer be compatible with OG Sunshine and OG Moonlight eventually, but they'll work even better with much more carefully designed features. + +The main repo had stayed silent for 5 months, with nobody actually responding to issues, and people are getting totally no help besides the limited FAQ in their Discord server. I tried to answer issues and questions, solve problems within my ablilty but I got kicked out just for helping others. + +**PRs for feature improvements are welcomed here unlike the main repo, your ideas are more likely to be appreciated and your efforts are actually being respected. We welcome people who can and willing to share their efforts, helping yourselves and other people in need.** + +**Update**: They have contacted me and apologized for this incident, but the fact it **happened** still motivated me to start my own fork. ## Downloads -* [Google Play Store](https://play.google.com/store/apps/details?id=com.limelight) -* [Amazon App Store](https://www.amazon.com/gp/product/B00JK4MFN2) -* [F-Droid](https://f-droid.org/packages/com.limelight) -* [APK](https://github.com/moonlight-stream/moonlight-android/releases) +* [Download APK directly](https://github.com/ClassicOldSong/moonlight-android/releases) +* [Use Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.limelight.noir%22%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2FClassicOldSong%2Fmoonlight-android%22%2C%22author%22%3A%22ClassicOldSong%22%2C%22name%22%3A%22Artemis%22%2C%22additionalSettings%22%3A%22%7B%5C%22apkFilterRegEx%5C%22%3A%5C%22nonRoot%5C%22%2C%5C%22matchGroutToUse%5C%22%3A%5C%22%241%5C%22%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22v(.%2B)%5C%22%7D%22%7D) (recommended) ## Building * Install Android Studio and the Android NDK diff --git a/app/build.gradle b/app/build.gradle index 26d5f84c1e..e137af813e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,8 +11,8 @@ android { minSdk 21 targetSdk 34 - versionName "12.1" - versionCode = 314 + versionName "12.1.250415" + versionCode = 43 // Generate native debug symbols to allow Google Play to symbolicate our native crashes ndk.debugSymbolLevel = 'FULL' @@ -36,18 +36,26 @@ android { } } + resValue "string", + "obtainium_app_url", + "data:text/html;base64,PGgxPlJvb3QgYnVpbGQgaXMgbm90IGF2YWlsYWJsZTwvaDE+" + applicationId "com.limelight.root" dimension "root" buildConfigField "boolean", "ROOT_BUILD", "true" } - nonRoot { + nonRoot_game { externalNativeBuild { ndkBuild { arguments "PRODUCT_FLAVOR=nonRoot" } } + resValue "string", + "obtainium_app_url", + "https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.limelight.noir%22%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2FClassicOldSong%2Fmoonlight-android%22%2C%22author%22%3A%22ClassicOldSong%22%2C%22name%22%3A%22Artemis%22%2C%22additionalSettings%22%3A%22%7B%5C%22apkFilterRegEx%5C%22%3A%5C%22nonRoot%5C%22%2C%5C%22matchGroutToUse%5C%22%3A%5C%22%241%5C%22%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22v(.%2B)%5C%22%7D%22%7D" + applicationId "com.limelight" dimension "root" buildConfigField "boolean", "ROOT_BUILD", "false" @@ -81,9 +89,10 @@ android { buildTypes { debug { - applicationIdSuffix ".debug" - resValue "string", "app_label", "Moonlight (Debug)" - resValue "string", "app_label_root", "Moonlight (Root Debug)" + applicationIdSuffix ".noirdebug" + resValue "string", "app_label", "Diana" + resValue "string", "app_label_root", "Diana (Root)" + resValue "string", "app_label_game", "Diana (Game)" minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' @@ -119,9 +128,10 @@ android { // is to please change the applicationId before you publish. // // TL;DR: Leave the following line alone! - applicationIdSuffix ".unofficial" - resValue "string", "app_label", "Moonlight" - resValue "string", "app_label_root", "Moonlight (Root)" + applicationIdSuffix ".noir" + resValue "string", "app_label", "Artemis" + resValue "string", "app_label_root", "Artemis (Root)" + resValue "string", "app_label_game", "Artemis (Game)" minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' @@ -142,4 +152,10 @@ dependencies { implementation 'com.squareup.okhttp3:okhttp:4.12.0' implementation 'org.jmdns:jmdns:3.5.9' implementation 'com.github.cgutman:ShieldControllerExtensions:1.0.1' + implementation 'com.google.code.gson:gson:2.10.1' + implementation 'androidx.annotation:annotation:1.9.0' + implementation 'androidx.cardview:cardview:1.0.0' + implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'androidx.preference:preference:1.2.1' + implementation 'com.github.ByteHamster:SearchPreference:v2.5.1' } diff --git a/app/src/game/AndroidManifest.xml b/app/src/game/AndroidManifest.xml new file mode 100644 index 0000000000..769d99a8b3 --- /dev/null +++ b/app/src/game/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml old mode 100644 new mode 100755 index 7be9a20bf1..8b99ae4e64 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,190 +1,232 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/config/keyboard.json b/app/src/main/assets/config/keyboard.json new file mode 100755 index 0000000000..847103acd0 --- /dev/null +++ b/app/src/main/assets/config/keyboard.json @@ -0,0 +1,330 @@ +{ + "desc": "rocker=摇杆|dpad=十字键|keystroke=普通按钮", + "data": { + "rocker": [ + { + "name": "摇杆", + "elementId": "rocker_1", + "leftCode": 29, + "rightCode": 32, + "upCode": 51, + "downCode": 47, + "middleCode": 59, + "desc":"简单描述下,上下左右中按键code值为keycode查阅附赠的keycode-android.java文件获取,elementId值是唯一的,middleCode是中键的小圆点,双击可以触发" + } + ], + "dpad": [ + { + "name": "十字键", + "elementId": "dpad_1", + "leftCode": 29, + "rightCode": 32, + "upCode": 51, + "downCode": 47, + "desc":"简单描述下,上下左右按键code值为keycode查阅附赠的keycode-android.java文件获取,elementId值是唯一的" + } + ], + "mouse": [ + { + "name": "ML", + "code": 1, + "desc":"简单描述下,code值固定,name为按钮显示文字,ML左键、MM中键、MR右键" + }, + { + "name": "MR", + "code": 3 + }, + { + "name": "MR+", + "code": 3, + "switchButton": 1 + }, + { + "name": "MM", + "code": 2 + }, + { + "name": "TP(R)", + "code": 9, + "desc": "特殊触控板按键适合魔兽转视野,name的数值可以清空,但是怕不好区分,可以修改成其他的" + }, + { + "name": "TP", + "code": 10, + "desc": "普通触控板控件" + }, + { + "name": "TP(L)", + "code": 11, + "desc": "一直按着左键不放~" + } + + ], + "keystroke": [ + { + "name": "Esc", + "code": 111 + }, + { + "name": "F1", + "code": 131 + }, + { + "name": "F2", + "code": 132 + }, + { + "name": "F3", + "code": 133 + }, + { + "name": "F4", + "code": 134 + }, + { + "name": "F5", + "code": 135 + }, + { + "name": "F6", + "code": 136 + }, + { + "name": "F7", + "code": 137 + }, + { + "name": "F8", + "code": 138 + }, + { + "name": "F9", + "code": 139 + }, + { + "name": "F10", + "code": 140 + }, + { + "name": "F11", + "code": 141 + }, + { + "name": "F12", + "code": 142 + }, + { + "name": "Del", + "code": 112 + }, + { + "name": "Home", + "code": 122 + }, + { + "name": "End", + "code": 123 + }, + { + "name": "PgUp", + "code": 92 + }, + { + "name": "PgDn", + "code": 93 + }, + { + "name": "1", + "code": 8 + }, + { + "name": "2", + "code": 9 + }, + { + "name": "3", + "code": 10 + }, + { + "name": "4", + "code": 11 + }, + { + "name": "5", + "code": 12 + }, + { + "name": "6", + "code": 13 + }, + { + "name": "7", + "code": 14 + }, + { + "name": "8", + "code": 15 + }, + { + "name": "9", + "code": 16 + }, + { + "name": "0", + "code": 7 + }, + { + "name": "Ctrl", + "code": 113 + }, + { + "name": "Win", + "code": 117 + }, + { + "name": "Alt", + "code": 57 + }, + { + "name": "Space", + "code": 62 + }, + { + "name": "Shift", + "code": 59 + }, + { + "name": "Tab", + "code": 61 + }, + { + "name": "Enter", + "code": 66 + }, + { + "name": "A", + "code": 29 + }, + { + "name": "B", + "code": 30 + }, + { + "name": "C", + "code": 31 + }, + { + "name": "D", + "code": 32 + }, + { + "name": "E", + "code": 33 + }, + { + "name": "F", + "code": 34 + }, + { + "name": "G", + "code": 35 + }, + { + "name": "H", + "code": 36 + }, + { + "name": "I", + "code": 37 + }, + { + "name": "J", + "code": 38 + }, + { + "name": "K", + "code": 39 + }, + { + "name": "L", + "code": 40 + }, + { + "name": "M", + "code": 41 + }, + { + "name": "N", + "code": 42 + }, + { + "name": "O", + "code": 43 + }, + { + "name": "P", + "code": 44 + }, + { + "name": "Q", + "code": 45 + }, + { + "name": "R", + "code": 46 + }, + { + "name": "S", + "code": 47 + }, + { + "name": "T", + "code": 48 + }, + { + "name": "U", + "code": 49 + }, + { + "name": "V", + "code": 50 + }, + { + "name": "W", + "code": 51 + }, + { + "name": "X", + "code": 52 + }, + { + "name": "Y", + "code": 53 + }, + { + "name": "Z", + "code": 54 + }, + { + "name": "Insert", + "code": 124 + }, + { + "name": "↑", + "code": 19 + }, + { + "name": "↓", + "code": 20 + }, + { + "name": "←", + "code": 21 + }, + { + "name": "→", + "code": 22 + } + ] + } +} \ No newline at end of file diff --git a/app/src/main/assets/config/specialbuttons.json b/app/src/main/assets/config/specialbuttons.json new file mode 100755 index 0000000000..042202a42f --- /dev/null +++ b/app/src/main/assets/config/specialbuttons.json @@ -0,0 +1,14 @@ +{ + "desc": "游戏快捷菜单中快捷指令,键值参考PC端:https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes", + "data":[ + { + "name": "自定义导入数据 (切换显示器)", + "desc": "data中的数值为上方链接获取的16进制code,例如ctrl为0xA2,alt为0xA4,shift为0xA0,f12为0x7B,win为0x5B", + "data":["0xA2","0xA4","0xA0","0x7B"] + }, + { + "name": "自定义导入数据 (ALT+F4)", + "data":["0xA4","0x73"] + } + ] +} \ No newline at end of file diff --git a/app/src/main/ic_launcher-web.png b/app/src/main/ic_launcher-web.png old mode 100644 new mode 100755 diff --git a/app/src/main/java/com/limelight/AppView.java b/app/src/main/java/com/limelight/AppView.java old mode 100644 new mode 100755 index 1337627e2e..bbab02dfad --- a/app/src/main/java/com/limelight/AppView.java +++ b/app/src/main/java/com/limelight/AppView.java @@ -1,665 +1,732 @@ -package com.limelight; - -import java.io.IOException; -import java.io.StringReader; -import java.util.HashSet; -import java.util.List; - -import com.limelight.computers.ComputerManagerListener; -import com.limelight.computers.ComputerManagerService; -import com.limelight.grid.AppGridAdapter; -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.preferences.PreferenceConfiguration; -import com.limelight.ui.AdapterFragment; -import com.limelight.ui.AdapterFragmentCallbacks; -import com.limelight.utils.CacheHelper; -import com.limelight.utils.Dialog; -import com.limelight.utils.ServerHelper; -import com.limelight.utils.ShortcutHelper; -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.Intent; -import android.content.ServiceConnection; -import android.content.SharedPreferences; -import android.content.res.Configuration; -import android.graphics.Bitmap; -import android.graphics.drawable.BitmapDrawable; -import android.os.Build; -import android.os.Bundle; -import android.os.IBinder; -import android.view.ContextMenu; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.view.ContextMenu.ContextMenuInfo; -import android.widget.AbsListView; -import android.widget.AdapterView; -import android.widget.AdapterView.OnItemClickListener; -import android.widget.ImageView; -import android.widget.TextView; -import android.widget.Toast; -import android.widget.AdapterView.AdapterContextMenuInfo; - -import org.xmlpull.v1.XmlPullParserException; - -public class AppView extends Activity implements AdapterFragmentCallbacks { - private AppGridAdapter appGridAdapter; - private String uuidString; - private ShortcutHelper shortcutHelper; - - private ComputerDetails computer; - private ComputerManagerService.ApplistPoller poller; - private SpinnerDialog blockingLoadSpinner; - private String lastRawApplist; - private int lastRunningAppId; - private boolean suspendGridUpdates; - private boolean inForeground; - private boolean showHiddenApps; - private HashSet hiddenAppIds = new HashSet<>(); - - private final static int START_OR_RESUME_ID = 1; - private final static int QUIT_ID = 2; - private final static int START_WITH_QUIT = 4; - private final static int VIEW_DETAILS_ID = 5; - private final static int CREATE_SHORTCUT_ID = 6; - private final static int HIDE_APP_ID = 7; - - public final static String HIDDEN_APPS_PREF_FILENAME = "HiddenApps"; - - public final static String NAME_EXTRA = "Name"; - public final static String UUID_EXTRA = "UUID"; - public final static String NEW_PAIR_EXTRA = "NewPair"; - public final static String SHOW_HIDDEN_APPS_EXTRA = "ShowHiddenApps"; - - private ComputerManagerService.ComputerManagerBinder managerBinder; - 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(); - - // Get the computer object - computer = localBinder.getComputer(uuidString); - if (computer == null) { - finish(); - return; - } - - // Add a launcher shortcut for this PC (forced, since this is user interaction) - shortcutHelper.createAppViewShortcut(computer, true, getIntent().getBooleanExtra(NEW_PAIR_EXTRA, false)); - shortcutHelper.reportComputerShortcutUsed(computer); - - try { - appGridAdapter = new AppGridAdapter(AppView.this, - PreferenceConfiguration.readPreferences(AppView.this), - computer, localBinder.getUniqueId(), - showHiddenApps); - } catch (Exception e) { - e.printStackTrace(); - finish(); - return; - } - - appGridAdapter.updateHiddenApps(hiddenAppIds, true); - - // Now make the binder visible. We must do this after appGridAdapter - // is set to prevent us from reaching updateUiWithServerinfo() and - // touching the appGridAdapter prior to initialization. - managerBinder = localBinder; - - // Load the app grid with cached data (if possible). - // This must be done _before_ startComputerUpdates() - // so the initial serverinfo response can update the running - // icon. - populateAppGridWithCache(); - - // Start updates - startComputerUpdates(); - - runOnUiThread(new Runnable() { - @Override - public void run() { - if (isFinishing() || isChangingConfigurations()) { - return; - } - - // Despite my best efforts to catch all conditions that could - // cause the activity to be destroyed when we try to commit - // I haven't been able to, so we have this try-catch block. - try { - getFragmentManager().beginTransaction() - .replace(R.id.appFragmentContainer, new AdapterFragment()) - .commitAllowingStateLoss(); - } catch (IllegalStateException e) { - e.printStackTrace(); - } - } - }); - } - }.start(); - } - - public void onServiceDisconnected(ComponentName className) { - managerBinder = null; - } - }; - - @Override - public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - - // If appGridAdapter is initialized, let it know about the configuration change. - // If not, it will pick it up when it initializes. - if (appGridAdapter != null) { - // Update the app grid adapter to create grid items with the correct layout - appGridAdapter.updateLayoutWithPreferences(this, PreferenceConfiguration.readPreferences(this)); - - try { - // Reinflate the app grid itself to pick up the layout change - getFragmentManager().beginTransaction() - .replace(R.id.appFragmentContainer, new AdapterFragment()) - .commitAllowingStateLoss(); - } catch (IllegalStateException e) { - e.printStackTrace(); - } - } - } - - private void startComputerUpdates() { - // Don't start polling if we're not bound or in the foreground - if (managerBinder == null || !inForeground) { - return; - } - - managerBinder.startPolling(new ComputerManagerListener() { - @Override - public void notifyComputerUpdated(final ComputerDetails details) { - // Do nothing if updates are suspended - if (suspendGridUpdates) { - return; - } - - // Don't care about other computers - if (!details.uuid.equalsIgnoreCase(uuidString)) { - return; - } - - if (details.state == ComputerDetails.State.OFFLINE) { - // The PC is unreachable now - AppView.this.runOnUiThread(new Runnable() { - @Override - public void run() { - // Display a toast to the user and quit the activity - Toast.makeText(AppView.this, getResources().getText(R.string.lost_connection), Toast.LENGTH_SHORT).show(); - finish(); - } - }); - - return; - } - - // Close immediately if the PC is no longer paired - if (details.state == ComputerDetails.State.ONLINE && details.pairState != PairingManager.PairState.PAIRED) { - AppView.this.runOnUiThread(new Runnable() { - @Override - public void run() { - // Disable shortcuts referencing this PC for now - shortcutHelper.disableComputerShortcut(details, - getResources().getString(R.string.scut_not_paired)); - - // Display a toast to the user and quit the activity - Toast.makeText(AppView.this, getResources().getText(R.string.scut_not_paired), Toast.LENGTH_SHORT).show(); - finish(); - } - }); - - return; - } - - // App list is the same or empty - if (details.rawAppList == null || details.rawAppList.equals(lastRawApplist)) { - - // Let's check if the running app ID changed - if (details.runningGameId != lastRunningAppId) { - // Update the currently running game using the app ID - lastRunningAppId = details.runningGameId; - updateUiWithServerinfo(details); - } - - return; - } - - lastRunningAppId = details.runningGameId; - lastRawApplist = details.rawAppList; - - try { - updateUiWithAppList(NvHTTP.getAppListByReader(new StringReader(details.rawAppList))); - updateUiWithServerinfo(details); - - if (blockingLoadSpinner != null) { - blockingLoadSpinner.dismiss(); - blockingLoadSpinner = null; - } - } catch (XmlPullParserException | IOException e) { - e.printStackTrace(); - } - } - }); - - if (poller == null) { - poller = managerBinder.createAppListPoller(computer); - } - poller.start(); - } - - private void stopComputerUpdates() { - if (poller != null) { - poller.stop(); - } - - if (managerBinder != null) { - managerBinder.stopPolling(); - } - - if (appGridAdapter != null) { - appGridAdapter.cancelQueuedOperations(); - } - } - - @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; - - shortcutHelper = new ShortcutHelper(this); - - UiHelper.setLocale(this); - - setContentView(R.layout.activity_app_view); - - // Allow floating expanded PiP overlays while browsing apps - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - setShouldDockBigOverlays(false); - } - - UiHelper.notifyNewRootView(this); - - showHiddenApps = getIntent().getBooleanExtra(SHOW_HIDDEN_APPS_EXTRA, false); - uuidString = getIntent().getStringExtra(UUID_EXTRA); - - SharedPreferences hiddenAppsPrefs = getSharedPreferences(HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE); - for (String hiddenAppIdStr : hiddenAppsPrefs.getStringSet(uuidString, new HashSet())) { - hiddenAppIds.add(Integer.parseInt(hiddenAppIdStr)); - } - - String computerName = getIntent().getStringExtra(NAME_EXTRA); - - TextView label = findViewById(R.id.appListText); - setTitle(computerName); - label.setText(computerName); - - // Bind to the computer manager service - bindService(new Intent(this, ComputerManagerService.class), serviceConnection, - Service.BIND_AUTO_CREATE); - } - - private void updateHiddenApps(boolean hideImmediately) { - HashSet hiddenAppIdStringSet = new HashSet<>(); - - for (Integer hiddenAppId : hiddenAppIds) { - hiddenAppIdStringSet.add(hiddenAppId.toString()); - } - - getSharedPreferences(HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE) - .edit() - .putStringSet(uuidString, hiddenAppIdStringSet) - .apply(); - - appGridAdapter.updateHiddenApps(hiddenAppIds, hideImmediately); - } - - private void populateAppGridWithCache() { - try { - // Try to load from cache - lastRawApplist = CacheHelper.readInputStreamToString(CacheHelper.openCacheFileForInput(getCacheDir(), "applist", uuidString)); - List applist = NvHTTP.getAppListByReader(new StringReader(lastRawApplist)); - updateUiWithAppList(applist); - LimeLog.info("Loaded applist from cache"); - } catch (IOException | XmlPullParserException e) { - if (lastRawApplist != null) { - LimeLog.warning("Saved applist corrupted: "+lastRawApplist); - e.printStackTrace(); - } - LimeLog.info("Loading applist from the network"); - // We'll need to load from the network - loadAppsBlocking(); - } - } - - private void loadAppsBlocking() { - blockingLoadSpinner = SpinnerDialog.displayDialog(this, getResources().getString(R.string.applist_refresh_title), - getResources().getString(R.string.applist_refresh_msg), true); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - - SpinnerDialog.closeDialogs(this); - Dialog.closeDialogs(); - - 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(); - } - - @Override - public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { - super.onCreateContextMenu(menu, v, menuInfo); - - AdapterContextMenuInfo info = (AdapterContextMenuInfo) menuInfo; - AppObject selectedApp = (AppObject) appGridAdapter.getItem(info.position); - - menu.setHeaderTitle(selectedApp.app.getAppName()); - - if (lastRunningAppId != 0) { - if (lastRunningAppId == selectedApp.app.getAppId()) { - menu.add(Menu.NONE, START_OR_RESUME_ID, 1, getResources().getString(R.string.applist_menu_resume)); - menu.add(Menu.NONE, QUIT_ID, 2, getResources().getString(R.string.applist_menu_quit)); - } - else { - menu.add(Menu.NONE, START_WITH_QUIT, 1, getResources().getString(R.string.applist_menu_quit_and_start)); - } - } - - // Only show the hide checkbox if this is not the currently running app or it's already hidden - if (lastRunningAppId != selectedApp.app.getAppId() || selectedApp.isHidden) { - MenuItem hideAppItem = menu.add(Menu.NONE, HIDE_APP_ID, 3, getResources().getString(R.string.applist_menu_hide_app)); - hideAppItem.setCheckable(true); - hideAppItem.setChecked(selectedApp.isHidden); - } - - menu.add(Menu.NONE, VIEW_DETAILS_ID, 4, getResources().getString(R.string.applist_menu_details)); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // Only add an option to create shortcut if box art is loaded - // and when we're in grid-mode (not list-mode). - ImageView appImageView = info.targetView.findViewById(R.id.grid_image); - if (appImageView != null) { - // We have a grid ImageView, so we must be in grid-mode - BitmapDrawable drawable = (BitmapDrawable)appImageView.getDrawable(); - if (drawable != null && drawable.getBitmap() != null) { - // We have a bitmap loaded too - menu.add(Menu.NONE, CREATE_SHORTCUT_ID, 5, getResources().getString(R.string.applist_menu_scut)); - } - } - } - } - - @Override - public void onContextMenuClosed(Menu menu) { - } - - @Override - public boolean onContextItemSelected(MenuItem item) { - AdapterContextMenuInfo info = (AdapterContextMenuInfo) item.getMenuInfo(); - final AppObject app = (AppObject) appGridAdapter.getItem(info.position); - switch (item.getItemId()) { - case START_WITH_QUIT: - // Display a confirmation dialog first - UiHelper.displayQuitConfirmationDialog(this, new Runnable() { - @Override - public void run() { - ServerHelper.doStart(AppView.this, app.app, computer, managerBinder); - } - }, null); - return true; - - case START_OR_RESUME_ID: - // Resume is the same as start for us - ServerHelper.doStart(AppView.this, app.app, computer, managerBinder); - return true; - - case QUIT_ID: - // Display a confirmation dialog first - UiHelper.displayQuitConfirmationDialog(this, new Runnable() { - @Override - public void run() { - suspendGridUpdates = true; - ServerHelper.doQuit(AppView.this, computer, - app.app, managerBinder, new Runnable() { - @Override - public void run() { - // Trigger a poll immediately - suspendGridUpdates = false; - if (poller != null) { - poller.pollNow(); - } - } - }); - } - }, null); - return true; - - case VIEW_DETAILS_ID: - Dialog.displayDialog(AppView.this, getResources().getString(R.string.title_details), app.app.toString(), false); - return true; - - case HIDE_APP_ID: - if (item.isChecked()) { - // Transitioning hidden to shown - hiddenAppIds.remove(app.app.getAppId()); - } - else { - // Transitioning shown to hidden - hiddenAppIds.add(app.app.getAppId()); - } - updateHiddenApps(false); - return true; - - case CREATE_SHORTCUT_ID: - ImageView appImageView = info.targetView.findViewById(R.id.grid_image); - Bitmap appBits = ((BitmapDrawable)appImageView.getDrawable()).getBitmap(); - if (!shortcutHelper.createPinnedGameShortcut(computer, app.app, appBits)) { - Toast.makeText(AppView.this, getResources().getString(R.string.unable_to_pin_shortcut), Toast.LENGTH_LONG).show(); - } - return true; - - default: - return super.onContextItemSelected(item); - } - } - - private void updateUiWithServerinfo(final ComputerDetails details) { - AppView.this.runOnUiThread(new Runnable() { - @Override - public void run() { - boolean updated = false; - - // Look through our current app list to tag the running app - for (int i = 0; i < appGridAdapter.getCount(); i++) { - AppObject existingApp = (AppObject) appGridAdapter.getItem(i); - - // There can only be one or zero apps running. - if (existingApp.isRunning && - existingApp.app.getAppId() == details.runningGameId) { - // This app was running and still is, so we're done now - return; - } - else if (existingApp.app.getAppId() == details.runningGameId) { - // This app wasn't running but now is - existingApp.isRunning = true; - updated = true; - } - else if (existingApp.isRunning) { - // This app was running but now isn't - existingApp.isRunning = false; - updated = true; - } - else { - // This app wasn't running and still isn't - } - } - - if (updated) { - appGridAdapter.notifyDataSetChanged(); - } - } - }); - } - - private void updateUiWithAppList(final List appList) { - AppView.this.runOnUiThread(new Runnable() { - @Override - public void run() { - boolean updated = false; - - // First handle app updates and additions - for (NvApp app : appList) { - boolean foundExistingApp = false; - - // Try to update an existing app in the list first - for (int i = 0; i < appGridAdapter.getCount(); i++) { - AppObject existingApp = (AppObject) appGridAdapter.getItem(i); - if (existingApp.app.getAppId() == app.getAppId()) { - // Found the app; update its properties - if (!existingApp.app.getAppName().equals(app.getAppName())) { - existingApp.app.setAppName(app.getAppName()); - updated = true; - } - - foundExistingApp = true; - break; - } - } - - if (!foundExistingApp) { - // This app must be new - appGridAdapter.addApp(new AppObject(app)); - - // We could have a leftover shortcut from last time this PC was paired - // or if this app was removed then added again. Enable those shortcuts - // again if present. - shortcutHelper.enableAppShortcut(computer, app); - - updated = true; - } - } - - // Next handle app removals - int i = 0; - while (i < appGridAdapter.getCount()) { - boolean foundExistingApp = false; - AppObject existingApp = (AppObject) appGridAdapter.getItem(i); - - // Check if this app is in the latest list - for (NvApp app : appList) { - if (existingApp.app.getAppId() == app.getAppId()) { - foundExistingApp = true; - break; - } - } - - // This app was removed in the latest app list - if (!foundExistingApp) { - shortcutHelper.disableAppShortcut(computer, existingApp.app, "App removed from PC"); - appGridAdapter.removeApp(existingApp); - updated = true; - - // Check this same index again because the item at i+1 is now at i after - // the removal - continue; - } - - // Move on to the next item - i++; - } - - if (updated) { - appGridAdapter.notifyDataSetChanged(); - } - } - }); - } - - @Override - public int getAdapterFragmentLayoutId() { - return PreferenceConfiguration.readPreferences(AppView.this).smallIconMode ? - R.layout.app_grid_view_small : R.layout.app_grid_view; - } - - @Override - public void receiveAbsListView(AbsListView listView) { - listView.setAdapter(appGridAdapter); - listView.setOnItemClickListener(new OnItemClickListener() { - @Override - public void onItemClick(AdapterView arg0, View arg1, int pos, - long id) { - AppObject app = (AppObject) appGridAdapter.getItem(pos); - - // Only open the context menu if something is running, otherwise start it - if (lastRunningAppId != 0) { - openContextMenu(arg1); - } else { - ServerHelper.doStart(AppView.this, app.app, computer, managerBinder); - } - } - }); - UiHelper.applyStatusBarPadding(listView); - registerForContextMenu(listView); - listView.requestFocus(); - } - - public static class AppObject { - public final NvApp app; - public boolean isRunning; - public boolean isHidden; - - public AppObject(NvApp app) { - if (app == null) { - throw new IllegalArgumentException("app must not be null"); - } - this.app = app; - } - - @Override - public String toString() { - return app.getAppName(); - } - } -} +package com.limelight; + +import java.io.IOException; +import java.io.StringReader; +import java.util.HashSet; +import java.util.List; + +import com.limelight.computers.ComputerManagerListener; +import com.limelight.computers.ComputerManagerService; +import com.limelight.grid.AppGridAdapter; +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.preferences.PreferenceConfiguration; +import com.limelight.ui.AdapterFragment; +import com.limelight.ui.AdapterFragmentCallbacks; +import com.limelight.utils.CacheHelper; +import com.limelight.utils.Dialog; +import com.limelight.utils.ServerHelper; +import com.limelight.utils.ShortcutHelper; +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.Intent; +import android.content.ServiceConnection; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; +import android.view.ContextMenu; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ContextMenu.ContextMenuInfo; +import android.widget.AbsListView; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; +import android.widget.AdapterView.AdapterContextMenuInfo; + +import org.xmlpull.v1.XmlPullParserException; + +public class AppView extends Activity implements AdapterFragmentCallbacks { + private AppGridAdapter appGridAdapter; + private String uuidString; + private ShortcutHelper shortcutHelper; + + private ComputerDetails computer; + private ComputerManagerService.ApplistPoller poller; + private SpinnerDialog blockingLoadSpinner; + private String lastRawApplist; + private int lastRunningAppId; + private boolean suspendGridUpdates; + private boolean inForeground; + private boolean showHiddenApps; + private HashSet hiddenAppIds = new HashSet<>(); + + private PreferenceConfiguration prefConfig; + + private final static int START_OR_RESUME_ID = 1; + private final static int QUIT_ID = 2; + private final static int START_WITH_QUIT = 4; + private final static int VIEW_DETAILS_ID = 5; + private final static int CREATE_SHORTCUT_ID = 6; + private final static int HIDE_APP_ID = 7; + private final static int START_WITH_VDISPLAY = 20; + private final static int START_WITH_QUIT_VDISPLAY = 21; + + public final static String HIDDEN_APPS_PREF_FILENAME = "HiddenApps"; + + public final static String NAME_EXTRA = "Name"; + public final static String UUID_EXTRA = "UUID"; + public final static String NEW_PAIR_EXTRA = "NewPair"; + public final static String SHOW_HIDDEN_APPS_EXTRA = "ShowHiddenApps"; + + private ComputerManagerService.ComputerManagerBinder managerBinder; + 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(); + + // Get the computer object + computer = localBinder.getComputer(uuidString); + if (computer == null) { + finish(); + return; + } + + // Add a launcher shortcut for this PC (forced, since this is user interaction) + shortcutHelper.createAppViewShortcut(computer, true, getIntent().getBooleanExtra(NEW_PAIR_EXTRA, false)); + shortcutHelper.reportComputerShortcutUsed(computer); + + try { + appGridAdapter = new AppGridAdapter(AppView.this, + PreferenceConfiguration.readPreferences(AppView.this), + computer, localBinder.getUniqueId(), + showHiddenApps); + } catch (Exception e) { + e.printStackTrace(); + finish(); + return; + } + + appGridAdapter.updateHiddenApps(hiddenAppIds, true); + + // Now make the binder visible. We must do this after appGridAdapter + // is set to prevent us from reaching updateUiWithServerinfo() and + // touching the appGridAdapter prior to initialization. + managerBinder = localBinder; + + // Load the app grid with cached data (if possible). + // This must be done _before_ startComputerUpdates() + // so the initial serverinfo response can update the running + // icon. + populateAppGridWithCache(); + + // Start updates + startComputerUpdates(); + + runOnUiThread(new Runnable() { + @Override + public void run() { + if (isFinishing() || isChangingConfigurations()) { + return; + } + + // Despite my best efforts to catch all conditions that could + // cause the activity to be destroyed when we try to commit + // I haven't been able to, so we have this try-catch block. + try { + getFragmentManager().beginTransaction() + .replace(R.id.appFragmentContainer, new AdapterFragment()) + .commitAllowingStateLoss(); + } catch (IllegalStateException e) { + e.printStackTrace(); + } + } + }); + } + }.start(); + } + + public void onServiceDisconnected(ComponentName className) { + managerBinder = null; + } + }; + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + this.prefConfig = PreferenceConfiguration.readPreferences(this); + + // If appGridAdapter is initialized, let it know about the configuration change. + // If not, it will pick it up when it initializes. + if (appGridAdapter != null) { + // Update the app grid adapter to create grid items with the correct layout + appGridAdapter.updateLayoutWithPreferences(this, this.prefConfig); + + try { + // Reinflate the app grid itself to pick up the layout change + getFragmentManager().beginTransaction() + .replace(R.id.appFragmentContainer, new AdapterFragment()) + .commitAllowingStateLoss(); + } catch (IllegalStateException e) { + e.printStackTrace(); + } + } + } + + private void startComputerUpdates() { + // Don't start polling if we're not bound or in the foreground + if (managerBinder == null || !inForeground) { + return; + } + + managerBinder.startPolling(new ComputerManagerListener() { + @Override + public void notifyComputerUpdated(final ComputerDetails details) { + // Do nothing if updates are suspended + if (suspendGridUpdates) { + return; + } + + // Don't care about other computers + if (!details.uuid.equalsIgnoreCase(uuidString)) { + return; + } + + if (details.state == ComputerDetails.State.OFFLINE) { + // The PC is unreachable now + AppView.this.runOnUiThread(new Runnable() { + @Override + public void run() { + // Display a toast to the user and quit the activity + Toast.makeText(AppView.this, getResources().getText(R.string.lost_connection), Toast.LENGTH_SHORT).show(); + finish(); + } + }); + + return; + } + + // Close immediately if the PC is no longer paired + if (details.state == ComputerDetails.State.ONLINE && details.pairState != PairingManager.PairState.PAIRED) { + AppView.this.runOnUiThread(new Runnable() { + @Override + public void run() { + // Disable shortcuts referencing this PC for now + shortcutHelper.disableComputerShortcut(details, + getResources().getString(R.string.scut_not_paired)); + + // Display a toast to the user and quit the activity + Toast.makeText(AppView.this, getResources().getText(R.string.scut_not_paired), Toast.LENGTH_SHORT).show(); + finish(); + } + }); + + return; + } + + // App list is the same or empty + if (details.rawAppList == null || details.rawAppList.equals(lastRawApplist)) { + + // Let's check if the running app ID changed + if (details.runningGameId != lastRunningAppId) { + // Update the currently running game using the app ID + lastRunningAppId = details.runningGameId; + updateUiWithServerinfo(details); + } + + return; + } + + lastRunningAppId = details.runningGameId; + lastRawApplist = details.rawAppList; + + try { + updateUiWithAppList(NvHTTP.getAppListByReader(new StringReader(details.rawAppList))); + updateUiWithServerinfo(details); + + if (blockingLoadSpinner != null) { + blockingLoadSpinner.dismiss(); + blockingLoadSpinner = null; + } + } catch (XmlPullParserException | IOException e) { + e.printStackTrace(); + } + } + }); + + if (poller == null) { + poller = managerBinder.createAppListPoller(computer); + } + poller.start(); + } + + private void stopComputerUpdates() { + if (poller != null) { + poller.stop(); + } + + if (managerBinder != null) { + managerBinder.stopPolling(); + } + + if (appGridAdapter != null) { + appGridAdapter.cancelQueuedOperations(); + } + } + + @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; + + shortcutHelper = new ShortcutHelper(this); + + UiHelper.setLocale(this); + + setContentView(R.layout.activity_app_view); + + // Allow floating expanded PiP overlays while browsing apps + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + setShouldDockBigOverlays(false); + } + + UiHelper.notifyNewRootView(this); + + showHiddenApps = getIntent().getBooleanExtra(SHOW_HIDDEN_APPS_EXTRA, false); + uuidString = getIntent().getStringExtra(UUID_EXTRA); + + SharedPreferences hiddenAppsPrefs = getSharedPreferences(HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE); + for (String hiddenAppIdStr : hiddenAppsPrefs.getStringSet(uuidString, new HashSet())) { + hiddenAppIds.add(Integer.parseInt(hiddenAppIdStr)); + } + + String computerName = getIntent().getStringExtra(NAME_EXTRA); + + TextView label = findViewById(R.id.appListText); + setTitle(computerName); + label.setText(computerName); + + this.prefConfig = PreferenceConfiguration.readPreferences(this); + + // Bind to the computer manager service + bindService(new Intent(this, ComputerManagerService.class), serviceConnection, + Service.BIND_AUTO_CREATE); + } + + private void updateHiddenApps(boolean hideImmediately) { + HashSet hiddenAppIdStringSet = new HashSet<>(); + + for (Integer hiddenAppId : hiddenAppIds) { + hiddenAppIdStringSet.add(hiddenAppId.toString()); + } + + getSharedPreferences(HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE) + .edit() + .putStringSet(uuidString, hiddenAppIdStringSet) + .apply(); + + appGridAdapter.updateHiddenApps(hiddenAppIds, hideImmediately); + } + + private void populateAppGridWithCache() { + try { + // Try to load from cache + lastRawApplist = CacheHelper.readInputStreamToString(CacheHelper.openCacheFileForInput(getCacheDir(), "applist", uuidString)); + List applist = NvHTTP.getAppListByReader(new StringReader(lastRawApplist)); + updateUiWithAppList(applist); + LimeLog.info("Loaded applist from cache"); + } catch (IOException | XmlPullParserException e) { + if (lastRawApplist != null) { + LimeLog.warning("Saved applist corrupted: "+lastRawApplist); + e.printStackTrace(); + } + LimeLog.info("Loading applist from the network"); + // We'll need to load from the network + loadAppsBlocking(); + } + } + + private void loadAppsBlocking() { + blockingLoadSpinner = SpinnerDialog.displayDialog(this, getResources().getString(R.string.applist_refresh_title), + getResources().getString(R.string.applist_refresh_msg), true); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + SpinnerDialog.closeDialogs(this); + Dialog.closeDialogs(); + + 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(); + } + + @Override + public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, v, menuInfo); + + AdapterContextMenuInfo info = (AdapterContextMenuInfo) menuInfo; + AppObject selectedApp = (AppObject) appGridAdapter.getItem(info.position); + + menu.setHeaderTitle(selectedApp.app.getAppName()); + + if (lastRunningAppId == 0) { + if (prefConfig.useVirtualDisplay) { + menu.add(Menu.NONE, START_OR_RESUME_ID, 1, getResources().getString(R.string.applist_menu_start_primarydisplay)); + } else { + menu.add(Menu.NONE, START_WITH_VDISPLAY, 1, getResources().getString(R.string.applist_menu_start_vdisplay)); + } + } else { + if (lastRunningAppId == selectedApp.app.getAppId()) { + menu.add(Menu.NONE, START_OR_RESUME_ID, 1, getResources().getString(R.string.applist_menu_resume)); + menu.add(Menu.NONE, QUIT_ID, 2, getResources().getString(R.string.applist_menu_quit)); + } + else { + if (prefConfig.useVirtualDisplay) { + menu.add(Menu.NONE, START_WITH_QUIT_VDISPLAY, 1, getResources().getString(R.string.applist_menu_quit_and_start)); + menu.add(Menu.NONE, START_WITH_QUIT, 2, getResources().getString(R.string.applist_menu_quit_and_start_primarydisplay)); + } else{ + menu.add(Menu.NONE, START_WITH_QUIT, 1, getResources().getString(R.string.applist_menu_quit_and_start)); + menu.add(Menu.NONE, START_WITH_QUIT_VDISPLAY, 2, getResources().getString(R.string.applist_menu_quit_and_start_vdisplay)); + } + } + } + + // Only show the hide checkbox if this is not the currently running app or it's already hidden + if (lastRunningAppId != selectedApp.app.getAppId() || selectedApp.isHidden) { + MenuItem hideAppItem = menu.add(Menu.NONE, HIDE_APP_ID, 3, getResources().getString(R.string.applist_menu_hide_app)); + hideAppItem.setCheckable(true); + hideAppItem.setChecked(selectedApp.isHidden); + } + + menu.add(Menu.NONE, VIEW_DETAILS_ID, 4, getResources().getString(R.string.applist_menu_details)); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Only add an option to create shortcut if box art is loaded + // and when we're in grid-mode (not list-mode). + ImageView appImageView = info.targetView.findViewById(R.id.grid_image); + if (appImageView != null) { + // We have a grid ImageView, so we must be in grid-mode + BitmapDrawable drawable = (BitmapDrawable)appImageView.getDrawable(); + if (drawable != null && drawable.getBitmap() != null) { + // We have a bitmap loaded too + menu.add(Menu.NONE, CREATE_SHORTCUT_ID, 5, getResources().getString(R.string.applist_menu_scut)); + } + } + } + } + + @Override + public void onContextMenuClosed(Menu menu) { + } + + @Override + public boolean onContextItemSelected(MenuItem item) { + AdapterContextMenuInfo info = (AdapterContextMenuInfo) item.getMenuInfo(); + final AppObject app = (AppObject) appGridAdapter.getItem(info.position); + int itemId = item.getItemId(); + switch (itemId) { + case START_WITH_QUIT: + case START_WITH_QUIT_VDISPLAY: { + boolean withVDiaplay = itemId == START_WITH_QUIT_VDISPLAY; + if (withVDiaplay && !(computer.vDisplaySupported && computer.vDisplayDriverReady)) { + UiHelper.displayVdisplayConfirmationDialog( + AppView.this, + computer, + () -> UiHelper.displayQuitConfirmationDialog(this, new Runnable() { + @Override + public void run() { + ServerHelper.doStart(AppView.this, app.app, computer, managerBinder, true); + } + }, null), + null + ); + } else { + // Display a confirmation dialog first + UiHelper.displayQuitConfirmationDialog(this, new Runnable() { + @Override + public void run() { + ServerHelper.doStart(AppView.this, app.app, computer, managerBinder, withVDiaplay); + } + }, null); + } + return true; + } + + case START_OR_RESUME_ID: + case START_WITH_VDISPLAY: { + boolean withVDiaplay = itemId == START_WITH_VDISPLAY; + if (withVDiaplay && !(computer.vDisplaySupported && computer.vDisplayDriverReady)) { + UiHelper.displayVdisplayConfirmationDialog( + AppView.this, + computer, + () -> ServerHelper.doStart(AppView.this, app.app, computer, managerBinder, true), + null + ); + } else { + // Resume is the same as start for us + ServerHelper.doStart(AppView.this, app.app, computer, managerBinder, withVDiaplay); + } + return true; + } + + case QUIT_ID: { + // Display a confirmation dialog first + UiHelper.displayQuitConfirmationDialog(this, new Runnable() { + @Override + public void run() { + suspendGridUpdates = true; + ServerHelper.doQuit(AppView.this, computer, + app.app, managerBinder, new Runnable() { + @Override + public void run() { + // Trigger a poll immediately + suspendGridUpdates = false; + if (poller != null) { + poller.pollNow(); + } + } + }); + } + }, null); + return true; + } + + case VIEW_DETAILS_ID: { + Dialog.displayDialog(AppView.this, getResources().getString(R.string.title_details), app.app.toString(), false); + return true; + } + + case HIDE_APP_ID: { + if (item.isChecked()) { + // Transitioning hidden to shown + hiddenAppIds.remove(app.app.getAppId()); + } else { + // Transitioning shown to hidden + hiddenAppIds.add(app.app.getAppId()); + } + updateHiddenApps(false); + return true; + } + + case CREATE_SHORTCUT_ID: { + ImageView appImageView = info.targetView.findViewById(R.id.grid_image); + Bitmap appBits = ((BitmapDrawable) appImageView.getDrawable()).getBitmap(); + if (!shortcutHelper.createPinnedGameShortcut(computer, app.app, appBits)) { + Toast.makeText(AppView.this, getResources().getString(R.string.unable_to_pin_shortcut), Toast.LENGTH_LONG).show(); + } + return true; + } + + default: { + return super.onContextItemSelected(item); + } + } + } + + private void updateUiWithServerinfo(final ComputerDetails details) { + AppView.this.runOnUiThread(new Runnable() { + @Override + public void run() { + boolean updated = false; + + // Look through our current app list to tag the running app + for (int i = 0; i < appGridAdapter.getCount(); i++) { + AppObject existingApp = (AppObject) appGridAdapter.getItem(i); + + // There can only be one or zero apps running. + if (existingApp.isRunning && + existingApp.app.getAppId() == details.runningGameId) { + // This app was running and still is, so we're done now + return; + } + else if (existingApp.app.getAppId() == details.runningGameId) { + // This app wasn't running but now is + existingApp.isRunning = true; + updated = true; + } + else if (existingApp.isRunning) { + // This app was running but now isn't + existingApp.isRunning = false; + updated = true; + } + else { + // This app wasn't running and still isn't + } + } + + if (updated) { + appGridAdapter.notifyDataSetChanged(); + } + } + }); + } + + private void updateUiWithAppList(final List appList) { + AppView.this.runOnUiThread(new Runnable() { + @Override + public void run() { + boolean updated = false; + + // First handle app updates and additions + for (NvApp app : appList) { + boolean foundExistingApp = false; + + // Try to update an existing app in the list first + for (int i = 0; i < appGridAdapter.getCount(); i++) { + AppObject existingApp = (AppObject) appGridAdapter.getItem(i); + if (existingApp.app.getAppId() == app.getAppId()) { + // Found the app; update its properties + if (!existingApp.app.getAppName().equals(app.getAppName())) { + existingApp.app.setAppName(app.getAppName()); + updated = true; + } + + foundExistingApp = true; + break; + } + } + + if (!foundExistingApp) { + // This app must be new + appGridAdapter.addApp(new AppObject(app)); + + // We could have a leftover shortcut from last time this PC was paired + // or if this app was removed then added again. Enable those shortcuts + // again if present. + shortcutHelper.enableAppShortcut(computer, app); + + updated = true; + } + } + + // Next handle app removals + int i = 0; + while (i < appGridAdapter.getCount()) { + boolean foundExistingApp = false; + AppObject existingApp = (AppObject) appGridAdapter.getItem(i); + + // Check if this app is in the latest list + for (NvApp app : appList) { + if (existingApp.app.getAppId() == app.getAppId()) { + foundExistingApp = true; + break; + } + } + + // This app was removed in the latest app list + if (!foundExistingApp) { + shortcutHelper.disableAppShortcut(computer, existingApp.app, "App removed from PC"); + appGridAdapter.removeApp(existingApp); + updated = true; + + // Check this same index again because the item at i+1 is now at i after + // the removal + continue; + } + + // Move on to the next item + i++; + } + + if (updated) { + appGridAdapter.notifyDataSetChanged(); + } + } + }); + } + + @Override + public int getAdapterFragmentLayoutId() { + return PreferenceConfiguration.readPreferences(AppView.this).smallIconMode ? + R.layout.app_grid_view_small : R.layout.app_grid_view; + } + + @Override + public void receiveAbsListView(AbsListView listView) { + listView.setAdapter(appGridAdapter); + listView.setOnItemClickListener(new OnItemClickListener() { + @Override + public void onItemClick(AdapterView arg0, View arg1, int pos, + long id) { + AppObject app = (AppObject) appGridAdapter.getItem(pos); + + // Only open the context menu if something is running, otherwise start it + if (lastRunningAppId != 0) { + if (prefConfig.resumeWithoutConfirm && lastRunningAppId == app.app.getAppId()) { + ServerHelper.doStart(AppView.this, app.app, computer, managerBinder, prefConfig.useVirtualDisplay); + } else { + openContextMenu(arg1); + } + } else { + if (prefConfig.useVirtualDisplay && !(computer.vDisplaySupported && computer.vDisplayDriverReady)) { + UiHelper.displayVdisplayConfirmationDialog( + AppView.this, + computer, + () -> ServerHelper.doStart(AppView.this, app.app, computer, managerBinder, true), + null + ); + } else { + ServerHelper.doStart(AppView.this, app.app, computer, managerBinder, prefConfig.useVirtualDisplay); + } + } + } + }); + UiHelper.applyStatusBarPadding(listView); + registerForContextMenu(listView); + listView.requestFocus(); + } + + public static class AppObject { + public final NvApp app; + public boolean isRunning; + public boolean isHidden; + + public AppObject(NvApp app) { + if (app == null) { + throw new IllegalArgumentException("app must not be null"); + } + this.app = app; + } + + @Override + public String toString() { + return app.getAppName(); + } + } +} diff --git a/app/src/main/java/com/limelight/DebugInfoActivity.java b/app/src/main/java/com/limelight/DebugInfoActivity.java new file mode 100644 index 0000000000..1e0b8e97ae --- /dev/null +++ b/app/src/main/java/com/limelight/DebugInfoActivity.java @@ -0,0 +1,264 @@ +package com.limelight; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.hardware.Sensor; +import android.media.AudioAttributes; +import android.os.Build; +import android.os.Bundle; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.view.InputDevice; +import android.view.MotionEvent; +import android.view.View; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.SeekBar; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.Nullable; + +import com.limelight.utils.DeviceUtils; + +import java.util.ArrayList; +import java.util.List; + +public class DebugInfoActivity extends Activity implements View.OnClickListener { + + private TextView tx_gamepad_info; + private Vibrator vibrator; + private Button bt_vibrator; + private List ids = new ArrayList<>(); + private Vibrator vibratorOnline; + private Button bt_vibrator_value; + private int simulatedAmplitude = 220; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_axitest); + + tx_gamepad_info = findViewById(R.id.tx_game_pad_info); + TextView tx_content = findViewById(R.id.tx_content); + bt_vibrator = findViewById(R.id.bt_vibrator); + bt_vibrator_value = findViewById(R.id.bt_vibrator_value); + + vibrator = (Vibrator) this.getSystemService(VIBRATOR_SERVICE); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + + String kernelVersion = System.getProperty("os.version"); + StringBuffer sb = new StringBuffer(); + sb.append(getString(R.string.debug_info_android_version) + DeviceUtils.getSDKVersionName()); + sb.append("\t" + getString(R.string.debug_info_api_version) + Build.VERSION.SDK_INT); + sb.append("\n" + getString(R.string.debug_info_kernel_version) + kernelVersion); + sb.append("\n" + getString(R.string.debug_info_brand_model) + DeviceUtils.getManufacturer() + "\t-\t" + DeviceUtils.getModel()); + tx_content.setText(sb.toString()); + + boolean hasVibrator = ((Vibrator) getSystemService(Context.VIBRATOR_SERVICE)).hasVibrator(); + String content = hasVibrator ? getString(R.string.debug_info_has_vibration_motor) : getString(R.string.debug_info_no_vibration_motor); + bt_vibrator.setText(getString(R.string.debug_info_test_device_vibration, content)); + + showSimlateAmp(); + } + + private void showSimlateAmp() { + bt_vibrator_value.setText(getString(R.string.debug_info_vibration_amplitude, simulatedAmplitude)); + } + + private void cancleRumble() { + if (vibratorOnline != null) { + vibratorOnline.cancel(); + } + if (vibrator != null) { + vibrator.cancel(); + } + } + + @Override + public void onClick(View v) { + if (v.getId() == R.id.bt_vibrator_cancle) { + cancleRumble(); + return; + } + // Device Vibration + if (v.getId() == R.id.bt_vibrator) { + String[] titles = new String[]{getString(R.string.debug_info_simple_vibration), getString(R.string.debug_info_continuous_hd_vibration)}; + new AlertDialog.Builder(this).setItems(titles, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + switch (which) { + case 0: + vibrator.vibrate(1000); + break; + case 1: + rumble(vibrator); + break; + } + } + }).setTitle(getString(R.string.debug_info_please_choose)).create().show(); + return; + } + + // Gamepad Vibration + if (v.getId() == R.id.bt_vibrator_gamepad) { + if (ids.isEmpty()) { + Toast.makeText(DebugInfoActivity.this, getString(R.string.debug_info_no_gamepad_detected), Toast.LENGTH_LONG).show(); + return; + } + String[] strings = new String[ids.size()]; + for (int i = 0; i < ids.size(); i++) { + strings[i] = ids.get(i).getName(); + } + new AlertDialog.Builder(this).setItems(strings, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + if (ids.get(which).getVibrator().hasVibrator()) { + String[] titles = new String[]{getString(R.string.debug_info_simple_vibration), getString(R.string.debug_info_continuous_hd_vibration)}; + new AlertDialog.Builder(DebugInfoActivity.this).setItems(titles, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which2) { + dialog.dismiss(); + switch (which2) { + case 0: + ids.get(which).getVibrator().vibrate(1000); + break; + case 1: + cancleRumble(); + vibratorOnline = ids.get(which).getVibrator(); + rumble(vibratorOnline); + break; + } + } + }).setTitle(getString(R.string.debug_info_please_choose)).create().show(); + } else { + Toast.makeText(DebugInfoActivity.this, getString(R.string.debug_info_no_vibrator), Toast.LENGTH_SHORT).show(); + } + } + }).setTitle(getString(R.string.debug_info_please_choose)).create().show(); + return; + } + + // Refresh Gamepad Info + if (v.getId() == R.id.bt_update_gamepad) { + updateGamePad(); + return; + } + + if (v.getId() == R.id.bt_vibrator_value) { + SeekBar mSeekBar = getSeekBar(); + AlertDialog.Builder editDialog = new AlertDialog.Builder(this); + editDialog.setTitle(getString(R.string.debug_info_set_amplitude)); + editDialog.setView(mSeekBar); + editDialog.create().show(); + } + } + + private SeekBar getSeekBar() { + SeekBar mSeekBar = new SeekBar(this); + mSeekBar.setMax(255); + mSeekBar.setProgress(simulatedAmplitude); + mSeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + simulatedAmplitude = progress; + showSimlateAmp(); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) {} + + @Override + public void onStopTrackingTouch(SeekBar seekBar) {} + }); + return mSeekBar; + } + + private void rumble(Vibrator vibrator) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createWaveform(new long[]{1000}, new int[]{simulatedAmplitude}, 0)); + } else { + long pwmPeriod = 20; + long onTime = (long) ((simulatedAmplitude / 255.0) * pwmPeriod); + long offTime = pwmPeriod - onTime; + AudioAttributes audioAttributes = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_GAME) + .build(); + vibrator.vibrate(new long[]{0, onTime, offTime}, 0, audioAttributes); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (vibratorOnline != null) { + vibratorOnline.cancel(); + } + } + + private void updateGamePad() { + ids.clear(); + StringBuffer sb = new StringBuffer(); + sb.append("\n"); + int[] deviceIds = InputDevice.getDeviceIds(); + for (int deviceId : deviceIds) { + InputDevice dev = InputDevice.getDevice(deviceId); + int sources = dev.getSources(); + if (((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) + || ((sources & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK)) { + if (getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_X) != null && + getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_Y) != null) { + // This is a gamepad + ids.add(dev); + sb.append(getString(R.string.debug_info_name) + dev.getName()); + sb.append("\n"); + sb.append(getString(R.string.debug_info_sensors)); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + String sensor = ""; + if (dev.getSensorManager().getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null) { + sensor += getString(R.string.debug_info_accelerometer); + } + if (dev.getSensorManager().getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null) { + sensor += getString(R.string.debug_info_gyroscope); + } + if (sensor.length() == 0) { + sb.append(getString(R.string.debug_info_no_relevant_driver)); + } else { + sb.append(sensor); + } + sb.append("\n"); + } else { + sb.append(getString(R.string.debug_info_no_api_below_android12)); + sb.append("\n"); + } + sb.append(getString(R.string.debug_info_vid_pid) + dev.getVendorId() + "_" + dev.getProductId() + + "\t [" + String.format("%04x", dev.getVendorId()) + "_" + String.format("%04x", dev.getProductId()) + "]"); + sb.append("\n"); + sb.append(getString(R.string.debug_info_vibration) + (dev.getVibrator().hasVibrator() ? getString(R.string.debug_info_supported) : getString(R.string.debug_info_not_supported))); + sb.append("\n"); + sb.append(getString(R.string.debug_info_details) + "\n"); + sb.append(dev.toString()); + sb.append("\n"); + } + } + } + tx_gamepad_info.setText(getString(R.string.debug_info_number_of_gamepads) + ids.size() + "\n" + sb.toString()); + } + + 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; + } +} diff --git a/app/src/main/java/com/limelight/Game.java b/app/src/main/java/com/limelight/Game.java old mode 100644 new mode 100755 index 5d214508a3..e53d1c9ebb --- a/app/src/main/java/com/limelight/Game.java +++ b/app/src/main/java/com/limelight/Game.java @@ -4,6 +4,7 @@ import com.limelight.binding.PlatformBinding; import com.limelight.binding.audio.AndroidAudioRenderer; import com.limelight.binding.input.ControllerHandler; +import com.limelight.binding.input.GameInputDevice; import com.limelight.binding.input.KeyboardTranslator; import com.limelight.binding.input.capture.InputCaptureManager; import com.limelight.binding.input.capture.InputCaptureProvider; @@ -12,7 +13,10 @@ import com.limelight.binding.input.driver.UsbDriverService; import com.limelight.binding.input.evdev.EvdevListener; import com.limelight.binding.input.touch.TouchContext; +import com.limelight.binding.input.touch.TrackpadContext; import com.limelight.binding.input.virtual_controller.VirtualController; +import com.limelight.binding.input.virtual_controller.keyboard.KeyBoardController; +import com.limelight.binding.input.virtual_controller.keyboard.KeyBoardLayoutController; import com.limelight.binding.video.CrashListener; import com.limelight.binding.video.MediaCodecDecoderRenderer; import com.limelight.binding.video.MediaCodecHelper; @@ -23,7 +27,6 @@ import com.limelight.nvstream.http.ComputerDetails; import com.limelight.nvstream.http.NvApp; import com.limelight.nvstream.http.NvHTTP; -import com.limelight.nvstream.input.ControllerPacket; import com.limelight.nvstream.input.KeyboardPacket; import com.limelight.nvstream.input.MouseButtonPacket; import com.limelight.nvstream.jni.MoonBridge; @@ -32,16 +35,20 @@ import com.limelight.ui.GameGestures; import com.limelight.ui.StreamView; import com.limelight.utils.Dialog; +import com.limelight.utils.PanZoomHandler; import com.limelight.utils.ServerHelper; import com.limelight.utils.ShortcutHelper; import com.limelight.utils.SpinnerDialog; import com.limelight.utils.UiHelper; - import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.app.Activity; +import android.app.AlertDialog; import android.app.PictureInPictureParams; import android.app.Service; +import android.content.ClipData; +import android.content.ClipDescription; +import android.content.ClipboardManager; import android.content.ComponentName; import android.content.Context; import android.content.Intent; @@ -50,8 +57,10 @@ import android.content.pm.ActivityInfo; import android.content.pm.PackageManager; import android.content.res.Configuration; +import android.graphics.Outline; import android.graphics.Point; import android.graphics.Rect; +import android.hardware.display.DisplayManager; import android.hardware.input.InputManager; import android.media.AudioManager; import android.net.ConnectivityManager; @@ -60,8 +69,12 @@ import android.os.Bundle; import android.os.Handler; import android.os.IBinder; +import android.os.PersistableBundle; +import android.os.VibrationEffect; +import android.os.Vibrator; import android.util.Rational; import android.view.Display; +import android.view.Gravity; import android.view.InputDevice; import android.view.KeyCharacterMap; import android.view.KeyEvent; @@ -72,6 +85,9 @@ import android.view.View.OnGenericMotionListener; import android.view.View.OnSystemUiVisibilityChangeListener; import android.view.View.OnTouchListener; +import android.view.ViewGroup; +import android.view.ViewOutlineProvider; +import android.view.ViewParent; import android.view.Window; import android.view.WindowManager; import android.widget.FrameLayout; @@ -79,24 +95,35 @@ import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.preference.PreferenceManager; + import java.io.ByteArrayInputStream; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; -import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; import java.util.Locale; +import java.util.Map; public class Game extends Activity implements SurfaceHolder.Callback, OnGenericMotionListener, OnTouchListener, NvConnectionListener, EvdevListener, OnSystemUiVisibilityChangeListener, GameGestures, StreamView.InputCallbacks, - PerfOverlayListener, UsbDriverService.UsbDriverStateListener, View.OnKeyListener { + PerfOverlayListener, UsbDriverService.UsbDriverStateListener, View.OnKeyListener{ + public static Game instance; + private int lastButtonState = 0; // Only 2 touches are supported private final TouchContext[] touchContextMap = new TouchContext[2]; + private final TouchContext[] trackpadContextMap = new TouchContext[2]; + private PanZoomHandler panZoomHandler; private long threeFingerDownTime = 0; + private long fourFingerDownTime = 0; private static final int REFERENCE_HORIZ_RES = 1280; private static final int REFERENCE_VERT_RES = 720; @@ -108,19 +135,28 @@ public class Game extends Activity implements SurfaceHolder.Callback, private static final int STYLUS_UP_DEAD_ZONE_RADIUS = 50; private static final int THREE_FINGER_TAP_THRESHOLD = 300; + private static final int FOUR_FINGER_TAP_THRESHOLD = 300; private ControllerHandler controllerHandler; private KeyboardTranslator keyboardTranslator; private VirtualController virtualController; + private KeyBoardController keyBoardController; + + private KeyBoardLayoutController keyBoardLayoutController; + private PreferenceConfiguration prefConfig; private SharedPreferences tombstonePrefs; + private int displayWidth; + private int displayHeight; + private int currentOrientation; + private NvConnection conn; private SpinnerDialog spinner; private boolean displayedFailureDialog = false; private boolean connecting = false; - private boolean connected = false; + public boolean connected = false; private boolean autoEnterPip = false; private boolean surfaceCreated = false; private boolean attemptedConnection = false; @@ -134,18 +170,32 @@ public class Game extends Activity implements SurfaceHolder.Callback, private int modifierFlags = 0; private boolean grabbedInput = true; private boolean cursorVisible = false; + private boolean isPanZoomMode = false; + private boolean synthClickPending = false; + private boolean pointerSwiping = false; private boolean waitingForAllModifiersUp = false; private int specialKeyCode = KeyEvent.KEYCODE_UNKNOWN; private StreamView streamView; + private long synthTouchDownTime = 0; + + private boolean pendingDrag = false; + private boolean isDragging = false; + private float lastTouchDownX, lastTouchDownY; + private long lastAbsTouchUpTime = 0; private long lastAbsTouchDownTime = 0; private float lastAbsTouchUpX, lastAbsTouchUpY; private float lastAbsTouchDownX, lastAbsTouchDownY; + private boolean quitOnStop = false; private boolean isHidingOverlays; private TextView notificationOverlayView; private int requestedNotificationOverlayVisibility = View.GONE; - private TextView performanceOverlayView; + private View performanceOverlayView; + + private TextView performanceOverlayLite; + + private TextView performanceOverlayBig; private MediaCodecDecoderRenderer decoderRenderer; private boolean reportedCrash; @@ -180,11 +230,41 @@ public void onServiceDisconnected(ComponentName componentName) { public static final String EXTRA_PC_NAME = "PcName"; public static final String EXTRA_APP_HDR = "HDR"; public static final String EXTRA_SERVER_CERT = "ServerCert"; + public static final String EXTRA_VDISPLAY = "VirtualDisplay"; + public static final String EXTRA_SERVER_COMMANDS = "ServerCommands"; + + public static final String CLIPBOARD_IDENTIFIER = "ArtemisStreaming"; + + private String host; + private int port; + private int httpsPort; + private int appId; + private String uniqueId; + private X509Certificate serverCert; + private boolean vDisplay; + private ArrayList serverCommands; + + private ViewParent rootView; + private ClipboardManager clipboardManager; + private boolean clipboardSyncRunning = false; + + private NvHTTP httpConn; + public interface GameMenuCallbacks { + void showMenu(GameInputDevice devic); + void hideMenu(); + boolean isMenuOpen(); + } + + public GameMenuCallbacks gameMenuCallbacks; + + @SuppressLint("MissingInflatedId") @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + instance = this; + UiHelper.setLocale(this); // We don't want a title bar @@ -211,6 +291,8 @@ protected void onCreate(Bundle savedInstanceState) { // Inflate the content setContentView(R.layout.activity_game); + clipboardManager = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + // Start the spinner spinner = SpinnerDialog.displayDialog(this, getResources().getString(R.string.conn_establishing_title), getResources().getString(R.string.conn_establishing_msg), true); @@ -219,10 +301,25 @@ protected void onCreate(Bundle savedInstanceState) { prefConfig = PreferenceConfiguration.readPreferences(this); tombstonePrefs = Game.this.getSharedPreferences("DecoderTombstone", 0); + if (prefConfig.autoOrientation) { + currentOrientation = getResources().getConfiguration().orientation; + } else { + currentOrientation = Configuration.ORIENTATION_LANDSCAPE; + } + + boolean portraitMode = currentOrientation == Configuration.ORIENTATION_PORTRAIT; + boolean shouldInvertDecoderResolution = portraitMode && prefConfig.autoInvertVideoResolution; + + displayWidth = shouldInvertDecoderResolution ? prefConfig.height : prefConfig.width; + displayHeight = shouldInvertDecoderResolution ? prefConfig.width : prefConfig.height; + // Enter landscape unless we're on a square screen setPreferredOrientationForCurrentDisplay(); - if (prefConfig.stretchVideo || shouldIgnoreInsetsForResolution(prefConfig.width, prefConfig.height)) { + if ( + prefConfig.videoScaleMode == PreferenceConfiguration.ScaleMode.STRETCH || + shouldIgnoreInsetsForResolution(displayWidth, displayHeight) + ) { // Allow the activity to layout under notches if the fill-screen option // was turned on by the user or it's a full-screen native resolution if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { @@ -234,13 +331,20 @@ else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; } } - // Listen for non-touch events on the game surface streamView = findViewById(R.id.surfaceView); streamView.setOnGenericMotionListener(this); streamView.setOnKeyListener(this); streamView.setInputCallbacks(this); + //光标是否显示 + cursorVisible = prefConfig.enableMouseLocalCursor; + + //串流画面 顶部居中显示 + if(prefConfig.alignDisplayTopCenter){ + FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) streamView.getLayoutParams(); + params.gravity = Gravity.CENTER_HORIZONTAL|Gravity.TOP; + } // Listen for touch events on the background touch view to enable trackpad mode // to work on areas outside of the StreamView itself. We use a separate View // for this rather than just handling it at the Activity level, because that @@ -248,23 +352,32 @@ else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { View backgroundTouchView = findViewById(R.id.backgroundTouchView); backgroundTouchView.setOnTouchListener(this); + rootView = streamView.getParent(); + + panZoomHandler = new PanZoomHandler( + getApplicationContext(), + this, + streamView, + prefConfig + ); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { // Request unbuffered input event dispatching for all input classes we handle here. // Without this, input events are buffered to be delivered in lock-step with VBlank, // artificially increasing input latency while streaming. streamView.requestUnbufferedDispatch( InputDevice.SOURCE_CLASS_BUTTON | // Keyboards - InputDevice.SOURCE_CLASS_JOYSTICK | // Gamepads - InputDevice.SOURCE_CLASS_POINTER | // Touchscreens and mice (w/o pointer capture) - InputDevice.SOURCE_CLASS_POSITION | // Touchpads - InputDevice.SOURCE_CLASS_TRACKBALL // Mice (pointer capture) + InputDevice.SOURCE_CLASS_JOYSTICK | // Gamepads + InputDevice.SOURCE_CLASS_POINTER | // Touchscreens and mice (w/o pointer capture) + InputDevice.SOURCE_CLASS_POSITION | // Touchpads + InputDevice.SOURCE_CLASS_TRACKBALL // Mice (pointer capture) ); backgroundTouchView.requestUnbufferedDispatch( InputDevice.SOURCE_CLASS_BUTTON | // Keyboards - InputDevice.SOURCE_CLASS_JOYSTICK | // Gamepads - InputDevice.SOURCE_CLASS_POINTER | // Touchscreens and mice (w/o pointer capture) - InputDevice.SOURCE_CLASS_POSITION | // Touchpads - InputDevice.SOURCE_CLASS_TRACKBALL // Mice (pointer capture) + InputDevice.SOURCE_CLASS_JOYSTICK | // Gamepads + InputDevice.SOURCE_CLASS_POINTER | // Touchscreens and mice (w/o pointer capture) + InputDevice.SOURCE_CLASS_POSITION | // Touchpads + InputDevice.SOURCE_CLASS_TRACKBALL // Mice (pointer capture) ); } @@ -272,12 +385,18 @@ else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { performanceOverlayView = findViewById(R.id.performanceOverlay); + performanceOverlayLite = findViewById(R.id.performanceOverlayLite); + + performanceOverlayBig = findViewById(R.id.performanceOverlayBig); + inputCaptureProvider = InputCaptureManager.getInputCaptureProvider(this, this); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { streamView.setOnCapturedPointerListener(new View.OnCapturedPointerListener() { @Override public boolean onCapturedPointer(View view, MotionEvent motionEvent) { +// LimeLog.info("onCapturedPointer="+motionEvent.toString()); +// LimeLog.info("onCapturedPointer-Device="+motionEvent.getDevice().toString()); return handleMotionEvent(view, motionEvent); } }); @@ -285,7 +404,8 @@ public boolean onCapturedPointer(View view, MotionEvent motionEvent) { // Warn the user if they're on a metered connection ConnectivityManager connMgr = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); - if (connMgr.isActiveNetworkMetered()) { + boolean isMetered = connMgr.isActiveNetworkMetered(); + if (isMetered) { displayTransientMessage(getResources().getString(R.string.conn_metered)); } @@ -310,23 +430,26 @@ public boolean onCapturedPointer(View view, MotionEvent motionEvent) { appName = Game.this.getIntent().getStringExtra(EXTRA_APP_NAME); pcName = Game.this.getIntent().getStringExtra(EXTRA_PC_NAME); - String host = Game.this.getIntent().getStringExtra(EXTRA_HOST); - int port = Game.this.getIntent().getIntExtra(EXTRA_PORT, NvHTTP.DEFAULT_HTTP_PORT); - int httpsPort = Game.this.getIntent().getIntExtra(EXTRA_HTTPS_PORT, 0); // 0 is treated as unknown - int appId = Game.this.getIntent().getIntExtra(EXTRA_APP_ID, StreamConfiguration.INVALID_APP_ID); - String uniqueId = Game.this.getIntent().getStringExtra(EXTRA_UNIQUEID); + host = Game.this.getIntent().getStringExtra(EXTRA_HOST); + port = Game.this.getIntent().getIntExtra(EXTRA_PORT, NvHTTP.DEFAULT_HTTP_PORT); + httpsPort = Game.this.getIntent().getIntExtra(EXTRA_HTTPS_PORT, 0); // 0 is treated as unknown + appId = Game.this.getIntent().getIntExtra(EXTRA_APP_ID, StreamConfiguration.INVALID_APP_ID); + uniqueId = Game.this.getIntent().getStringExtra(EXTRA_UNIQUEID); + vDisplay = Game.this.getIntent().getBooleanExtra(EXTRA_VDISPLAY, false); + serverCommands = Game.this.getIntent().getStringArrayListExtra(EXTRA_SERVER_COMMANDS); boolean appSupportsHdr = Game.this.getIntent().getBooleanExtra(EXTRA_APP_HDR, false); byte[] derCertData = Game.this.getIntent().getByteArrayExtra(EXTRA_SERVER_CERT); app = new NvApp(appName != null ? appName : "app", appId, appSupportsHdr); - X509Certificate serverCert = null; try { if (derCertData != null) { serverCert = (X509Certificate) CertificateFactory.getInstance("X.509") .generateCertificate(new ByteArrayInputStream(derCertData)); + + httpConn = new NvHTTP(new ComputerDetails.AddressTuple(host, port), httpsPort, uniqueId, serverCert, PlatformBinding.getCryptoProvider(this)); } - } catch (CertificateException e) { + } catch (Exception e) { e.printStackTrace(); } @@ -371,6 +494,14 @@ public boolean onCapturedPointer(View view, MotionEvent motionEvent) { // Check if the user has enabled performance stats overlay if (prefConfig.enablePerfOverlay) { performanceOverlayView.setVisibility(View.VISIBLE); + if(prefConfig.enablePerfOverlayLite){ + performanceOverlayLite.setVisibility(View.VISIBLE); + if(prefConfig.enablePerfOverlayLiteDialog){ + performanceOverlayLite.setOnClickListener(v -> showGameMenu(null)); + } + }else{ + performanceOverlayBig.setVisibility(View.VISIBLE); + } } decoderRenderer = new MediaCodecDecoderRenderer( @@ -390,6 +521,7 @@ public void notifyCrash(Exception e) { tombstonePrefs.getInt("CrashCount", 0), connMgr.isActiveNetworkMetered(), willStreamHdr, + shouldInvertDecoderResolution, glPrefs.glRenderer, this); @@ -443,7 +575,7 @@ public void notifyCrash(Exception e) { // If the user requested frame pacing using a capped FPS, we will need to change our // desired FPS setting here in accordance with the active display refresh rate. int roundedRefreshRate = Math.round(displayRefreshRate); - int chosenFrameRate = prefConfig.fps; + float chosenFrameRate = prefConfig.fps; if (prefConfig.framePacing == PreferenceConfiguration.FRAME_PACING_CAP_FPS) { if (prefConfig.fps >= roundedRefreshRate) { if (prefConfig.fps > roundedRefreshRate + 3) { @@ -462,12 +594,22 @@ public void notifyCrash(Exception e) { } } + if (prefConfig.framePacingWarpFactor > 0) { + chosenFrameRate *= prefConfig.framePacingWarpFactor; + } + StreamConfiguration config = new StreamConfiguration.Builder() - .setResolution(prefConfig.width, prefConfig.height) + .setResolution( + displayWidth, + displayHeight + ) .setLaunchRefreshRate(prefConfig.fps) .setRefreshRate(chosenFrameRate) + .setVirtualDisplay(vDisplay) + .setResolutionScaleFactor(prefConfig.resolutionScaleFactor) .setApp(app) - .setBitrate(prefConfig.bitrate) + .setEnableUltraLowLatency(prefConfig.enableUltraLowLatency) + .setBitrate(isMetered ? prefConfig.meteredBitrate: prefConfig.bitrate) .setEnableSops(prefConfig.enableSops) .enableLocalAudioPlayback(prefConfig.playHostAudio) .setMaxPacketSize(1392) @@ -487,36 +629,34 @@ public void notifyCrash(Exception e) { httpsPort, uniqueId, config, PlatformBinding.getCryptoProvider(this), serverCert); controllerHandler = new ControllerHandler(this, conn, this, prefConfig); - keyboardTranslator = new KeyboardTranslator(); + keyboardTranslator = new KeyboardTranslator(prefConfig); InputManager inputManager = (InputManager) getSystemService(Context.INPUT_SERVICE); inputManager.registerInputDeviceListener(keyboardTranslator, null); // Initialize touch contexts - for (int i = 0; i < touchContextMap.length; i++) { - if (!prefConfig.touchscreenTrackpad) { - touchContextMap[i] = new AbsoluteTouchContext(conn, i, streamView); - } - else { - touchContextMap[i] = new RelativeTouchContext(conn, i, - REFERENCE_HORIZ_RES, REFERENCE_VERT_RES, - streamView, prefConfig); - } + String mouseMode = PreferenceManager.getDefaultSharedPreferences(this).getString("mouse_mode_list", "0"); + applyMouseMode(Integer.parseInt(mouseMode)); + + // Initialize trackpad contexts + for (int i = 0; i < trackpadContextMap.length; i++) { + trackpadContextMap[i] = new TrackpadContext(conn, i, prefConfig.trackpadSwapAxis, prefConfig.trackpadSensitivityX, prefConfig.trackpadSensitivityY); } if (prefConfig.onscreenController) { // create virtual onscreen controller - virtualController = new VirtualController(controllerHandler, - (FrameLayout)streamView.getParent(), - this); - virtualController.refreshLayout(); - virtualController.show(); + if (prefConfig.hideOSCWhenHasGamepad) { + if (!controllerHandler.hasController()) { + initVirtualController(); + } + } else { + initVirtualController(); + } } - if (prefConfig.usbDriver) { - // Start the USB driver - bindService(new Intent(this, UsbDriverService.class), - usbDriverServiceConnection, Service.BIND_AUTO_CREATE); + //特殊按键屏幕布局 + if(prefConfig.enableKeyboard){ + initKeyboardController(); } if (!decoderRenderer.isAvcSupported()) { @@ -533,6 +673,59 @@ public void notifyCrash(Exception e) { // The connection will be started when the surface gets created streamView.getHolder().addCallback(this); + + //外接显示器模式 + if(prefConfig.enableExDisplay){ + showSecondScreen(); + } + + gameMenuCallbacks = new GameMenu(this, conn); + } + + private void initKeyboardController(){ + keyBoardController = new KeyBoardController(conn,(FrameLayout)rootView, this); + keyBoardController.refreshLayout(); + keyBoardController.show(); + } + + + private void initVirtualController(){ + virtualController = new VirtualController(controllerHandler, (FrameLayout)rootView, this); + virtualController.refreshLayout(); + virtualController.show(); + } + + private void initkeyBoardLayoutController(){ + keyBoardLayoutController = new KeyBoardLayoutController((FrameLayout)rootView, this, prefConfig); + keyBoardLayoutController.refreshLayout(); + keyBoardLayoutController.show(); + } + + //显示隐藏虚拟特殊按键 + public void showHideKeyboardController(){ + if(keyBoardController==null){ + initKeyboardController(); + return; + } + keyBoardController.toggleVisibility(); + } + + public void showHidekeyBoardLayoutController(){ + if(keyBoardLayoutController==null){ + initkeyBoardLayoutController(); + return; + } + keyBoardLayoutController.toggleVisibility(); + } + + //显示隐藏虚拟手柄控制器 + public void showHideVirtualController(){ + if(virtualController==null){ + initVirtualController(); + prefConfig.onscreenController=true; + return; + } + prefConfig.onscreenController= virtualController.switchShowHide() != 0; } private void setPreferredOrientationForCurrentDisplay() { @@ -549,7 +742,7 @@ private void setPreferredOrientationForCurrentDisplay() { // For native resolution, we will lock the orientation to the one that matches the specified resolution if (PreferenceConfiguration.isNativeResolution(prefConfig.width, prefConfig.height)) { - if (prefConfig.width > prefConfig.height) { + if (displayWidth > displayHeight) { desiredOrientation = Configuration.ORIENTATION_LANDSCAPE; } else { @@ -569,8 +762,12 @@ else if (desiredOrientation == Configuration.ORIENTATION_PORTRAIT) { } } else { - // For regular displays, we always request landscape - setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE); + // Lock to current orientation + if (currentOrientation == Configuration.ORIENTATION_LANDSCAPE) { + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE); + } else { + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT); + } } } @@ -586,6 +783,14 @@ public void onConfigurationChanged(Configuration newConfig) { virtualController.refreshLayout(); } + if(keyBoardController != null){ + keyBoardController.refreshLayout(); + } + + if(keyBoardLayoutController != null){ + keyBoardLayoutController.refreshLayout(); + } + // Hide on-screen overlays in PiP mode if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (isInPictureInPictureMode()) { @@ -595,6 +800,16 @@ public void onConfigurationChanged(Configuration newConfig) { virtualController.hide(); } + if (keyBoardController != null && keyBoardController.shown) { + keyBoardController.hide(true); + } + + if (keyBoardLayoutController!=null && keyBoardLayoutController.shown) { + keyBoardLayoutController.hide(true); + } + + hideGameMenu(); + performanceOverlayView.setVisibility(View.GONE); notificationOverlayView.setVisibility(View.GONE); @@ -613,6 +828,14 @@ public void onConfigurationChanged(Configuration newConfig) { virtualController.show(); } + if (keyBoardController != null && keyBoardController.shown) { + keyBoardController.show(); + } + + if(keyBoardLayoutController!=null && keyBoardLayoutController.shown){ + keyBoardLayoutController.show(); + } + if (prefConfig.enablePerfOverlay) { performanceOverlayView.setVisibility(View.VISIBLE); } @@ -630,12 +853,29 @@ public void onConfigurationChanged(Configuration newConfig) { @TargetApi(Build.VERSION_CODES.O) private PictureInPictureParams getPictureInPictureParams(boolean autoEnter) { + View view; + Rect hint; + if (prefConfig.videoScaleMode == PreferenceConfiguration.ScaleMode.FIT && streamView.getScaleX() == 1) { + view = streamView; + } else { + view = (View)rootView; + } + + int[] viewLocation = new int[2]; + + view.getLocationOnScreen(viewLocation); + + int left = viewLocation[0]; + int top = viewLocation[1]; + int width = view.getWidth(); + int height = view.getHeight(); + Rational aspectRatio = new Rational(width, height); + hint = new Rect(left, top, left + width, top + height); + PictureInPictureParams.Builder builder = new PictureInPictureParams.Builder() - .setAspectRatio(new Rational(prefConfig.width, prefConfig.height)) - .setSourceRectHint(new Rect( - streamView.getLeft(), streamView.getTop(), - streamView.getRight(), streamView.getBottom())); + .setAspectRatio(aspectRatio) + .setSourceRectHint(hint); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { builder.setAutoEnterEnabled(autoEnter); @@ -657,7 +897,7 @@ else if (pcName != null) { return builder.build(); } - private void updatePipAutoEnter() { + public void updatePipAutoEnter() { if (!prefConfig.enablePip) { return; } @@ -690,13 +930,8 @@ public void setMetaKeyCaptureState(boolean enabled) { else { LimeLog.warning("SemWindowManager.getInstance() returned null"); } - } catch (ClassNotFoundException e) { - e.printStackTrace(); - } catch (NoSuchMethodException e) { - e.printStackTrace(); - } catch (InvocationTargetException e) { - e.printStackTrace(); - } catch (IllegalAccessException e) { + } catch (ClassNotFoundException | NoSuchMethodException | InvocationTargetException | + IllegalAccessException e) { e.printStackTrace(); } } @@ -880,7 +1115,8 @@ else if (!isRefreshRateGoodMatch(candidate.getRefreshRate())) { // If we only changed refresh rate and we're on an OS that supports Surface.setFrameRate() // use that instead of using preferredDisplayModeId to avoid the possibility of triggering // bugs that can cause the system to switch from 4K60 to 4K24 on Chromecast 4K. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || + if (prefConfig.enforceDisplayMode || + Build.VERSION.SDK_INT < Build.VERSION_CODES.S || display.getMode().getPhysicalWidth() != bestMode.getPhysicalWidth() || display.getMode().getPhysicalHeight() != bestMode.getPhysicalHeight()) { // Apply the display mode change @@ -936,20 +1172,20 @@ else if (!isRefreshRateGoodMatch(candidate.getRefreshRate())) { display.getSize(screenSize); double screenAspectRatio = ((double)screenSize.y) / screenSize.x; - double streamAspectRatio = ((double)prefConfig.height) / prefConfig.width; + double streamAspectRatio = ((double)displayHeight) / displayWidth; if (Math.abs(screenAspectRatio - streamAspectRatio) < 0.001) { LimeLog.info("Stream has compatible aspect ratio with output display"); aspectRatioMatch = true; } } - if (prefConfig.stretchVideo || aspectRatioMatch) { - // Set the surface to the size of the video - streamView.getHolder().setFixedSize(prefConfig.width, prefConfig.height); - } - else { + // Don't do setFixedSize since it might not update the view dimensions correctly when entering PiP mode + if (!(prefConfig.videoScaleMode == PreferenceConfiguration.ScaleMode.STRETCH || aspectRatioMatch)) { // Set the surface to scale based on the aspect ratio of the stream - streamView.setDesiredAspectRatio((double)prefConfig.width / (double)prefConfig.height); + streamView.setDesiredAspectRatio((double)displayWidth / (double)displayHeight); + streamView.setFillDisplay(prefConfig.videoScaleMode == PreferenceConfiguration.ScaleMode.FILL); + LimeLog.info("surfaceChanged-->"+(double)displayWidth / (double)displayHeight); + LimeLog.info("scaleMode-->"+prefConfig.videoScaleMode); } // Set the desired refresh rate that will get passed into setFrameRate() later @@ -971,28 +1207,28 @@ else if (!isRefreshRateGoodMatch(candidate.getRefreshRate())) { @SuppressLint("InlinedApi") private final Runnable hideSystemUi = new Runnable() { - @Override - public void run() { - // TODO: Do we want to use WindowInsetsController here on R+ instead of - // SYSTEM_UI_FLAG_IMMERSIVE_STICKY? They seem to do the same thing as of S... + @Override + public void run() { + // TODO: Do we want to use WindowInsetsController here on R+ instead of + // SYSTEM_UI_FLAG_IMMERSIVE_STICKY? They seem to do the same thing as of S... - // In multi-window mode on N+, we need to drop our layout flags or we'll - // be drawing underneath the system UI. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInMultiWindowMode()) { - Game.this.getWindow().getDecorView().setSystemUiVisibility( - View.SYSTEM_UI_FLAG_LAYOUT_STABLE); - } - else { - // Use immersive mode - Game.this.getWindow().getDecorView().setSystemUiVisibility( - View.SYSTEM_UI_FLAG_LAYOUT_STABLE | - View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | - View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | - View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | - View.SYSTEM_UI_FLAG_FULLSCREEN | - View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY); - } + // In multi-window mode on N+, we need to drop our layout flags or we'll + // be drawing underneath the system UI. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInMultiWindowMode()) { + Game.this.getWindow().getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE); + } + else { + // Use immersive mode + Game.this.getWindow().getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_FULLSCREEN | + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY); } + } }; private void hideSystemUi(int delay) { @@ -1029,6 +1265,12 @@ public void onMultiWindowModeChanged(boolean isInMultiWindowMode) { protected void onDestroy() { super.onDestroy(); + instance = null; + + if(presentation!=null){ + presentation.dismiss(); + } + if (controllerHandler != null) { controllerHandler.destroy(); } @@ -1078,6 +1320,13 @@ protected void onStop() { if (virtualController != null) { virtualController.hide(); } + if (keyBoardController != null) { + keyBoardController.hide(); + } + + if(keyBoardLayoutController!=null){ + keyBoardLayoutController.hide(); + } if (conn != null) { int videoFormat = decoderRenderer.getActiveVideoFormat(); @@ -1174,15 +1423,15 @@ private boolean handleSpecialKeys(int androidKeyCode, boolean down) { int nonModifierKeyCode = KeyEvent.KEYCODE_UNKNOWN; if (androidKeyCode == KeyEvent.KEYCODE_CTRL_LEFT || - androidKeyCode == KeyEvent.KEYCODE_CTRL_RIGHT) { + androidKeyCode == KeyEvent.KEYCODE_CTRL_RIGHT) { modifierMask = KeyboardPacket.MODIFIER_CTRL; } else if (androidKeyCode == KeyEvent.KEYCODE_SHIFT_LEFT || - androidKeyCode == KeyEvent.KEYCODE_SHIFT_RIGHT) { + androidKeyCode == KeyEvent.KEYCODE_SHIFT_RIGHT) { modifierMask = KeyboardPacket.MODIFIER_SHIFT; } else if (androidKeyCode == KeyEvent.KEYCODE_ALT_LEFT || - androidKeyCode == KeyEvent.KEYCODE_ALT_RIGHT) { + androidKeyCode == KeyEvent.KEYCODE_ALT_RIGHT) { modifierMask = KeyboardPacket.MODIFIER_ALT; } else if (androidKeyCode == KeyEvent.KEYCODE_META_LEFT || @@ -1312,6 +1561,11 @@ public boolean handleKeyDown(KeyEvent event) { return false; } + int deviceId = event.getDeviceId(); + if (prefConfig.ignoreSynthEvents && deviceId <= 0) { + return false; + } + // Handle a synthetic back button event that some Android OS versions // create as a result of a right-click. This event WILL repeat if // the right mouse button is held down, so we ignore those. @@ -1354,20 +1608,24 @@ public boolean handleKeyDown(KeyEvent event) { // We'll send it as a raw key event if we have a key mapping, otherwise we'll send it // as UTF-8 text (if it's a printable character). - short translated = keyboardTranslator.translate(event.getKeyCode(), event.getDeviceId()); + short translated = keyboardTranslator.translate(event.getKeyCode(), event.getScanCode(), deviceId); if (translated == 0) { - // Make sure it has a valid Unicode representation and it's not a dead character - // (which we don't support). If those are true, we can send it as UTF-8 text. - // - // NB: We need to be sure this happens before the getRepeatCount() check because - // UTF-8 events don't auto-repeat on the host side. - int unicodeChar = event.getUnicodeChar(); - if ((unicodeChar & KeyCharacterMap.COMBINING_ACCENT) == 0 && (unicodeChar & KeyCharacterMap.COMBINING_ACCENT_MASK) != 0) { - conn.sendUtf8Text(""+(char)unicodeChar); - return true; - } + if (prefConfig.backAsMeta && event.getKeyCode() == KeyEvent.KEYCODE_BACK) { + translated = 0x5b; // Meta key + } else { + // Make sure it has a valid Unicode representation and it's not a dead character + // (which we don't support). If those are true, we can send it as UTF-8 text. + // + // NB: We need to be sure this happens before the getRepeatCount() check because + // UTF-8 events don't auto-repeat on the host side. + int unicodeChar = event.getUnicodeChar(); + if ((unicodeChar & KeyCharacterMap.COMBINING_ACCENT) == 0 && (unicodeChar & KeyCharacterMap.COMBINING_ACCENT_MASK) != 0) { + conn.sendUtf8Text(""+(char)unicodeChar); + return true; + } - return false; + return false; + } } // Eat repeat down events @@ -1376,7 +1634,7 @@ public boolean handleKeyDown(KeyEvent event) { } conn.sendKeyboardInput(translated, KeyboardPacket.KEY_DOWN, getModifierState(event), - keyboardTranslator.hasNormalizedMapping(event.getKeyCode(), event.getDeviceId()) ? 0 : MoonBridge.SS_KBE_FLAG_NON_NORMALIZED); + keyboardTranslator.hasNormalizedMapping(event.getKeyCode(), deviceId) ? 0 : MoonBridge.SS_KBE_FLAG_NON_NORMALIZED); } return true; @@ -1394,6 +1652,11 @@ public boolean handleKeyUp(KeyEvent event) { return false; } + int deviceId = event.getDeviceId(); + if (prefConfig.ignoreSynthEvents && deviceId <= 0) { + return false; + } + // Handle a synthetic back button event that some Android OS versions // create as a result of a right-click. int eventSource = event.getSource(); @@ -1431,16 +1694,20 @@ public boolean handleKeyUp(KeyEvent event) { return false; } - short translated = keyboardTranslator.translate(event.getKeyCode(), event.getDeviceId()); + short translated = keyboardTranslator.translate(event.getKeyCode(), event.getScanCode(), deviceId); if (translated == 0) { - // If we sent this event as UTF-8 on key down, also report that it was handled - // when we get the key up event for it. - int unicodeChar = event.getUnicodeChar(); - return (unicodeChar & KeyCharacterMap.COMBINING_ACCENT) == 0 && (unicodeChar & KeyCharacterMap.COMBINING_ACCENT_MASK) != 0; + if (prefConfig.backAsMeta && event.getKeyCode() == KeyEvent.KEYCODE_BACK) { + translated = 0x5b; // Meta key + } else { + // If we sent this event as UTF-8 on key down, also report that it was handled + // when we get the key up event for it. + int unicodeChar = event.getUnicodeChar(); + return (unicodeChar & KeyCharacterMap.COMBINING_ACCENT) == 0 && (unicodeChar & KeyCharacterMap.COMBINING_ACCENT_MASK) != 0; + } } conn.sendKeyboardInput(translated, KeyboardPacket.KEY_UP, getModifierState(event), - keyboardTranslator.hasNormalizedMapping(event.getKeyCode(), event.getDeviceId()) ? 0 : MoonBridge.SS_KBE_FLAG_NON_NORMALIZED); + keyboardTranslator.hasNormalizedMapping(event.getKeyCode(), deviceId) ? 0 : MoonBridge.SS_KBE_FLAG_NON_NORMALIZED); } return true; @@ -1468,10 +1735,168 @@ private boolean handleKeyMultiple(KeyEvent event) { return true; } - private TouchContext getTouchContext(int actionIndex) + public boolean handleFocusChange(boolean hasFocus) { + if (connected && prefConfig.smartClipboardSync) { + if (hasFocus) { + return sendClipboard(false); + } else { + return getClipboard(0); + } + } + + return false; + } + + // Method to get clipboard content + private String getClipboardContent(boolean force) { + // Check if there is any clipboard data + if (clipboardManager.hasPrimaryClip()) { + ClipDescription clipDescription = clipboardManager.getPrimaryClipDescription(); + if (!force && clipDescription != null) { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { + PersistableBundle extras = clipDescription.getExtras(); + if (extras != null && extras.getBoolean(CLIPBOARD_IDENTIFIER)) { + // We're getting the clipboard data we just set/read a while ago + return null; + } + } else { + CharSequence clipLabel = clipDescription.getLabel(); + if (clipLabel != null && clipLabel.equals(CLIPBOARD_IDENTIFIER)) { + // We're getting the clipboard data we set a while ago + return null; + } + } + } + + ClipData clipData = clipboardManager.getPrimaryClip(); + + if (clipData != null && clipData.getItemCount() > 0) { + // Get the first item from the clipboard data + ClipData.Item item = clipData.getItemAt(0); + + // Mark the clip as visited + if (clipDescription != null) { + ClipData clonedClip = cloneClipData(clipDescription, item); + clipboardManager.setPrimaryClip(clonedClip); + } + + // Get the text data from the clipboard item + CharSequence clipText = item.getText(); + if (clipText == null) { + return null; + } + return clipText.toString(); + } + } + + return null; + } + + private static @NonNull ClipData cloneClipData(ClipDescription clipDescription, ClipData.Item item) { + ClipDescription clonedDescription = new ClipDescription(clipDescription); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + PersistableBundle extras = clipDescription.getExtras(); + if (extras == null) { + extras = new PersistableBundle(); + } + extras.putBoolean(CLIPBOARD_IDENTIFIER, true); + clonedDescription.setExtras(extras); + } + + return new ClipData(clonedDescription, item); + } + + public boolean sendClipboard(boolean force) { + if (httpConn == null) { + LimeLog.warning("httpConn not ready, cannot send clipboard!"); + return false; + } + + String clipboardText = getClipboardContent(force); + if (clipboardText != null) { + new Thread() { + public void run() { + try { + if (!httpConn.sendClipboard(clipboardText)) { + if (prefConfig.smartClipboardSyncToast) { + Game.this.runOnUiThread(() -> Toast.makeText(Game.this, getString(R.string.clipboard_sync_unsupported), Toast.LENGTH_SHORT).show()); + } + } else { + if (prefConfig.smartClipboardSyncToast) { + Game.this.runOnUiThread(() -> Toast.makeText(Game.this, getString(R.string.send_clipboard_success), Toast.LENGTH_SHORT).show()); + } + } + } catch (Exception e) { + e.printStackTrace(); + if (prefConfig.smartClipboardSyncToast) { + Game.this.runOnUiThread(() -> Toast.makeText(Game.this, getString(R.string.send_clipboard_failed) + e.getMessage(), Toast.LENGTH_SHORT).show()); + } + } + } + }.start(); + + return true; + } + + return false; + } + + public boolean getClipboard(int delay) { + if (httpConn == null) { + LimeLog.warning("httpConn not ready, cannot get clipboard!"); + return false; + } + + if (delay == 0 && gameMenuCallbacks != null && gameMenuCallbacks.isMenuOpen()) { + return false; + } + + new Thread() { + public void run() { + if (clipboardSyncRunning) { + return; + } + + clipboardSyncRunning = true; + try { + if (delay > 0) { + sleep(delay); + } + String clipboardContent = httpConn.getClipboard(); + ClipData clipData = ClipData.newPlainText(CLIPBOARD_IDENTIFIER, clipboardContent); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + ClipDescription clipDescription = clipData.getDescription(); + PersistableBundle newExtras = new PersistableBundle(); + newExtras.putBoolean(CLIPBOARD_IDENTIFIER, true); + if (prefConfig.hideClipboardContent) { + // We don't know if the message is sensitive or not, to be safe mark them all as sensitive. + newExtras.putBoolean("android.content.extra.IS_SENSITIVE", true); + } + clipDescription.setExtras(newExtras); + } + + clipboardManager.setPrimaryClip(clipData); + if (prefConfig.smartClipboardSyncToast) { + Game.this.runOnUiThread(() -> Toast.makeText(Game.this, getString(R.string.get_clipboard_success), Toast.LENGTH_SHORT).show()); + } + } catch (Exception e) { + e.printStackTrace(); + if (prefConfig.smartClipboardSyncToast) { + Game.this.runOnUiThread(() -> Toast.makeText(Game.this, getString(R.string.get_clipboard_failed) + e.getMessage(), Toast.LENGTH_SHORT).show()); + } + } + clipboardSyncRunning = false; + } + }.start(); + + return true; + } + + private TouchContext getTouchContext(int actionIndex, TouchContext[] inputContextMap) { - if (actionIndex < touchContextMap.length) { - return touchContextMap[actionIndex]; + if (actionIndex < inputContextMap.length) { + return inputContextMap[actionIndex]; } else { return null; @@ -1522,19 +1947,75 @@ private byte getLiTouchTypeFromEvent(MotionEvent event) { return MoonBridge.LI_TOUCH_EVENT_BUTTON_ONLY; default: - return -1; + return -1; + } + } + + //灵敏度保存到集合 适配多个手指 + private Map sensitivityMap=new HashMap<>(); + + //修改移动的触控灵敏度(通过修改移动的距离实现) 默认使用右半边屏幕的时候开启 + private float[] getStreamViewRelativeSensitivityXY(MotionEvent event,float normalizedX,float normalizedY,int pointerIndex){ + float[] normalized=new float[2]; + normalized[0]=normalizedX; + normalized[1]=normalizedY; + + //如果不是全局模式 并且 坐标 不在右边 则返回 + if(!prefConfig.touchSensitivityGlobal&&normalizedX=streamView.getWidth()){ + normalizedX=streamView.getWidth()/2.0f; + } + if(normalizedY>=streamView.getHeight()){ + normalizedY=streamView.getHeight()/2.0f; + } + } + bean.setLastAbsoluteX(event.getX(pointerIndex)); + bean.setLastAbsoluteY(event.getY(pointerIndex)); + bean.setLastRelativelyX(normalizedX); + bean.setLastRelativelyY(normalizedY); + sensitivityMap.put(String.valueOf(event.getPointerId(pointerIndex)),bean); + } + //抬起的时候,恢复初始化状态 + if (event.getActionMasked() == MotionEvent.ACTION_UP||event.getActionMasked() == MotionEvent.ACTION_POINTER_UP) { + sensitivityMap.remove(String.valueOf(event.getPointerId(pointerIndex))); } + normalized[0]=normalizedX; + normalized[1]=normalizedY; + return normalized; } + private float[] getStreamViewRelativeNormalizedXY(View view, MotionEvent event, int pointerIndex) { float normalizedX = event.getX(pointerIndex); float normalizedY = event.getY(pointerIndex); - + //开启自定义修改触控灵敏度 并且 数值不为100 + if(prefConfig.enableTouchSensitivity&&(prefConfig.touchSensitivityX !=100||prefConfig.touchSensitivityY!=100)){ + float[] normalized=getStreamViewRelativeSensitivityXY(event,normalizedX,normalizedY,pointerIndex); + normalizedX=normalized[0]; + normalizedY=normalized[1]; + } // For the containing background view, we must subtract the origin // of the StreamView to get video-relative coordinates. if (view != streamView) { - normalizedX -= streamView.getX(); - normalizedY -= streamView.getY(); + float[] normalized = getNormalizedCoordinates(streamView, normalizedX, normalizedY); + normalizedX = normalized[0]; + normalizedY = normalized[1]; } normalizedX = Math.max(normalizedX, 0.0f); @@ -1549,6 +2030,16 @@ private float[] getStreamViewRelativeNormalizedXY(View view, MotionEvent event, return new float[] { normalizedX, normalizedY }; } + private float[] getNormalizedCoordinates(View streamView, float rawX, float rawY) { + float scaleX = streamView.getScaleX(); + float scaleY = streamView.getScaleY(); + + float normalizedX = (rawX - streamView.getX()) / scaleX; + float normalizedY = (rawY - streamView.getY()) / scaleY; + + return new float[] { normalizedX, normalizedY }; + } + private static float normalizeValueInRange(float value, InputDevice.MotionRange range) { return (value - range.getMin()) / range.getRange(); } @@ -1764,11 +2255,17 @@ else if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) { // Returns true if the event was consumed // NB: View is only present if called from a view callback private boolean handleMotionEvent(View view, MotionEvent event) { + // Pass through mouse/touch/joystick input if we're not grabbing if (!grabbedInput) { return false; } + int deviceId = event.getDeviceId(); + if (prefConfig.ignoreSynthEvents && deviceId <= 0) { + return false; + } + int eventSource = event.getSource(); int deviceSources = event.getDevice() != null ? event.getDevice().getSources() : 0; if ((eventSource & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) { @@ -1780,37 +2277,38 @@ else if ((deviceSources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0 && controllerH return true; } else if ((eventSource & InputDevice.SOURCE_CLASS_POINTER) != 0 || - (eventSource & InputDevice.SOURCE_CLASS_POSITION) != 0 || - eventSource == InputDevice.SOURCE_MOUSE_RELATIVE) + (eventSource & InputDevice.SOURCE_CLASS_POSITION) != 0 || + eventSource == InputDevice.SOURCE_MOUSE_RELATIVE) { + boolean hasActionButton = Build.VERSION.SDK_INT < Build.VERSION_CODES.M || (event.getActionButton() != 0); // This case is for mice and non-finger touch devices - if (eventSource == InputDevice.SOURCE_MOUSE || - (eventSource & InputDevice.SOURCE_CLASS_POSITION) != 0 || // SOURCE_TOUCHPAD - eventSource == InputDevice.SOURCE_MOUSE_RELATIVE || - (event.getPointerCount() >= 1 && - (event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE || - event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS || - event.getToolType(0) == MotionEvent.TOOL_TYPE_ERASER)) || - eventSource == 12290) // 12290 = Samsung DeX mode desktop mouse - { + if ( + eventSource == InputDevice.SOURCE_MOUSE || + ((eventSource & InputDevice.SOURCE_CLASS_POSITION) != 0 && hasActionButton) || // SOURCE_TOUCHPAD + (eventSource == InputDevice.SOURCE_MOUSE_RELATIVE || + (event.getPointerCount() >= 1 && + (event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE || + event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS || + event.getToolType(0) == MotionEvent.TOOL_TYPE_ERASER)) || + eventSource == 12290) // 12290 = Samsung DeX mode desktop mouse + ) { int buttonState = event.getButtonState(); int changedButtons = buttonState ^ lastButtonState; - // The DeX touchpad on the Fold 4 sends proper right click events using BUTTON_SECONDARY, - // but doesn't send BUTTON_PRIMARY for a regular click. Instead it sends ACTION_DOWN/UP, - // so we need to fix that up to look like a sane input event to process it correctly. - if (eventSource == 12290) { - if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { - buttonState |= MotionEvent.BUTTON_PRIMARY; + // Two finger click + if ((eventSource & InputDevice.SOURCE_CLASS_POSITION) != 0 && + event.getPointerCount() == 2 && + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && event.getActionButton() == MotionEvent.BUTTON_PRIMARY)) { + if (event.getActionMasked() == MotionEvent.ACTION_BUTTON_PRESS) { + buttonState |= MotionEvent.BUTTON_SECONDARY; } - else if (event.getAction() == MotionEvent.ACTION_UP) { - buttonState &= ~MotionEvent.BUTTON_PRIMARY; - } - else { - // We may be faking the primary button down from a previous event, - // so be sure to add that bit back into the button state. - buttonState |= (lastButtonState & MotionEvent.BUTTON_PRIMARY); + else if (event.getActionMasked() == MotionEvent.ACTION_BUTTON_RELEASE) { + buttonState &= ~MotionEvent.BUTTON_SECONDARY; } + // We may not pressing the primary button down from a previous event, + // so be sure to clear that bit out the button state. + buttonState &= ~MotionEvent.BUTTON_PRIMARY; + buttonState |= (lastButtonState & MotionEvent.BUTTON_PRIMARY); changedButtons = buttonState ^ lastButtonState; } @@ -1862,7 +2360,7 @@ else if ((eventSource & InputDevice.SOURCE_CLASS_POSITION) != 0) { // Touchpads must be smaller than (65535, 65535) if (xMax <= Short.MAX_VALUE && yMax <= Short.MAX_VALUE) { conn.sendMousePosition((short)event.getX(), (short)event.getY(), - (short)xMax, (short)yMax); + (short)xMax, (short)yMax); } } } @@ -1872,8 +2370,100 @@ else if (view != null && trySendPenEvent(view, event)) { return true; } else if (view != null) { - // Otherwise send absolute position based on the view for SOURCE_CLASS_POINTER - updateMousePosition(view, event); + if (event.getToolType(0) == MotionEvent.TOOL_TYPE_FINGER) { + // Handle trackpad when pointer is not captured by synthesizing a trackpad movement + int eventAction = event.getActionMasked(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && event.getClassification() == MotionEvent.CLASSIFICATION_TWO_FINGER_SWIPE) { + if (!pointerSwiping) { + pointerSwiping = true; + handleTouchInput(event, trackpadContextMap, false, MotionEvent.ACTION_POINTER_DOWN, 1, 2); + } + return handleTouchInput(event, trackpadContextMap, false, MotionEvent.ACTION_MOVE, 1, 2); + } else if (pointerSwiping && eventAction == MotionEvent.ACTION_UP) { + pointerSwiping = false; + synthClickPending = false; + handleTouchInput(event, trackpadContextMap, false, MotionEvent.ACTION_POINTER_UP, 1, 2); + return true; + } + + // Press & Hold / Double-Tap & Hold for Selection or Drag & Drop + double positionDelta = Math.sqrt( + Math.pow(event.getX() - lastTouchDownX, 2) + + Math.pow(event.getY() - lastTouchDownY, 2) + ); + + if (synthClickPending && + event.getEventTime() - synthTouchDownTime >= prefConfig.trackpadDragDropThreshold) { + if (positionDelta > 50) { + pendingDrag = false; + } else if (pendingDrag) { + pendingDrag = false; + isDragging = true; + if (prefConfig.trackpadDragDropVibration) { + Vibrator vibrator = ((Vibrator) getSystemService(Context.VIBRATOR_SERVICE)); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createOneShot(20, 127)); + } else { + vibrator.vibrate(20); + } + } + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_LEFT); + return true; + } + } + + switch (eventAction) { + case MotionEvent.ACTION_HOVER_MOVE: + case MotionEvent.ACTION_MOVE: + updateMousePosition(view, event); + return true; + case MotionEvent.ACTION_HOVER_EXIT: + case MotionEvent.ACTION_DOWN: + pendingDrag = true; + synthClickPending = true; + lastTouchDownX = event.getX(); + lastTouchDownY = event.getY(); + synthTouchDownTime = event.getEventTime(); + return true; + case MotionEvent.ACTION_HOVER_ENTER: + case MotionEvent.ACTION_UP: + if (synthClickPending) { + long timeDiff = event.getEventTime() - synthTouchDownTime; + + if (eventSource == 12290) { + // Special handle for DeX + // DeX reports button secondary when tapping with two fingers + // So there's no need to distinguish left/right click by time difference + if (timeDiff < 120) { + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_LEFT); + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); + } + } else { + if (timeDiff < 20) { + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_LEFT); + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); + } else if (timeDiff < 120) { + conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_RIGHT); + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); + } + } + if (isDragging) { + isDragging = false; + conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); + } + pendingDrag = false; + synthClickPending = false; + } + return true; + case MotionEvent.ACTION_BUTTON_PRESS: + case MotionEvent.ACTION_BUTTON_RELEASE: + synthClickPending = false; + default: + break; + } + } else { + updateMousePosition(view, event); + } } if (event.getActionMasked() == MotionEvent.ACTION_SCROLL) { @@ -1972,145 +2562,205 @@ else if (event.getActionMasked() == MotionEvent.ACTION_UP || event.getActionMask lastButtonState = buttonState; } // This case is for fingers - else - { - if (virtualController != null && - (virtualController.getControllerMode() == VirtualController.ControllerMode.MoveButtons || - virtualController.getControllerMode() == VirtualController.ControllerMode.ResizeButtons)) { - // Ignore presses when the virtual controller is being configured - return true; - } + else { + if (eventSource == InputDevice.SOURCE_TOUCHPAD) { + return handleTouchInput(event, trackpadContextMap, false); + } else { + if (virtualController != null && + (virtualController.getControllerMode() == VirtualController.ControllerMode.MoveButtons || + virtualController.getControllerMode() == VirtualController.ControllerMode.ResizeButtons)) { + // Ignore presses when the virtual controller is being configured + return true; + } - // If this is the parent view, we'll offset our coordinates to appear as if they - // are relative to the StreamView like our StreamView touch events are. - float xOffset, yOffset; - if (view != streamView && !prefConfig.touchscreenTrackpad) { - xOffset = -streamView.getX(); - yOffset = -streamView.getY(); - } - else { - xOffset = 0.f; - yOffset = 0.f; - } + if (isPanZoomMode) { + // panning the streamView + panZoomHandler.handleTouchEvent(event); + return true; + } - int actionIndex = event.getActionIndex(); + // If touch is disabled or not initialized, we'll try panning the streamView + if (touchContextMap[0] == null) { + return true; + } - int eventX = (int)(event.getX(actionIndex) + xOffset); - int eventY = (int)(event.getY(actionIndex) + yOffset); + // TODO: Re-enable native touch when have a better solution for handling + // cancelled touches from Android gestures and 3 finger taps to activate + // the software keyboard. + if (prefConfig.enableMultiTouchScreen) { + if (!prefConfig.touchscreenTrackpad && trySendTouchEvent(view, event)) { + // If this host supports touch events and absolute touch is enabled, + // send it directly as a touch event. + return true; + } + } else { + // Special handling for 3 finger gesture + if (event.getActionMasked() == MotionEvent.ACTION_POINTER_DOWN) { + int fingerCount = event.getPointerCount(); + if (fingerCount == 3) { + // Three fingers down + threeFingerDownTime = event.getEventTime(); + } else if (fingerCount == 4) { + threeFingerDownTime = 0; + fourFingerDownTime = event.getEventTime(); + } - // Special handling for 3 finger gesture - if (event.getActionMasked() == MotionEvent.ACTION_POINTER_DOWN && - event.getPointerCount() == 3) { - // Three fingers down - threeFingerDownTime = event.getEventTime(); + if (fingerCount > 2) { + // Cancel previous touches to avoid + // erroneous events + for (TouchContext aTouchContext : touchContextMap) { + aTouchContext.cancelTouch(); + } - // Cancel the first and second touches to avoid - // erroneous events - for (TouchContext aTouchContext : touchContextMap) { - aTouchContext.cancelTouch(); + return true; + } + } } - return true; + return handleTouchInput(event, touchContextMap, true); } + } - // TODO: Re-enable native touch when have a better solution for handling - // cancelled touches from Android gestures and 3 finger taps to activate - // the software keyboard. - /*if (!prefConfig.touchscreenTrackpad && trySendTouchEvent(view, event)) { - // If this host supports touch events and absolute touch is enabled, - // send it directly as a touch event. - return true; - }*/ + // Handled a known source + return true; + } - TouchContext context = getTouchContext(actionIndex); - if (context == null) { - return false; - } + // Unknown class + return false; + } - switch (event.getActionMasked()) - { - case MotionEvent.ACTION_POINTER_DOWN: - case MotionEvent.ACTION_DOWN: - for (TouchContext touchContext : touchContextMap) { - touchContext.setPointerCount(event.getPointerCount()); - } - context.touchDownEvent(eventX, eventY, event.getEventTime(), true); - break; - case MotionEvent.ACTION_POINTER_UP: - case MotionEvent.ACTION_UP: - if (event.getPointerCount() == 1 && + private boolean handleTouchInput(MotionEvent event, TouchContext[] inputContextMap, boolean isTouchScreen) { + return handleTouchInput(event, inputContextMap, isTouchScreen, event.getActionMasked(), event.getActionIndex(), event.getPointerCount()); + } + + private boolean handleTouchInput(MotionEvent event, TouchContext[] inputContextMap, boolean isTouchScreen, int eventAction, int actionIndex, int pointerCount) { + int actualActionIndex = event.getActionIndex(); + int actualPointerCount = event.getPointerCount(); + + boolean shouldDuplicateMovement = actualPointerCount < pointerCount; + + int eventX = (int)event.getX(actualActionIndex); + int eventY = (int)event.getY(actualActionIndex); + + // Handle view scaling + if (isTouchScreen) { + float[] normalizedCoords = getNormalizedCoordinates(streamView, eventX, eventY); + eventX = (int)normalizedCoords[0]; + eventY = (int)normalizedCoords[1]; + } + + TouchContext context = getTouchContext(actionIndex, inputContextMap); + if (context == null) { + return false; + } + + switch (eventAction) + { + case MotionEvent.ACTION_POINTER_DOWN: + case MotionEvent.ACTION_DOWN: + for (TouchContext touchContext : inputContextMap) { + touchContext.setPointerCount(pointerCount); + } + context.touchDownEvent(eventX, eventY, event.getEventTime(), true); + break; + case MotionEvent.ACTION_POINTER_UP: + case MotionEvent.ACTION_UP: + //是触控板模式 三点呼出软键盘 + if(prefConfig.touchscreenTrackpad){ + if (pointerCount == 1 && (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || (event.getFlags() & MotionEvent.FLAG_CANCELED) == 0)) { // All fingers up - if (event.getEventTime() - threeFingerDownTime < THREE_FINGER_TAP_THRESHOLD) { + long currentEventTime = event.getEventTime(); + if (currentEventTime - threeFingerDownTime < THREE_FINGER_TAP_THRESHOLD) { // This is a 3 finger tap to bring up the keyboard toggleKeyboard(); return true; + } else if (currentEventTime - fourFingerDownTime < FOUR_FINGER_TAP_THRESHOLD) { + showHidekeyBoardLayoutController(); + return true; } } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && (event.getFlags() & MotionEvent.FLAG_CANCELED) != 0) { + context.cancelTouch(); + } + else { + context.touchUpEvent(eventX, eventY, event.getEventTime()); + } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && (event.getFlags() & MotionEvent.FLAG_CANCELED) != 0) { - context.cancelTouch(); - } - else { - context.touchUpEvent(eventX, eventY, event.getEventTime()); + for (TouchContext touchContext : inputContextMap) { + touchContext.setPointerCount(pointerCount - 1); + } + if (actionIndex == 0 && pointerCount > 1 && !context.isCancelled()) { + // The original secondary touch now becomes primary + int pointer1X = (int)event.getX(1); + int pointer1Y = (int)event.getY(1); + if (isTouchScreen) { + float[] normalizedCoords = getNormalizedCoordinates(streamView, pointer1X, pointer1Y); + pointer1X = (int)normalizedCoords[0]; + pointer1Y = (int)normalizedCoords[1]; } + context.touchDownEvent( + pointer1X, + pointer1Y, + event.getEventTime(), false); + } + break; + case MotionEvent.ACTION_MOVE: + // ACTION_MOVE is special because it always has actionIndex == 0 + // We'll call the move handlers for all indexes manually - for (TouchContext touchContext : touchContextMap) { - touchContext.setPointerCount(event.getPointerCount() - 1); - } - if (actionIndex == 0 && event.getPointerCount() > 1 && !context.isCancelled()) { - // The original secondary touch now becomes primary - context.touchDownEvent( - (int)(event.getX(1) + xOffset), - (int)(event.getY(1) + yOffset), - event.getEventTime(), false); - } - break; - case MotionEvent.ACTION_MOVE: - // ACTION_MOVE is special because it always has actionIndex == 0 - // We'll call the move handlers for all indexes manually - - // First process the historical events - for (int i = 0; i < event.getHistorySize(); i++) { - for (TouchContext aTouchContextMap : touchContextMap) { - if (aTouchContextMap.getActionIndex() < event.getPointerCount()) - { - aTouchContextMap.touchMoveEvent( - (int)(event.getHistoricalX(aTouchContextMap.getActionIndex(), i) + xOffset), - (int)(event.getHistoricalY(aTouchContextMap.getActionIndex(), i) + yOffset), - event.getHistoricalEventTime(i)); + // First process the historical events + for (int i = 0; i < event.getHistorySize(); i++) { + for (TouchContext aTouchContextMap : inputContextMap) { + if (aTouchContextMap.getActionIndex() < pointerCount) + { + int aActionIndex = shouldDuplicateMovement ? 0 : aTouchContextMap.getActionIndex(); + int historicalX = (int)event.getHistoricalX(aActionIndex, i); + int historicalY = (int)event.getHistoricalY(aActionIndex, i); + if (isTouchScreen) { + float[] normalizedCoords = getNormalizedCoordinates(streamView, historicalX, historicalY); + historicalX = (int)normalizedCoords[0]; + historicalY = (int)normalizedCoords[1]; } + aTouchContextMap.touchMoveEvent( + historicalX, + historicalY, + event.getHistoricalEventTime(i)); } } + } - // Now process the current values - for (TouchContext aTouchContextMap : touchContextMap) { - if (aTouchContextMap.getActionIndex() < event.getPointerCount()) - { - aTouchContextMap.touchMoveEvent( - (int)(event.getX(aTouchContextMap.getActionIndex()) + xOffset), - (int)(event.getY(aTouchContextMap.getActionIndex()) + yOffset), - event.getEventTime()); + // Now process the current values + for (TouchContext aTouchContextMap : inputContextMap) { + if (aTouchContextMap.getActionIndex() < pointerCount) + { + int aActionIndex = shouldDuplicateMovement ? 0 : aTouchContextMap.getActionIndex(); + int currentX = (int)event.getX(aActionIndex); + int currentY = (int)event.getY(aActionIndex); + if (isTouchScreen) { + float[] normalizedCoords = getNormalizedCoordinates(streamView, currentX, currentY); + currentX = (int)normalizedCoords[0]; + currentY = (int)normalizedCoords[1]; } + aTouchContextMap.touchMoveEvent( + currentX, + currentY, + event.getEventTime()); } - break; - case MotionEvent.ACTION_CANCEL: - for (TouchContext aTouchContext : touchContextMap) { - aTouchContext.cancelTouch(); - aTouchContext.setPointerCount(0); - } - break; - default: - return false; } - } - - // Handled a known source - return true; + break; + case MotionEvent.ACTION_CANCEL: + for (TouchContext aTouchContext : inputContextMap) { + aTouchContext.cancelTouch(); + aTouchContext.setPointerCount(0); + } + break; + default: + return false; } - // Unknown class - return false; + return true; } @Override @@ -2137,7 +2787,7 @@ private void updateMousePosition(View touchedView, MotionEvent event) { if (event.getPointerCount() == 1 && event.getActionIndex() == 0 && (event.getToolType(0) == MotionEvent.TOOL_TYPE_ERASER || - event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS)) + event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS)) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: @@ -2224,17 +2874,31 @@ private void stopConnection() { new Thread() { public void run() { conn.stop(); + if (httpConn != null && quitOnStop) { + try { + sleep(1000); + httpConn.quitApp(); + Game.this.runOnUiThread(() -> Toast.makeText(Game.this, Game.this.getResources().getString(R.string.applist_quit_success) + " " + appName, Toast.LENGTH_LONG).show()); + } catch (Exception e) { + Game.this.runOnUiThread(() -> Toast.makeText(Game.this, e.getMessage(), Toast.LENGTH_LONG).show()); + } + } } }.start(); } } @Override - public void stageFailed(final String stage, final int portFlags, final int errorCode) { + public boolean stageFailed(final String stage, final int portFlags, final int errorCode) { // Perform a connection test if the failure could be due to a blocked port // This does network I/O, so don't do it on the main thread. final int portTestResult = MoonBridge.testClientConnectivity(ServerHelper.CONNECTION_TEST_SERVER, 443, portFlags); + if (errorCode == 0 && portFlags != 0 && (portTestResult == MoonBridge.ML_TEST_RESULT_INCONCLUSIVE || portTestResult == 0)) { + spinner.setMessage(getResources().getString(R.string.unlocking_or_starting)); + return true; + } + runOnUiThread(new Runnable() { @Override public void run() { @@ -2254,6 +2918,20 @@ public void run() { String dialogText = getResources().getString(R.string.conn_error_msg) + " " + stage +" (error "+errorCode+")"; + switch (errorCode) { + case 403: { + dialogText += "\n\n" + getResources().getString(R.string.error_msg_permission_denied) + " (" + getResources().getString(R.string.permission_launch_app) + ")"; + break; + } + case -408: { + dialogText += "\n\n" + getResources().getString(R.string.error_msg_timeout); + break; + } + default: { + // do nothing + } + } + if (portFlags != 0) { dialogText += "\n\n" + getResources().getString(R.string.check_ports_msg) + "\n" + MoonBridge.stringifyPortFlags(portFlags, "\n"); @@ -2267,6 +2945,8 @@ public void run() { } } }); + + return false; } @Override @@ -2415,10 +3095,19 @@ public void run() { // Update GameManager state to indicate we're in game UiHelper.notifyStreamConnected(Game.this); + // Sync local clipboard to host + handleFocusChange(true); + hideSystemUi(1000); } }); + if (prefConfig.usbDriver) { + // Start the USB driver + bindService(new Intent(this, UsbDriverService.class), + usbDriverServiceConnection, Service.BIND_AUTO_CREATE); + } + // Report this shortcut being used (off the main thread to prevent ANRs) ComputerDetails computer = new ComputerDetails(); computer.name = pcName; @@ -2489,16 +3178,26 @@ public void surfaceChanged(SurfaceHolder holder, int format, int width, int heig throw new IllegalStateException("Surface changed before creation!"); } + LimeLog.info("surfaceChanged-->"+width+" x "+height + "----"+displayWidth+" x "+displayHeight); + if (!attemptedConnection) { attemptedConnection = true; // Update GameManager state to indicate we're "loading" while connecting UiHelper.notifyStreamConnecting(Game.this); - decoderRenderer.setRenderTarget(holder); + decoderRenderer.setRenderTarget(holder.getSurface()); conn.start(new AndroidAudioRenderer(Game.this, prefConfig.enableAudioFx), decoderRenderer, Game.this); } + + panZoomHandler.handleSurfaceChange(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (!isInPictureInPictureMode()) { + updatePipAutoEnter(); + } + } } @Override @@ -2564,24 +3263,24 @@ public void mouseButtonEvent(int buttonId, boolean down) { switch (buttonId) { - case EvdevListener.BUTTON_LEFT: - buttonIndex = MouseButtonPacket.BUTTON_LEFT; - break; - case EvdevListener.BUTTON_MIDDLE: - buttonIndex = MouseButtonPacket.BUTTON_MIDDLE; - break; - case EvdevListener.BUTTON_RIGHT: - buttonIndex = MouseButtonPacket.BUTTON_RIGHT; - break; - case EvdevListener.BUTTON_X1: - buttonIndex = MouseButtonPacket.BUTTON_X1; - break; - case EvdevListener.BUTTON_X2: - buttonIndex = MouseButtonPacket.BUTTON_X2; - break; - default: - LimeLog.warning("Unhandled button: "+buttonId); - return; + case EvdevListener.BUTTON_LEFT: + buttonIndex = MouseButtonPacket.BUTTON_LEFT; + break; + case EvdevListener.BUTTON_MIDDLE: + buttonIndex = MouseButtonPacket.BUTTON_MIDDLE; + break; + case EvdevListener.BUTTON_RIGHT: + buttonIndex = MouseButtonPacket.BUTTON_RIGHT; + break; + case EvdevListener.BUTTON_X1: + buttonIndex = MouseButtonPacket.BUTTON_X1; + break; + case EvdevListener.BUTTON_X2: + buttonIndex = MouseButtonPacket.BUTTON_X2; + break; + default: + LimeLog.warning("Unhandled button: "+buttonId); + return; } if (down) { @@ -2604,7 +3303,7 @@ public void mouseHScroll(byte amount) { @Override public void keyboardEvent(boolean buttonDown, short keyCode) { - short keyMap = keyboardTranslator.translate(keyCode, -1); + short keyMap = keyboardTranslator.translate(keyCode, 0, -1); if (keyMap != 0) { // handleSpecialKeys() takes the Android keycode if (handleSpecialKeys(keyCode, buttonDown)) { @@ -2641,7 +3340,11 @@ public void onPerfUpdate(final String text) { runOnUiThread(new Runnable() { @Override public void run() { - performanceOverlayView.setText(text); + if(prefConfig.enablePerfOverlayLite){ + performanceOverlayLite.setText(text); + }else{ + performanceOverlayBig.setText(text); + } } }); } @@ -2673,4 +3376,207 @@ public boolean onKey(View view, int keyCode, KeyEvent keyEvent) { return false; } } + + @Override + public void onBackPressed() { + if(prefConfig.enableBackMenu){ + showGameMenu(null); + return; + } + super.onBackPressed(); + } + + public void sendExecServerCmd(int cmdId) { + conn.sendExecServerCmd(cmdId); + } + + public ArrayList 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 avcWidthRange = avcDecoder.getCapabilitiesForType("video/avc").getVideoCapabilities().getSupportedWidths(); - - LimeLog.info("AVC supported width range: "+avcWidthRange.getLower()+" - "+avcWidthRange.getUpper()); - - // If 720p is not reported as supported, ignore all results from this API - if (avcWidthRange.contains(1280)) { - if (avcWidthRange.contains(3840) && maxSupportedResW < 3840) { - maxSupportedResW = 3840; - } - else if (avcWidthRange.contains(1920) && maxSupportedResW < 1920) { - maxSupportedResW = 1920; - } - else if (maxSupportedResW < 1280) { - maxSupportedResW = 1280; - } - } - } - - if (hevcDecoder != null) { - Range hevcWidthRange = hevcDecoder.getCapabilitiesForType("video/hevc").getVideoCapabilities().getSupportedWidths(); - - LimeLog.info("HEVC supported width range: "+hevcWidthRange.getLower()+" - "+hevcWidthRange.getUpper()); - - // If 720p is not reported as supported, ignore all results from this API - if (hevcWidthRange.contains(1280)) { - if (hevcWidthRange.contains(3840) && maxSupportedResW < 3840) { - maxSupportedResW = 3840; - } - else if (hevcWidthRange.contains(1920) && maxSupportedResW < 1920) { - maxSupportedResW = 1920; - } - else if (maxSupportedResW < 1280) { - maxSupportedResW = 1280; - } - } - } - - LimeLog.info("Maximum resolution slot: "+maxSupportedResW); - - if (maxSupportedResW != 0) { - if (maxSupportedResW < 3840) { - // 4K is unsupported - removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_4K, new Runnable() { - @Override - public void run() { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); - setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1440P); - resetBitrateToDefault(prefs, null, null); - } - }); - } - if (maxSupportedResW < 2560) { - // 1440p is unsupported - removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1440P, new Runnable() { - @Override - public void run() { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); - setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1080P); - resetBitrateToDefault(prefs, null, null); - } - }); - } - if (maxSupportedResW < 1920) { - // 1080p is unsupported - removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1080P, new Runnable() { - @Override - public void run() { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); - setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_720P); - resetBitrateToDefault(prefs, null, null); - } - }); - } - // Never remove 720p - } - } - else { - // We can get the true metrics via the getRealMetrics() function (unlike the lies - // that getWidth() and getHeight() tell to us). - DisplayMetrics metrics = new DisplayMetrics(); - display.getRealMetrics(metrics); - int width = Math.max(metrics.widthPixels, metrics.heightPixels); - int height = Math.min(metrics.widthPixels, metrics.heightPixels); - addNativeResolutionEntries(width, height, false); - } - - if (!PreferenceConfiguration.readPreferences(this.getActivity()).unlockFps) { - // We give some extra room in case the FPS is rounded down - if (maxSupportedFps < 118) { - removeValue(PreferenceConfiguration.FPS_PREF_STRING, "120", new Runnable() { - @Override - public void run() { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); - setValue(PreferenceConfiguration.FPS_PREF_STRING, "90"); - resetBitrateToDefault(prefs, null, null); - } - }); - } - if (maxSupportedFps < 88) { - // 1080p is unsupported - removeValue(PreferenceConfiguration.FPS_PREF_STRING, "90", new Runnable() { - @Override - public void run() { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); - setValue(PreferenceConfiguration.FPS_PREF_STRING, "60"); - resetBitrateToDefault(prefs, null, null); - } - }); - } - // Never remove 30 FPS or 60 FPS - } - addNativeFrameRateEntry(maxSupportedFps); - - // Android L introduces the drop duplicate behavior of releaseOutputBuffer() - // that the unlock FPS option relies on to not massively increase latency. - findPreference(PreferenceConfiguration.UNLOCK_FPS_STRING).setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - // HACK: We need to let the preference change succeed before reinitializing to ensure - // it's reflected in the new layout. - final Handler h = new Handler(); - h.postDelayed(new Runnable() { - @Override - public void run() { - // Ensure the activity is still open when this timeout expires - StreamSettings settingsActivity = (StreamSettings) SettingsFragment.this.getActivity(); - if (settingsActivity != null) { - settingsActivity.reloadSettings(); - } - } - }, 500); - - // Allow the original preference change to take place - return true; - } - }); - - // Remove HDR preference for devices below Nougat - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { - LimeLog.info("Excluding HDR toggle based on OS"); - PreferenceCategory category = - (PreferenceCategory) findPreference("category_advanced_settings"); - category.removePreference(findPreference("checkbox_enable_hdr")); - } - else { - Display.HdrCapabilities hdrCaps = display.getHdrCapabilities(); - - // We must now ensure our display is compatible with HDR10 - boolean foundHdr10 = false; - if (hdrCaps != null) { - // getHdrCapabilities() returns null on Lenovo Lenovo Mirage Solo (vega), Android 8.0 - for (int hdrType : hdrCaps.getSupportedHdrTypes()) { - if (hdrType == Display.HdrCapabilities.HDR_TYPE_HDR10) { - foundHdr10 = true; - break; - } - } - } - - if (!foundHdr10) { - LimeLog.info("Excluding HDR toggle based on display capabilities"); - PreferenceCategory category = - (PreferenceCategory) findPreference("category_advanced_settings"); - category.removePreference(findPreference("checkbox_enable_hdr")); - } - else if (PreferenceConfiguration.isShieldAtvFirmwareWithBrokenHdr()) { - LimeLog.info("Disabling HDR toggle on old broken SHIELD TV firmware"); - PreferenceCategory category = - (PreferenceCategory) findPreference("category_advanced_settings"); - CheckBoxPreference hdrPref = (CheckBoxPreference) category.findPreference("checkbox_enable_hdr"); - hdrPref.setEnabled(false); - hdrPref.setChecked(false); - hdrPref.setSummary("Update the firmware on your NVIDIA SHIELD Android TV to enable HDR"); - } - } - - // Add a listener to the FPS and resolution preference - // so the bitrate can be auto-adjusted - findPreference(PreferenceConfiguration.RESOLUTION_PREF_STRING).setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); - String valueStr = (String) newValue; - - // Detect if this value is the native resolution option - CharSequence[] values = ((ListPreference)preference).getEntryValues(); - boolean isNativeRes = true; - for (int i = 0; i < values.length; i++) { - // Look for a match prior to the start of the native resolution entries - if (valueStr.equals(values[i].toString()) && i < nativeResolutionStartIndex) { - isNativeRes = false; - break; - } - } - - // If this is native resolution, show the warning dialog - if (isNativeRes) { - Dialog.displayDialog(getActivity(), - getResources().getString(R.string.title_native_res_dialog), - getResources().getString(R.string.text_native_res_dialog), - false); - } - - // Write the new bitrate value - resetBitrateToDefault(prefs, valueStr, null); - - // Allow the original preference change to take place - return true; - } - }); - findPreference(PreferenceConfiguration.FPS_PREF_STRING).setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); - String valueStr = (String) newValue; - - // If this is native frame rate, show the warning dialog - CharSequence[] values = ((ListPreference)preference).getEntryValues(); - if (nativeFramerateShown && values[values.length - 1].toString().equals(newValue.toString())) { - Dialog.displayDialog(getActivity(), - getResources().getString(R.string.title_native_fps_dialog), - getResources().getString(R.string.text_native_res_dialog), - false); - } - - // Write the new bitrate value - resetBitrateToDefault(prefs, null, valueStr); - - // Allow the original preference change to take place - return true; - } - }); - } - } -} +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.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.app.Activity; +import android.os.Handler; +import android.os.Vibrator; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.FileProvider; +import androidx.fragment.app.DialogFragment; +import androidx.preference.CheckBoxPreference; +import androidx.preference.EditTextPreference; +import androidx.preference.ListPreference; +import androidx.preference.Preference; +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceManager; +import androidx.preference.PreferenceScreen; + +import com.bytehamster.lib.preferencesearch.SearchConfiguration; +import com.bytehamster.lib.preferencesearch.SearchPreferenceResult; +import com.bytehamster.lib.preferencesearch.SearchPreference; +import com.bytehamster.lib.preferencesearch.SearchPreferenceResultListener; +import android.text.InputFilter; +import android.text.InputType; +import android.text.TextUtils; +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 android.widget.EditText; +import android.widget.Toast; + +import com.google.gson.Gson; +import com.limelight.DebugInfoActivity; +import com.limelight.BuildConfig; +import com.limelight.GameMenu; +import com.limelight.LimeLog; +import com.limelight.PcView; +import com.limelight.R; +import com.limelight.binding.input.virtual_controller.keyboard.KeyBoardControllerConfigurationLoader; +import com.limelight.binding.video.MediaCodecHelper; +import com.limelight.utils.Dialog; +import com.limelight.utils.FileUriUtils; +import com.limelight.utils.UiHelper; +import org.json.JSONObject; +import java.io.File; +import java.util.Arrays; +import java.util.Iterator; +import java.util.Map; + +public class StreamSettings extends AppCompatActivity implements SearchPreferenceResultListener { + private PreferenceConfiguration previousPrefs; + private int previousDisplayPixelCount; + + private SettingsFragment prefsFragment; + + // 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(); + } + prefsFragment = new SettingsFragment(PreferenceConfiguration.readPreferences(this)); + getSupportFragmentManager().beginTransaction().replace( + R.id.stream_settings, prefsFragment + ).commitAllowingStateLoss(); + } + + @Override + public void onSearchResultClicked(SearchPreferenceResult result) { + result.closeSearchPage(this); + result.highlight(prefsFragment); + } + + @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. + PreferenceConfiguration newPrefs = PreferenceConfiguration.readPreferences(this); + if (!newPrefs.language.equals(previousPrefs.language)) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + // 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); + } else { + if (newPrefs.language == PreferenceConfiguration.DEFAULT_LANGUAGE) { + Toast.makeText(this, "Language has been reset to default, please restart the app!", Toast.LENGTH_LONG).show(); + System.exit(0); + } + } + } + } + + public static class SettingsFragment extends PreferenceFragmentCompat { + private int nativeResolutionStartIndex = Integer.MAX_VALUE; + private boolean nativeFramerateShown = false; + + private PreferenceConfiguration prevPrefConfig; + + public SettingsFragment(PreferenceConfiguration prefCfg) { + prevPrefConfig = prefCfg; + } + + 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, boolean is_custom) { + ListPreference pref = (ListPreference) findPreference(PreferenceConfiguration.RESOLUTION_PREF_STRING); + + String newName; + + if (insetsRemoved) { + newName = getResources().getString(R.string.resolution_prefix_native_fullscreen); + } + else { + newName = is_custom ? getResources().getString(R.string.resolution_prefix_custom) : 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, boolean is_custom) { + if (PreferenceConfiguration.isSquarishScreen(nativeWidth, nativeHeight)) { + addNativeResolutionEntry(nativeHeight, nativeWidth, insetsRemoved, true, is_custom); + } + addNativeResolutionEntry(nativeWidth, nativeHeight, insetsRemoved, false, is_custom); + } + + private void addNativeFrameRateEntry(float framerate, boolean is_custom) { + if (!is_custom) { + framerate = Math.round(framerate); + if (framerate == 0) { + return; + } + } + + ListPreference pref = (ListPreference) findPreference(PreferenceConfiguration.FPS_PREF_STRING); + String fpsValue = is_custom ? Float.toString(framerate) : Integer.toString(Math.round(framerate)); + String fpsName = (is_custom ? getResources().getString(R.string.resolution_prefix_custom) : 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 onCreatePreferences(Bundle bundle, String s) {} + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + initializePreferences(); + + SearchPreference searchPreference = findPreference("searchPreference"); + assert searchPreference != null; + SearchConfiguration config = searchPreference.getSearchConfiguration(); + config.setActivity((AppCompatActivity) requireActivity()); + config.index(R.xml.preferences); + } + + public void initializePreferences() { + addPreferencesFromResource(R.xml.preferences); + PreferenceScreen screen = getPreferenceScreen(); + + AppCompatActivity activity = (AppCompatActivity) requireActivity(); + PackageManager pm = activity.getPackageManager(); + + // hide on-screen controls category on non touch screen devices + if (!pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)) { + PreferenceCategory category = findPreference("category_onscreen_controls"); + if (category != null) { + screen.removePreference(category); + } + category = findPreference("category_special_key_layout"); + if (category != null) { + 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 (!pm.hasSystemFeature(PackageManager.FEATURE_SENSOR_ACCELEROMETER) && + !activity.getPackageManager().hasSystemFeature(PackageManager.FEATURE_SENSOR_GYROSCOPE)) { + PreferenceCategory category = + (PreferenceCategory) findPreference("category_gamepad_settings"); + category.removePreference(findPreference("checkbox_force_device_motion")); + category.removePreference(findPreference("checkbox_gamepad_motion_fallback")); + } + + // Hide USB driver options on devices without USB host support + if (!pm.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 || + !pm.hasSystemFeature("android.software.picture_in_picture") || + pm.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 = findPreference("category_onscreen_controls"); + if (category != null) { + category.removePreference(findPreference("checkbox_vibrate_osc")); + } + category = findPreference("category_special_key_layout"); + if (category != null) { + category.removePreference(findPreference("checkbox_vibrate_keyboard")); + } + category = findPreference("category_gamepad_settings"); + if (category != null) { + category.removePreference(findPreference("checkbox_enable_device_rumble")); + } + } + 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")); + } + + // Check custom resolution + String customResStr = prevPrefConfig.customResolution; + if(customResStr != null && !customResStr.isEmpty()){ + String[] resolutionSegments = customResStr.split("x"); + if(resolutionSegments.length == 2){ + try { + addNativeResolutionEntries(Integer.parseInt(resolutionSegments[0]), Integer.parseInt(resolutionSegments[1]), false, true); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + + // Check custom refresh rate + String customRefreshRateStr = prevPrefConfig.customRefreshRate; + if (customRefreshRateStr != null && !customRefreshRateStr.isEmpty()) { + try { + float customRefreshRateValue = Float.parseFloat(customRefreshRateStr); + if (customRefreshRateValue > 0) { + addNativeFrameRateEntry(customRefreshRateValue, true); + } + } catch (NumberFormatException e) { + PreferenceManager.getDefaultSharedPreferences(this.getActivity()).edit().remove(PreferenceConfiguration.CUSTOM_REFRESH_RATE_PREF_STRING).apply(); + } + } + + Display display = activity.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, 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 (!activity.getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEVISION) || + (width > 3840 || height > 2160)) { + addNativeResolutionEntries(width, height, hasInsets, false); + } + + 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(requireContext()).glRenderer); + + MediaCodecInfo avcDecoder = MediaCodecHelper.findProbableSafeDecoder("video/avc", -1); + MediaCodecInfo hevcDecoder = MediaCodecHelper.findProbableSafeDecoder("video/hevc", -1); + + if (avcDecoder != null) { + Range avcWidthRange = avcDecoder.getCapabilitiesForType("video/avc").getVideoCapabilities().getSupportedWidths(); + + LimeLog.info("AVC supported width range: "+avcWidthRange.getLower()+" - "+avcWidthRange.getUpper()); + + // If 720p is not reported as supported, ignore all results from this API + if (avcWidthRange.contains(1280)) { + if (avcWidthRange.contains(3840) && maxSupportedResW < 3840) { + maxSupportedResW = 3840; + } + else if (avcWidthRange.contains(1920) && maxSupportedResW < 1920) { + maxSupportedResW = 1920; + } + else if (maxSupportedResW < 1280) { + maxSupportedResW = 1280; + } + } + } + + if (hevcDecoder != null) { + Range hevcWidthRange = hevcDecoder.getCapabilitiesForType("video/hevc").getVideoCapabilities().getSupportedWidths(); + + LimeLog.info("HEVC supported width range: "+hevcWidthRange.getLower()+" - "+hevcWidthRange.getUpper()); + + // If 720p is not reported as supported, ignore all results from this API + if (hevcWidthRange.contains(1280)) { + if (hevcWidthRange.contains(3840) && maxSupportedResW < 3840) { + maxSupportedResW = 3840; + } + else if (hevcWidthRange.contains(1920) && maxSupportedResW < 1920) { + maxSupportedResW = 1920; + } + else if (maxSupportedResW < 1280) { + maxSupportedResW = 1280; + } + } + } + + LimeLog.info("Maximum resolution slot: "+maxSupportedResW); + + if (maxSupportedResW != 0) { + if (maxSupportedResW < 3840) { + // 4K is unsupported + removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_4K, new Runnable() { + @Override + public void run() { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1440P); + resetBitrateToDefault(prefs, null, null); + } + }); + } + if (maxSupportedResW < 2560) { + // 1440p is unsupported + removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1440P, new Runnable() { + @Override + public void run() { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1080P); + resetBitrateToDefault(prefs, null, null); + } + }); + } + if (maxSupportedResW < 1920) { + // 1080p is unsupported + removeValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_1080P, new Runnable() { + @Override + public void run() { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + setValue(PreferenceConfiguration.RESOLUTION_PREF_STRING, PreferenceConfiguration.RES_720P); + resetBitrateToDefault(prefs, null, null); + } + }); + } + // Never remove 720p + } + } + else { + // We can get the true metrics via the getRealMetrics() function (unlike the lies + // that getWidth() and getHeight() tell to us). + DisplayMetrics metrics = new DisplayMetrics(); + display.getRealMetrics(metrics); + int width = Math.max(metrics.widthPixels, metrics.heightPixels); + int height = Math.min(metrics.widthPixels, metrics.heightPixels); + addNativeResolutionEntries(width, height, false, false); + } + + if (!PreferenceConfiguration.readPreferences(this.getActivity()).unlockFps) { + // We give some extra room in case the FPS is rounded down + if (maxSupportedFps < 118) { + removeValue(PreferenceConfiguration.FPS_PREF_STRING, "120", new Runnable() { + @Override + public void run() { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + setValue(PreferenceConfiguration.FPS_PREF_STRING, "90"); + resetBitrateToDefault(prefs, null, null); + } + }); + } + if (maxSupportedFps < 88) { + // 1080p is unsupported + removeValue(PreferenceConfiguration.FPS_PREF_STRING, "90", new Runnable() { + @Override + public void run() { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + setValue(PreferenceConfiguration.FPS_PREF_STRING, "60"); + resetBitrateToDefault(prefs, null, null); + } + }); + } + // Never remove 30 FPS or 60 FPS + } + addNativeFrameRateEntry(maxSupportedFps, false); + + // Android L introduces the drop duplicate behavior of releaseOutputBuffer() + // that the unlock FPS option relies on to not massively increase latency. + findPreference(PreferenceConfiguration.UNLOCK_FPS_STRING).setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + // HACK: We need to let the preference change succeed before reinitializing to ensure + // it's reflected in the new layout. + final Handler h = new Handler(); + h.postDelayed(new Runnable() { + @Override + public void run() { + // Ensure the activity is still open when this timeout expires + StreamSettings settingsActivity = (StreamSettings) SettingsFragment.this.getActivity(); + if (settingsActivity != null) { + settingsActivity.reloadSettings(); + } + } + }, 500); + + // Allow the original preference change to take place + return true; + } + }); + + // Remove HDR preference for devices below Nougat + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + LimeLog.info("Excluding HDR toggle based on OS"); + PreferenceCategory category = + (PreferenceCategory) findPreference("category_video_settings"); + category.removePreference(findPreference("checkbox_enable_hdr")); + } + else { + Display.HdrCapabilities hdrCaps = display.getHdrCapabilities(); + + // We must now ensure our display is compatible with HDR10 + boolean foundHdr10 = false; + if (hdrCaps != null) { + // getHdrCapabilities() returns null on Lenovo Lenovo Mirage Solo (vega), Android 8.0 + for (int hdrType : hdrCaps.getSupportedHdrTypes()) { + if (hdrType == Display.HdrCapabilities.HDR_TYPE_HDR10) { + foundHdr10 = true; + break; + } + } + } + + if (!foundHdr10) { + LimeLog.info("Excluding HDR toggle based on display capabilities"); + PreferenceCategory category = + (PreferenceCategory) findPreference("category_video_settings"); + category.removePreference(findPreference("checkbox_enable_hdr")); + } + else if (PreferenceConfiguration.isShieldAtvFirmwareWithBrokenHdr()) { + LimeLog.info("Disabling HDR toggle on old broken SHIELD TV firmware"); + PreferenceCategory category = + (PreferenceCategory) findPreference("category_video_settings"); + CheckBoxPreference hdrPref = (CheckBoxPreference) category.findPreference("checkbox_enable_hdr"); + hdrPref.setEnabled(false); + hdrPref.setChecked(false); + hdrPref.setSummary("Update the firmware on your NVIDIA SHIELD Android TV to enable HDR"); + } + } + + // Add a listener to the FPS and resolution preference + // so the bitrate can be auto-adjusted + findPreference(PreferenceConfiguration.RESOLUTION_PREF_STRING).setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + String valueStr = (String) newValue; + + // Detect if this value is the native resolution option + CharSequence[] values = ((ListPreference)preference).getEntryValues(); + boolean isNativeRes = true; + for (int i = 0; i < values.length; i++) { + // Look for a match prior to the start of the native resolution entries + if (valueStr.equals(values[i].toString()) && i < nativeResolutionStartIndex) { + isNativeRes = false; + break; + } + } + + // If this is native resolution, show the warning dialog + if (isNativeRes) { + Dialog.displayDialog(getActivity(), + getResources().getString(R.string.title_native_res_dialog), + getResources().getString(R.string.text_native_res_dialog), + false); + } + + // Write the new bitrate value + resetBitrateToDefault(prefs, valueStr, null); + + // Allow the original preference change to take place + return true; + } + }); + findPreference(PreferenceConfiguration.FPS_PREF_STRING).setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + String valueStr = (String) newValue; + + // If this is native frame rate, show the warning dialog + CharSequence[] values = ((ListPreference)preference).getEntryValues(); + if (nativeFramerateShown && values[values.length - 1].toString().equals(newValue.toString())) { + Dialog.displayDialog(getActivity(), + getResources().getString(R.string.title_native_fps_dialog), + getResources().getString(R.string.text_native_res_dialog), + false); + } + + // Write the new bitrate value + resetBitrateToDefault(prefs, null, valueStr); + + // Allow the original preference change to take place + return true; + } + }); + + Preference _pref; + _pref = findPreference("import_keyboard_file"); + if (_pref != null) { + _pref.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("application/json"); + startActivityForResult(intent, READ_REQUEST_CODE); + return false; + } + }); + } + + _pref = findPreference("import_special_button_file"); + if (_pref != null) { + _pref.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("application/json"); + startActivityForResult(intent, READ_REQUEST_SPECIAL_CODE); + return false; + } + }); + } + + _pref = findPreference("export_keyboard_file"); + if (_pref != null) { + _pref.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + File file = new File(requireActivity().getExternalCacheDir(),"export_settings"); + if(!file.exists()){ + file.mkdir(); + } + File file1= getJsonContent(requireActivity(),file); + if(file1==null){ + Toast.makeText(requireActivity(),getString(R.string.pref_error_occurred),Toast.LENGTH_SHORT).show(); + return false; + } + Uri uri; + Intent intent = new Intent(Intent.ACTION_SEND); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + String authority= BuildConfig.APPLICATION_ID+".fileprovider"; + uri = FileProvider.getUriForFile(requireActivity(),authority,file1); + intent.putExtra(Intent.EXTRA_STREAM, uri); + intent.setType("application/json"); + startActivity(Intent.createChooser(intent,getString(R.string.pref_save_keyboard_profile))); + return false; + } + }); + } + + _pref = findPreference("pref_debug_info"); + if (_pref != null) { + _pref.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(@NonNull Preference preference) { + Intent intent=new Intent(requireActivity(), DebugInfoActivity.class); + requireActivity().startActivity(intent); + return false; + } + }); + } + + EditTextPreference bitrateEditPre = findPreference(PreferenceConfiguration.CUSTOM_BITRATE_PREF_STRING); + if (bitrateEditPre != null) { + bitrateEditPre.setOnBindEditTextListener((EditText editText) -> { + editText.setInputType(InputType.TYPE_NUMBER_FLAG_DECIMAL); + editText.setFilters(new InputFilter[]{new InputFilter.LengthFilter(5)/*这里限制输入的长度为5个字母*/}); + }); + + bitrateEditPre.setOnPreferenceChangeListener((preference, newValue) -> { + String value = (String) newValue; + if (TextUtils.isEmpty(value)) { + Toast.makeText(getActivity(), getString(R.string.pref_enter_value_0_9999), Toast.LENGTH_SHORT).show(); + return false; + } + float bitrateValue = Float.parseFloat(value) * 1000; + LimeLog.info("axi-bitrateValue:" + bitrateValue); + int bitrate = (int) bitrateValue; + LimeLog.info("axi-bitrate:" + bitrate); + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + prefs.edit().putInt(PreferenceConfiguration.BITRATE_PREF_STRING, bitrate).apply(); + Toast.makeText(getActivity(), getString(R.string.pref_set_success), Toast.LENGTH_SHORT).show(); + return true; + }); + } + + EditTextPreference resolutionEditPref = findPreference(PreferenceConfiguration.CUSTOM_RESOLUTION_PREF_STRING); + if (resolutionEditPref != null) { + resolutionEditPref.setOnBindEditTextListener((EditText editText) -> { + editText.setInputType(InputType.TYPE_CLASS_TEXT); + editText.setFilters(new InputFilter[]{new InputFilter.LengthFilter(11)}); + }); + + resolutionEditPref.setOnPreferenceChangeListener((preference, newValue) -> { + String value = (String) newValue; + if (TextUtils.isEmpty(value)) { + Toast.makeText(getActivity(), getString(R.string.pref_enter_value_0_9999), Toast.LENGTH_SHORT).show(); + return false; + } + + // Verify format: [width]x[height] + String[] resolutionSegments = value.split("x"); + if (resolutionSegments.length != 2) { + Toast.makeText(getActivity(), getString(R.string.pref_error_occurred), Toast.LENGTH_SHORT).show(); + return false; + } + + try { + int width = Integer.parseInt(resolutionSegments[0]); + int height = Integer.parseInt(resolutionSegments[1]); + + if (width <= 0 || height <= 0) { + Toast.makeText(getActivity(), getString(R.string.pref_error_occurred), Toast.LENGTH_SHORT).show(); + return false; + } + + // Save the value and reload settings + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + prefs.edit().putString(PreferenceConfiguration.CUSTOM_RESOLUTION_PREF_STRING, value).apply(); + + // HACK: We need to let the preference change succeed before reinitializing to ensure + // it's reflected in the new layout. + final Handler h = new Handler(); + h.postDelayed(new Runnable() { + @Override + public void run() { + // Ensure the activity is still open when this timeout expires + StreamSettings settingsActivity = (StreamSettings) SettingsFragment.this.getActivity(); + if (settingsActivity != null) { + settingsActivity.reloadSettings(); + } + } + }, 500); + + return true; + } catch (NumberFormatException e) { + Toast.makeText(getActivity(), getString(R.string.pref_error_occurred), Toast.LENGTH_SHORT).show(); + return false; + } + }); + } + + EditTextPreference customRefreshRatePref = findPreference(PreferenceConfiguration.CUSTOM_REFRESH_RATE_PREF_STRING); + if (customRefreshRatePref != null) { + customRefreshRatePref.setOnBindEditTextListener((EditText editText) -> { + editText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL); + editText.setFilters(new InputFilter[]{new InputFilter.LengthFilter(7)}); + }); + + customRefreshRatePref.setOnPreferenceChangeListener((preference, newValue) -> { + String value = (String) newValue; + if (TextUtils.isEmpty(value)) { + Toast.makeText(getActivity(), getString(R.string.pref_enter_value_0_9999), Toast.LENGTH_SHORT).show(); + return false; + } + + try { + float refreshRate = Float.parseFloat(value); + if (refreshRate <= 0) { + Toast.makeText(getActivity(), getString(R.string.pref_enter_value_0_9999), Toast.LENGTH_SHORT).show(); + return false; + } + + // Format to max 3 decimal places + String formattedValue = String.format("%.3f", refreshRate); + // Remove trailing zeros + formattedValue = formattedValue.replaceAll("0+$", "").replaceAll("\\.$", ""); + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(SettingsFragment.this.getActivity()); + prefs.edit().putString(PreferenceConfiguration.CUSTOM_REFRESH_RATE_PREF_STRING, formattedValue).apply(); + + // HACK: We need to let the preference change succeed before reinitializing to ensure + // it's reflected in the new layout. + final Handler h = new Handler(); + h.postDelayed(new Runnable() { + @Override + public void run() { + // Ensure the activity is still open when this timeout expires + StreamSettings settingsActivity = (StreamSettings) SettingsFragment.this.getActivity(); + if (settingsActivity != null) { + settingsActivity.reloadSettings(); + } + } + }, 500); + + return true; + } catch (NumberFormatException e) { + Toast.makeText(getActivity(), getString(R.string.pref_error_occurred), Toast.LENGTH_SHORT).show(); + return false; + } + }); + } + } + + int READ_REQUEST_CODE = 1001; + int READ_REQUEST_SPECIAL_CODE = 1002; + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK && data.getData() != null) { + try { + Uri uri = data.getData(); + String json = FileUriUtils.openUriForRead(getActivity(), uri); + if (TextUtils.isEmpty(json)) { + Toast.makeText(getActivity(), getString(R.string.pref_empty_file), Toast.LENGTH_SHORT).show(); + return; + } + String name = PreferenceManager.getDefaultSharedPreferences(requireActivity()).getString(KeyBoardControllerConfigurationLoader.OSC_PREFERENCE, KeyBoardControllerConfigurationLoader.OSC_PREFERENCE_VALUE); + SharedPreferences.Editor prefEditor = requireActivity().getSharedPreferences(name, Activity.MODE_PRIVATE).edit(); + JSONObject object = new JSONObject(json); + Iterator it = object.keys(); + prefEditor.clear(); + while (it.hasNext()) { + String key = (String) it.next();// 获得key + String value = object.getString(key);// 获得value + prefEditor.putString(key, value); + } + prefEditor.apply(); + Toast.makeText(getActivity(), getString(R.string.pref_import_success), Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + e.printStackTrace(); + Toast.makeText(getActivity(), getString(R.string.pref_error_occurred) + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + return; + } + + if (requestCode == READ_REQUEST_SPECIAL_CODE && resultCode == Activity.RESULT_OK && data.getData() != null) { + try { + Uri uri = data.getData(); + String json = FileUriUtils.openUriForRead(getActivity(), uri); + if (TextUtils.isEmpty(json)) { + Toast.makeText(getActivity(), getString(R.string.pref_empty_file), Toast.LENGTH_SHORT).show(); + return; + } + SharedPreferences.Editor prefEditor = getActivity().getSharedPreferences(GameMenu.PREF_NAME, Activity.MODE_PRIVATE).edit(); + prefEditor.putString(GameMenu.KEY_NAME, json); + prefEditor.apply(); + Toast.makeText(getActivity(), getString(R.string.pref_import_success), Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + e.printStackTrace(); + Toast.makeText(getActivity(), getString(R.string.pref_error_occurred) + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + } + + @Override + public void onDisplayPreferenceDialog(@NonNull Preference preference) { + if (preference instanceof ConfirmDeleteOscPreference) { + DialogFragment dialogFragment = ConfirmDeleteOscPreference.DialogFragmentCompat.newInstance(preference.getKey()); + dialogFragment.setTargetFragment(this, 0); + dialogFragment.show(getFragmentManager(), null); + } else if (preference instanceof ConfirmDeleteKeyboardPreference) { + DialogFragment dialogFragment = ConfirmDeleteKeyboardPreference.DialogFragmentCompat.newInstance(preference.getKey()); + dialogFragment.setTargetFragment(this, 0); + dialogFragment.show(getFragmentManager(), null); + } else super.onDisplayPreferenceDialog(preference); + } + + private File getJsonContent(Context context,File file){ + String name = PreferenceManager.getDefaultSharedPreferences(context).getString(KeyBoardControllerConfigurationLoader.OSC_PREFERENCE, KeyBoardControllerConfigurationLoader.OSC_PREFERENCE_VALUE); + SharedPreferences pref = context.getSharedPreferences(name, Activity.MODE_PRIVATE); + Map map = pref.getAll(); + File file1= new File(file,name+".json"); + String jsonStr=new Gson().toJson(map); + if(!FileUriUtils.writerFileString(file1,jsonStr)){ + return null; + } + return file1; + } + + //获取所有设置项配置文件 + private File getAllJsonData(Context context,File file){ + SharedPreferences pref=PreferenceManager.getDefaultSharedPreferences(context); + Map map = pref.getAll(); + //获取适配电脑的数据库信息 +// List map= new ComputerDatabaseManager(context).getAllComputers(); + File file1= new File(file,"allJSON.json"); + String jsonStr=new Gson().toJson(map); + if(!FileUriUtils.writerFileString(file1,jsonStr)){ + return null; + } + return file1; + } + } +} + diff --git a/app/src/main/java/com/limelight/preferences/WebLauncherPreference.java b/app/src/main/java/com/limelight/preferences/WebLauncherPreference.java old mode 100644 new mode 100755 index d01a7b1e85..5101b06f75 --- a/app/src/main/java/com/limelight/preferences/WebLauncherPreference.java +++ b/app/src/main/java/com/limelight/preferences/WebLauncherPreference.java @@ -1,44 +1,45 @@ -package com.limelight.preferences; - -import android.annotation.TargetApi; -import android.content.Context; -import android.os.Build; -import android.preference.Preference; -import android.util.AttributeSet; - -import com.limelight.utils.HelpLauncher; - -public class WebLauncherPreference extends Preference { - private String url; - - public WebLauncherPreference(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - initialize(attrs); - } - - public WebLauncherPreference(Context context, AttributeSet attrs) { - super(context, attrs); - initialize(attrs); - } - - public WebLauncherPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - initialize(attrs); - } - - private void initialize(AttributeSet attrs) { - if (attrs == null) { - throw new IllegalStateException("WebLauncherPreference must have attributes!"); - } - - url = attrs.getAttributeValue(null, "url"); - if (url == null) { - throw new IllegalStateException("WebLauncherPreference must have 'url' attribute!"); - } - } - - @Override - public void onClick() { - HelpLauncher.launchUrl(getContext(), url); - } -} +package com.limelight.preferences; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.Preference; + +import com.limelight.utils.HelpLauncher; + +public class WebLauncherPreference extends Preference { + private String url; + + public WebLauncherPreference(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + initialize(attrs); + } + + public WebLauncherPreference(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + initialize(attrs); + } + + public WebLauncherPreference(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + initialize(attrs); + } + + private void initialize(AttributeSet attrs) { + if (attrs == null) { + throw new IllegalStateException("WebLauncherPreference must have attributes!"); + } + + url = attrs.getAttributeValue(null, "url"); + if (url == null) { + throw new IllegalStateException("WebLauncherPreference must have 'url' attribute!"); + } + } + + @Override + public void onClick() { + HelpLauncher.launchUrl(getContext(), url); + } +} diff --git a/app/src/main/java/com/limelight/ui/AdapterFragment.java b/app/src/main/java/com/limelight/ui/AdapterFragment.java old mode 100644 new mode 100755 index 8e1c7b9182..c808c7f472 --- a/app/src/main/java/com/limelight/ui/AdapterFragment.java +++ b/app/src/main/java/com/limelight/ui/AdapterFragment.java @@ -1,35 +1,35 @@ -package com.limelight.ui; - - -import android.app.Activity; -import android.app.Fragment; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.AbsListView; - -import com.limelight.R; - -public class AdapterFragment extends Fragment { - private AdapterFragmentCallbacks callbacks; - - @Override - public void onAttach(Activity activity) { - super.onAttach(activity); - - callbacks = (AdapterFragmentCallbacks) activity; - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, - Bundle savedInstanceState) { - return inflater.inflate(callbacks.getAdapterFragmentLayoutId(), container, false); - } - - @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - callbacks.receiveAbsListView(getView().findViewById(R.id.fragmentView)); - } -} +package com.limelight.ui; + + +import android.app.Activity; +import android.app.Fragment; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AbsListView; + +import com.limelight.R; + +public class AdapterFragment extends Fragment { + private AdapterFragmentCallbacks callbacks; + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + + callbacks = (AdapterFragmentCallbacks) activity; + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + return inflater.inflate(callbacks.getAdapterFragmentLayoutId(), container, false); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + callbacks.receiveAbsListView(getView().findViewById(R.id.fragmentView)); + } +} diff --git a/app/src/main/java/com/limelight/ui/AdapterFragmentCallbacks.java b/app/src/main/java/com/limelight/ui/AdapterFragmentCallbacks.java old mode 100644 new mode 100755 index 8a6db396df..630d1303c1 --- a/app/src/main/java/com/limelight/ui/AdapterFragmentCallbacks.java +++ b/app/src/main/java/com/limelight/ui/AdapterFragmentCallbacks.java @@ -1,8 +1,8 @@ -package com.limelight.ui; - -import android.widget.AbsListView; - -public interface AdapterFragmentCallbacks { - int getAdapterFragmentLayoutId(); - void receiveAbsListView(AbsListView gridView); -} +package com.limelight.ui; + +import android.widget.AbsListView; + +public interface AdapterFragmentCallbacks { + int getAdapterFragmentLayoutId(); + void receiveAbsListView(AbsListView gridView); +} diff --git a/app/src/main/java/com/limelight/ui/ApertureViewGroup.java b/app/src/main/java/com/limelight/ui/ApertureViewGroup.java new file mode 100755 index 0000000000..82bc29c98e --- /dev/null +++ b/app/src/main/java/com/limelight/ui/ApertureViewGroup.java @@ -0,0 +1,150 @@ +package com.limelight.ui; + +import android.animation.ObjectAnimator; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.LinearGradient; +import android.graphics.Outline; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.Shader; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewOutlineProvider; +import android.widget.LinearLayout; + +import com.limelight.R; +import com.limelight.utils.UiHelper; + + +public class ApertureViewGroup extends LinearLayout { + + private int mColor1 = 0; + private int mColor2 = 0; + private float mBorderWidth = 0f; + private float mBorderAngle = 0f; + private int mDuration = 0; + private int mMiddleColor = 0; + + private Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + private RectF rectF; + private LinearGradient color1; + private LinearGradient color2; + private ObjectAnimator animator; + private float currentSpeed = 0f; + + public ApertureViewGroup(Context context) { + this(context, null); + } + + public ApertureViewGroup(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ApertureViewGroup(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context, attrs); + } + + private void init(Context context, AttributeSet attrs) { + setOutlineProvider(new ViewOutlineProvider() { + @Override + public void getOutline(View view, Outline outline) { + outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), mBorderAngle); + } + }); + setClipToOutline(true); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ApertureViewGroup); + try { + mColor1 = a.getColor(R.styleable.ApertureViewGroup_aperture_color1, Color.YELLOW); + mColor2 = a.getColor(R.styleable.ApertureViewGroup_aperture_color2, -1); + mBorderWidth = a.getDimension(R.styleable.ApertureViewGroup_aperture_border_width, UiHelper.dpToPx(context, 20)); +// setPadding((int) mBorderWidth / 2,(int) mBorderWidth / 2,(int) mBorderWidth / 2,(int) mBorderWidth / 2); + mBorderAngle = a.getDimension(R.styleable.ApertureViewGroup_aperture_border_angle, UiHelper.dpToPx(context, 20)); + mDuration = a.getInt(R.styleable.ApertureViewGroup_aperture_duration, 3000); + mMiddleColor = a.getColor(R.styleable.ApertureViewGroup_aperture_middle_color, Color.BLACK); + } finally { + a.recycle(); + } + + animator = ObjectAnimator.ofFloat(this, "currentSpeed", 0f, 360f); + animator.setRepeatCount(ObjectAnimator.INFINITE); + animator.setRepeatMode(ObjectAnimator.RESTART); + animator.setInterpolator(null); + animator.setDuration(mDuration); + } + + public float getCurrentSpeed() { + return currentSpeed; + } + + public void setCurrentSpeed(float currentSpeed) { + this.currentSpeed = currentSpeed; + invalidate(); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + if (rectF == null) { + float left = 0f + mBorderWidth / 2f; + float top = 0f + mBorderWidth / 2f; + float right = left + w - mBorderWidth; + float bottom = top + h - mBorderWidth; + rectF = new RectF(left, top, right, bottom); + } + + if (color1 == null) { + color1 = new LinearGradient( + w * 1f, h / 2f, + w * 1f, h * 1f, + new int[]{Color.TRANSPARENT, mColor1}, + new float[]{0f, 0.9f}, + Shader.TileMode.CLAMP + ); + } + + if (color2 == null && mColor2 != -1) { + color2 = new LinearGradient( + w / 2f, h / 2f, + w / 2f, 0f, + new int[]{Color.TRANSPARENT, mColor2}, + new float[]{0f, 0.9f}, + Shader.TileMode.CLAMP + ); + } + + animator.start(); + } + + @Override + protected void dispatchDraw(Canvas canvas) { + float left1 = getWidth() / 2f; + float top1 = getHeight() / 2f; + float right1 = left1 + getWidth(); + float bottom1 = top1 + getHeight(); + + canvas.save(); + canvas.rotate(currentSpeed, getWidth() / 2f, getHeight() / 2f); + + paint.setShader(color1); + canvas.drawRect(left1, top1, right1, bottom1, paint); + paint.setShader(null); + + if (mColor2 != -1) { + paint.setShader(color2); + canvas.drawRect(left1, top1, -right1, -bottom1, paint); + paint.setShader(null); + } + + paint.setColor(mMiddleColor); + canvas.drawRoundRect(rectF, mBorderAngle, mBorderAngle, paint); + + canvas.restore(); + + super.dispatchDraw(canvas); + } +} diff --git a/app/src/main/java/com/limelight/ui/GameGestures.java b/app/src/main/java/com/limelight/ui/GameGestures.java old mode 100644 new mode 100755 index 74dd7b056f..50cdf5139e --- a/app/src/main/java/com/limelight/ui/GameGestures.java +++ b/app/src/main/java/com/limelight/ui/GameGestures.java @@ -1,5 +1,9 @@ -package com.limelight.ui; - -public interface GameGestures { - void toggleKeyboard(); -} +package com.limelight.ui; + +import com.limelight.binding.input.GameInputDevice; + +public interface GameGestures { + void toggleKeyboard(); + + default void showGameMenu(GameInputDevice device){}; +} diff --git a/app/src/main/java/com/limelight/ui/StreamView.java b/app/src/main/java/com/limelight/ui/StreamView.java old mode 100644 new mode 100755 index a11b416684..5a2536febd --- a/app/src/main/java/com/limelight/ui/StreamView.java +++ b/app/src/main/java/com/limelight/ui/StreamView.java @@ -1,85 +1,114 @@ -package com.limelight.ui; - -import android.annotation.TargetApi; -import android.content.Context; -import android.util.AttributeSet; -import android.view.KeyEvent; -import android.view.SurfaceView; - -public class StreamView extends SurfaceView { - private double desiredAspectRatio; - private InputCallbacks inputCallbacks; - - public void setDesiredAspectRatio(double aspectRatio) { - this.desiredAspectRatio = aspectRatio; - } - - public void setInputCallbacks(InputCallbacks callbacks) { - this.inputCallbacks = callbacks; - } - - public StreamView(Context context) { - super(context); - } - - public StreamView(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public StreamView(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public StreamView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - // If no fixed aspect ratio has been provided, simply use the default onMeasure() behavior - if (desiredAspectRatio == 0) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - return; - } - - // Based on code from: https://www.buzzingandroid.com/2012/11/easy-measuring-of-custom-views-with-specific-aspect-ratio/ - int widthSize = MeasureSpec.getSize(widthMeasureSpec); - int heightSize = MeasureSpec.getSize(heightMeasureSpec); - - int measuredHeight, measuredWidth; - if (widthSize > heightSize * desiredAspectRatio) { - measuredHeight = heightSize; - measuredWidth = (int)(measuredHeight * desiredAspectRatio); - } else { - measuredWidth = widthSize; - measuredHeight = (int)(measuredWidth / desiredAspectRatio); - } - - setMeasuredDimension(measuredWidth, measuredHeight); - } - - @Override - public boolean onKeyPreIme(int keyCode, KeyEvent event) { - // This callbacks allows us to override dumb IME behavior like when - // Samsung's default keyboard consumes Shift+Space. - if (inputCallbacks != null) { - if (event.getAction() == KeyEvent.ACTION_DOWN) { - if (inputCallbacks.handleKeyDown(event)) { - return true; - } - } - else if (event.getAction() == KeyEvent.ACTION_UP) { - if (inputCallbacks.handleKeyUp(event)) { - return true; - } - } - } - - return super.onKeyPreIme(keyCode, event); - } - - public interface InputCallbacks { - boolean handleKeyUp(KeyEvent event); - boolean handleKeyDown(KeyEvent event); - } -} +package com.limelight.ui; + +import android.annotation.TargetApi; +import android.content.ClipData; +import android.content.Context; +import android.text.ClipboardManager; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.SurfaceView; +import android.view.View; +import android.view.ViewGroup; + +public class StreamView extends SurfaceView { + private double desiredAspectRatio; + private InputCallbacks inputCallbacks; + private boolean fillDisplay = false; + + public void setDesiredAspectRatio(double aspectRatio) { + this.desiredAspectRatio = aspectRatio; + } + + public void setInputCallbacks(InputCallbacks callbacks) { + this.inputCallbacks = callbacks; + } + + public void setFillDisplay(boolean fillDisplay) { + this.fillDisplay = fillDisplay; + } + + public StreamView(Context context) { + super(context); + } + + public StreamView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public StreamView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public StreamView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + // If no fixed aspect ratio has been provided, simply use the default onMeasure() behavior + if (desiredAspectRatio == 0) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + return; + } + + // Based on code from: https://www.buzzingandroid.com/2012/11/easy-measuring-of-custom-views-with-specific-aspect-ratio/ + int widthSize = MeasureSpec.getSize(widthMeasureSpec); + int heightSize = MeasureSpec.getSize(heightMeasureSpec); + + int measuredHeight, measuredWidth; + if (fillDisplay) { + if (widthSize < heightSize * desiredAspectRatio) { + measuredHeight = heightSize; + measuredWidth = (int)(heightSize * desiredAspectRatio); + } else { + measuredWidth = widthSize; + measuredHeight = (int)(widthSize / desiredAspectRatio); + } + } + else { + if (widthSize > heightSize * desiredAspectRatio) { + measuredHeight = heightSize; + measuredWidth = (int)(measuredHeight * desiredAspectRatio); + } else { + measuredWidth = widthSize; + measuredHeight = (int)(measuredWidth / desiredAspectRatio); + } + } + + setMeasuredDimension(measuredWidth, measuredHeight); + } + + @Override + public boolean onKeyPreIme(int keyCode, KeyEvent event) { + // This callbacks allows us to override dumb IME behavior like when + // Samsung's default keyboard consumes Shift+Space. + if (inputCallbacks != null) { + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (inputCallbacks.handleKeyDown(event)) { + return true; + } + } + else if (event.getAction() == KeyEvent.ACTION_UP) { + if (inputCallbacks.handleKeyUp(event)) { + return true; + } + } + } + + return super.onKeyPreIme(keyCode, event); + } + + @Override + public void onWindowFocusChanged(boolean hasWindowFocus) { + super.onWindowFocusChanged(hasWindowFocus); + if (inputCallbacks != null) { + inputCallbacks.handleFocusChange(hasWindowFocus); + } + } + + public interface InputCallbacks { + boolean handleKeyUp(KeyEvent event); + boolean handleKeyDown(KeyEvent event); + boolean handleFocusChange(boolean hasWindowFocus); + } +} diff --git a/app/src/main/java/com/limelight/utils/CacheHelper.java b/app/src/main/java/com/limelight/utils/CacheHelper.java old mode 100644 new mode 100755 index 4d265e185b..014f4bd7b4 --- a/app/src/main/java/com/limelight/utils/CacheHelper.java +++ b/app/src/main/java/com/limelight/utils/CacheHelper.java @@ -1,86 +1,86 @@ -package com.limelight.utils; - -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.io.Reader; - -public class CacheHelper { - public static File openPath(boolean createPath, File root, String... path) { - File f = root; - for (int i = 0; i < path.length; i++) { - String component = path[i]; - - if (i == path.length - 1) { - // This is the file component so now we create parent directories - if (createPath) { - f.mkdirs(); - } - } - - f = new File(f, component); - } - return f; - } - - public static long getFileSize(File root, String... path) { - return openPath(false, root, path).length(); - } - - public static boolean deleteCacheFile(File root, String... path) { - return openPath(false, root, path).delete(); - } - - public static boolean cacheFileExists(File root, String... path) { - return openPath(false, root, path).exists(); - } - - public static InputStream openCacheFileForInput(File root, String... path) throws FileNotFoundException { - return new BufferedInputStream(new FileInputStream(openPath(false, root, path))); - } - - public static OutputStream openCacheFileForOutput(File root, String... path) throws FileNotFoundException { - return new BufferedOutputStream(new FileOutputStream(openPath(true, root, path))); - } - - public static void writeInputStreamToOutputStream(InputStream in, OutputStream out, long maxLength) throws IOException { - byte[] buf = new byte[4096]; - int bytesRead; - - while ((bytesRead = in.read(buf)) != -1) { - maxLength -= bytesRead; - if (maxLength <= 0) { - throw new IOException("Stream exceeded max size"); - } - out.write(buf, 0, bytesRead); - } - } - - public static String readInputStreamToString(InputStream in) throws IOException { - Reader r = new InputStreamReader(in); - - StringBuilder sb = new StringBuilder(); - char[] buf = new char[256]; - int bytesRead; - while ((bytesRead = r.read(buf)) != -1) { - sb.append(buf, 0, bytesRead); - } - - try { - in.close(); - } catch (IOException ignored) {} - - return sb.toString(); - } - - public static void writeStringToOutputStream(OutputStream out, String str) throws IOException { - out.write(str.getBytes("UTF-8")); - } -} +package com.limelight.utils; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.Reader; + +public class CacheHelper { + public static File openPath(boolean createPath, File root, String... path) { + File f = root; + for (int i = 0; i < path.length; i++) { + String component = path[i]; + + if (i == path.length - 1) { + // This is the file component so now we create parent directories + if (createPath) { + f.mkdirs(); + } + } + + f = new File(f, component); + } + return f; + } + + public static long getFileSize(File root, String... path) { + return openPath(false, root, path).length(); + } + + public static boolean deleteCacheFile(File root, String... path) { + return openPath(false, root, path).delete(); + } + + public static boolean cacheFileExists(File root, String... path) { + return openPath(false, root, path).exists(); + } + + public static InputStream openCacheFileForInput(File root, String... path) throws FileNotFoundException { + return new BufferedInputStream(new FileInputStream(openPath(false, root, path))); + } + + public static OutputStream openCacheFileForOutput(File root, String... path) throws FileNotFoundException { + return new BufferedOutputStream(new FileOutputStream(openPath(true, root, path))); + } + + public static void writeInputStreamToOutputStream(InputStream in, OutputStream out, long maxLength) throws IOException { + byte[] buf = new byte[4096]; + int bytesRead; + + while ((bytesRead = in.read(buf)) != -1) { + maxLength -= bytesRead; + if (maxLength <= 0) { + throw new IOException("Stream exceeded max size"); + } + out.write(buf, 0, bytesRead); + } + } + + public static String readInputStreamToString(InputStream in) throws IOException { + Reader r = new InputStreamReader(in); + + StringBuilder sb = new StringBuilder(); + char[] buf = new char[256]; + int bytesRead; + while ((bytesRead = r.read(buf)) != -1) { + sb.append(buf, 0, bytesRead); + } + + try { + in.close(); + } catch (IOException ignored) {} + + return sb.toString(); + } + + public static void writeStringToOutputStream(OutputStream out, String str) throws IOException { + out.write(str.getBytes("UTF-8")); + } +} diff --git a/app/src/main/java/com/limelight/utils/DeviceUtils.java b/app/src/main/java/com/limelight/utils/DeviceUtils.java new file mode 100755 index 0000000000..5f857c38cb --- /dev/null +++ b/app/src/main/java/com/limelight/utils/DeviceUtils.java @@ -0,0 +1,411 @@ +package com.limelight.utils; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.net.Uri; +import android.net.wifi.WifiInfo; +import android.net.wifi.WifiManager; +import android.os.Build; +import android.provider.Settings; +import android.telephony.TelephonyManager; +import android.text.TextUtils; +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.Enumeration; +import static android.Manifest.permission.ACCESS_WIFI_STATE; +import static android.Manifest.permission.CHANGE_WIFI_STATE; +import static android.content.Context.WIFI_SERVICE; + +import androidx.annotation.RequiresApi; +import androidx.annotation.RequiresPermission; + +public final class DeviceUtils { + + private DeviceUtils() { + throw new UnsupportedOperationException("u can't instantiate me..."); + } + + /** + * Return whether device is rooted. + * + * @return {@code true}: yes
{@code false}: no + */ + public static boolean isDeviceRooted() { + String su = "su"; + String[] locations = {"/system/bin/", "/system/xbin/", "/sbin/", "/system/sd/xbin/", + "/system/bin/failsafe/", "/data/local/xbin/", "/data/local/bin/", "/data/local/", + "/system/sbin/", "/usr/bin/", "/vendor/bin/"}; + for (String location : locations) { + if (new File(location + su).exists()) { + return true; + } + } + return false; + } + + /** + * Return whether ADB is enabled. + * + * @return {@code true}: yes
{@code false}: no + */ + @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR1) + public static boolean isAdbEnabled(Context context) { + return Settings.Secure.getInt( + context.getContentResolver(), + Settings.Global.ADB_ENABLED, 0 + ) > 0; + } + + /** + * Return the version name of device's system. + * + * @return the version name of device's system + */ + public static String getSDKVersionName() { + return android.os.Build.VERSION.RELEASE; + } + + /** + * Return version code of device's system. + * + * @return version code of device's system + */ + public static int getSDKVersionCode() { + return android.os.Build.VERSION.SDK_INT; + } + + /** + * Return the android id of device. + * + * @return the android id of device + */ + @SuppressLint("HardwareIds") + public static String getAndroidID(Context context) { + String id = Settings.Secure.getString( + context.getContentResolver(), + Settings.Secure.ANDROID_ID + ); + if ("9774d56d682e549c".equals(id)) return ""; + return id == null ? "" : id; + } + + /** + * Return the MAC address. + *

Must hold {@code }, + * {@code }, + * {@code }

+ * + * @return the MAC address + */ + @RequiresPermission(allOf = {ACCESS_WIFI_STATE, CHANGE_WIFI_STATE}) + public static String getMacAddress(Context context) { + String macAddress = getMacAddress(context,(String[]) null); + if (!TextUtils.isEmpty(macAddress) || getWifiEnabled(context)) return macAddress; + setWifiEnabled(context,true); + setWifiEnabled(context,false); + return getMacAddress(context,(String[]) null); + } + + private static boolean getWifiEnabled(Context context) { + @SuppressLint("WifiManagerLeak") + WifiManager manager = (WifiManager) context.getSystemService(WIFI_SERVICE); + if (manager == null) return false; + return manager.isWifiEnabled(); + } + + /** + * Enable or disable wifi. + *

Must hold {@code }

+ * + * @param enabled True to enabled, false otherwise. + */ + @RequiresPermission(CHANGE_WIFI_STATE) + private static void setWifiEnabled(Context context,final boolean enabled) { + @SuppressLint("WifiManagerLeak") + WifiManager manager = (WifiManager) context.getSystemService(WIFI_SERVICE); + if (manager == null) return; + if (enabled == manager.isWifiEnabled()) return; + manager.setWifiEnabled(enabled); + } + + /** + * Return the MAC address. + *

Must hold {@code }, + * {@code }

+ * + * @return the MAC address + */ + @RequiresPermission(allOf = {ACCESS_WIFI_STATE}) + public static String getMacAddress(Context context,final String... excepts) { + String macAddress = getMacAddressByNetworkInterface(); + if (isAddressNotInExcepts(macAddress, excepts)) { + return macAddress; + } + macAddress = getMacAddressByInetAddress(); + if (isAddressNotInExcepts(macAddress, excepts)) { + return macAddress; + } + macAddress = getMacAddressByWifiInfo(context); + if (isAddressNotInExcepts(macAddress, excepts)) { + return macAddress; + } + return ""; + } + + private static boolean isAddressNotInExcepts(final String address, final String... excepts) { + if (TextUtils.isEmpty(address)) { + return false; + } + if ("02:00:00:00:00:00".equals(address)) { + return false; + } + if (excepts == null || excepts.length == 0) { + return true; + } + for (String filter : excepts) { + if (filter != null && filter.equals(address)) { + return false; + } + } + return true; + } + + @RequiresPermission(ACCESS_WIFI_STATE) + private static String getMacAddressByWifiInfo(Context context) { + try { + final WifiManager wifi = (WifiManager) context + .getApplicationContext().getSystemService(WIFI_SERVICE); + if (wifi != null) { + final WifiInfo info = wifi.getConnectionInfo(); + if (info != null) { + @SuppressLint("HardwareIds") + String macAddress = info.getMacAddress(); + if (!TextUtils.isEmpty(macAddress)) { + return macAddress; + } + } + } + } catch (Exception e) { + e.printStackTrace(); + } + return "02:00:00:00:00:00"; + } + + private static String getMacAddressByNetworkInterface() { + try { + Enumeration nis = NetworkInterface.getNetworkInterfaces(); + while (nis.hasMoreElements()) { + NetworkInterface ni = nis.nextElement(); + if (ni == null || !ni.getName().equalsIgnoreCase("wlan0")) continue; + byte[] macBytes = ni.getHardwareAddress(); + if (macBytes != null && macBytes.length > 0) { + StringBuilder sb = new StringBuilder(); + for (byte b : macBytes) { + sb.append(String.format("%02x:", b)); + } + return sb.substring(0, sb.length() - 1); + } + } + } catch (Exception e) { + e.printStackTrace(); + } + return "02:00:00:00:00:00"; + } + + private static String getMacAddressByInetAddress() { + try { + InetAddress inetAddress = getInetAddress(); + if (inetAddress != null) { + NetworkInterface ni = NetworkInterface.getByInetAddress(inetAddress); + if (ni != null) { + byte[] macBytes = ni.getHardwareAddress(); + if (macBytes != null && macBytes.length > 0) { + StringBuilder sb = new StringBuilder(); + for (byte b : macBytes) { + sb.append(String.format("%02x:", b)); + } + return sb.substring(0, sb.length() - 1); + } + } + } + } catch (Exception e) { + e.printStackTrace(); + } + return "02:00:00:00:00:00"; + } + + private static InetAddress getInetAddress() { + try { + Enumeration nis = NetworkInterface.getNetworkInterfaces(); + while (nis.hasMoreElements()) { + NetworkInterface ni = nis.nextElement(); + // To prevent phone of xiaomi return "10.0.2.15" + if (!ni.isUp()) continue; + Enumeration addresses = ni.getInetAddresses(); + while (addresses.hasMoreElements()) { + InetAddress inetAddress = addresses.nextElement(); + if (!inetAddress.isLoopbackAddress()) { + String hostAddress = inetAddress.getHostAddress(); + if (hostAddress.indexOf(':') < 0) return inetAddress; + } + } + } + } catch (SocketException e) { + e.printStackTrace(); + } + return null; + } + + /** + * Return the manufacturer of the product/hardware. + *

e.g. Xiaomi

+ * + * @return the manufacturer of the product/hardware + */ + public static String getManufacturer() { + return Build.MANUFACTURER; + } + + /** + * Return the model of device. + *

e.g. MI2SC

+ * + * @return the model of device + */ + public static String getModel() { + String model = Build.MODEL; + if (model != null) { + model = model.trim().replaceAll("\\s*", ""); + } else { + model = ""; + } + return model; + } + + /** + * Return an ordered list of ABIs supported by this device. The most preferred ABI is the first + * element in the list. + * + * @return an ordered list of ABIs supported by this device + */ + public static String[] getABIs() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + return Build.SUPPORTED_ABIS; + } else { + if (!TextUtils.isEmpty(Build.CPU_ABI2)) { + return new String[]{Build.CPU_ABI, Build.CPU_ABI2}; + } + return new String[]{Build.CPU_ABI}; + } + } + + /** + * Return whether device is tablet. + * + * @return {@code true}: yes
{@code false}: no + */ + public static boolean isTablet() { + return (Resources.getSystem().getConfiguration().screenLayout + & Configuration.SCREENLAYOUT_SIZE_MASK) + >= Configuration.SCREENLAYOUT_SIZE_LARGE; + } + + /** + * Return whether device is emulator. + * + * @return {@code true}: yes
{@code false}: no + */ + public static boolean isEmulator(Context context) { + boolean checkProperty = Build.FINGERPRINT.startsWith("generic") + || Build.FINGERPRINT.toLowerCase().contains("vbox") + || Build.FINGERPRINT.toLowerCase().contains("test-keys") + || Build.MODEL.contains("google_sdk") + || Build.MODEL.contains("Emulator") + || Build.MODEL.contains("Android SDK built for x86") + || Build.MANUFACTURER.contains("Genymotion") + || (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) + || "google_sdk".equals(Build.PRODUCT); + if (checkProperty) return true; + + String operatorName = ""; + TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + if (tm != null) { + String name = tm.getNetworkOperatorName(); + if (name != null) { + operatorName = name; + } + } + boolean checkOperatorName = operatorName.toLowerCase().equals("android"); + if (checkOperatorName) return true; + + String url = "tel:" + "123456"; + Intent intent = new Intent(); + intent.setData(Uri.parse(url)); + intent.setAction(Intent.ACTION_DIAL); + boolean checkDial = intent.resolveActivity(context.getPackageManager()) == null; + if (checkDial) return true; + if (isEmulatorByCpu()) return true; + +// boolean checkDebuggerConnected = Debug.isDebuggerConnected(); +// if (checkDebuggerConnected) return true; + + return false; + } + + /** + * Returns whether is emulator by check cpu info. + * by function of {@link #readCpuInfo}, obtain the device cpu information. + * then compare whether it is intel or amd (because intel and amd are generally not mobile phone cpu), to determine whether it is a real mobile phone + * + * @return {@code true}: yes
{@code false}: no + */ + private static boolean isEmulatorByCpu() { + String cpuInfo = readCpuInfo(); + return cpuInfo.contains("intel") || cpuInfo.contains("amd"); + } + + /** + * Return Cpu information + * + * @return Cpu info + */ + public static String readCpuInfo() { + String result = ""; + try { + String[] args = {"/system/bin/cat", "/proc/cpuinfo"}; + ProcessBuilder cmd = new ProcessBuilder(args); + Process process = cmd.start(); + StringBuilder sb = new StringBuilder(); + String readLine; + BufferedReader responseReader = new BufferedReader(new InputStreamReader(process.getInputStream(), "utf-8")); + while ((readLine = responseReader.readLine()) != null) { + sb.append(readLine); + } + responseReader.close(); + result = sb.toString().toLowerCase(); + } catch (IOException ignored) { + } + return result; + } + + /** + * Whether user has enabled development settings. + * + * @return whether user has enabled development settings. + */ + @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR1) + public static boolean isDevelopmentSettingsEnabled(Context context) { + return Settings.Global.getInt( + context.getContentResolver(), + Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, 0 + ) > 0; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/utils/Dialog.java b/app/src/main/java/com/limelight/utils/Dialog.java old mode 100644 new mode 100755 index 7b3f9fd7dc..93ce78b619 --- a/app/src/main/java/com/limelight/utils/Dialog.java +++ b/app/src/main/java/com/limelight/utils/Dialog.java @@ -1,113 +1,113 @@ -package com.limelight.utils; - -import java.util.ArrayList; - -import android.app.Activity; -import android.app.AlertDialog; -import android.content.DialogInterface; -import android.widget.Button; - -import com.limelight.R; - -public class Dialog implements Runnable { - private final String title; - private final String message; - private final Activity activity; - private final Runnable runOnDismiss; - - private AlertDialog alert; - - private static final ArrayList rundownDialogs = new ArrayList<>(); - - private Dialog(Activity activity, String title, String message, Runnable runOnDismiss) - { - this.activity = activity; - this.title = title; - this.message = message; - this.runOnDismiss = runOnDismiss; - } - - public static void closeDialogs() - { - synchronized (rundownDialogs) { - for (Dialog d : rundownDialogs) { - if (d.alert.isShowing()) { - d.alert.dismiss(); - } - } - - rundownDialogs.clear(); - } - } - - public static void displayDialog(final Activity activity, String title, String message, final boolean endAfterDismiss) - { - activity.runOnUiThread(new Dialog(activity, title, message, new Runnable() { - @Override - public void run() { - if (endAfterDismiss) { - activity.finish(); - } - } - })); - } - - public static void displayDialog(Activity activity, String title, String message, Runnable runOnDismiss) - { - activity.runOnUiThread(new Dialog(activity, title, message, runOnDismiss)); - } - - @Override - public void run() { - // If we're dying, don't bother creating a dialog - if (activity.isFinishing()) - return; - - alert = new AlertDialog.Builder(activity).create(); - - alert.setTitle(title); - alert.setMessage(message); - alert.setCancelable(false); - alert.setCanceledOnTouchOutside(false); - - alert.setButton(AlertDialog.BUTTON_POSITIVE, activity.getResources().getText(android.R.string.ok), new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - synchronized (rundownDialogs) { - rundownDialogs.remove(Dialog.this); - alert.dismiss(); - } - - runOnDismiss.run(); - } - }); - alert.setButton(AlertDialog.BUTTON_NEUTRAL, activity.getResources().getText(R.string.help), new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - synchronized (rundownDialogs) { - rundownDialogs.remove(Dialog.this); - alert.dismiss(); - } - - runOnDismiss.run(); - - HelpLauncher.launchTroubleshooting(activity); - } - }); - alert.setOnShowListener(new DialogInterface.OnShowListener(){ - - @Override - public void onShow(DialogInterface dialog) { - // Set focus to the OK button by default - Button button = alert.getButton(AlertDialog.BUTTON_POSITIVE); - button.setFocusable(true); - button.setFocusableInTouchMode(true); - button.requestFocus(); - } - }); - - synchronized (rundownDialogs) { - rundownDialogs.add(this); - alert.show(); - } - } - -} +package com.limelight.utils; + +import java.util.ArrayList; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.widget.Button; + +import com.limelight.R; + +public class Dialog implements Runnable { + private final String title; + private final String message; + private final Activity activity; + private final Runnable runOnDismiss; + + private AlertDialog alert; + + private static final ArrayList rundownDialogs = new ArrayList<>(); + + private Dialog(Activity activity, String title, String message, Runnable runOnDismiss) + { + this.activity = activity; + this.title = title; + this.message = message; + this.runOnDismiss = runOnDismiss; + } + + public static void closeDialogs() + { + synchronized (rundownDialogs) { + for (Dialog d : rundownDialogs) { + if (d.alert.isShowing()) { + d.alert.dismiss(); + } + } + + rundownDialogs.clear(); + } + } + + public static void displayDialog(final Activity activity, String title, String message, final boolean endAfterDismiss) + { + activity.runOnUiThread(new Dialog(activity, title, message, new Runnable() { + @Override + public void run() { + if (endAfterDismiss) { + activity.finish(); + } + } + })); + } + + public static void displayDialog(Activity activity, String title, String message, Runnable runOnDismiss) + { + activity.runOnUiThread(new Dialog(activity, title, message, runOnDismiss)); + } + + @Override + public void run() { + // If we're dying, don't bother creating a dialog + if (activity.isFinishing()) + return; + + alert = new AlertDialog.Builder(activity).create(); + + alert.setTitle(title); + alert.setMessage(message); + alert.setCancelable(false); + alert.setCanceledOnTouchOutside(false); + + alert.setButton(AlertDialog.BUTTON_POSITIVE, activity.getResources().getText(android.R.string.ok), new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + synchronized (rundownDialogs) { + rundownDialogs.remove(Dialog.this); + alert.dismiss(); + } + + runOnDismiss.run(); + } + }); + alert.setButton(AlertDialog.BUTTON_NEUTRAL, activity.getResources().getText(R.string.help), new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + synchronized (rundownDialogs) { + rundownDialogs.remove(Dialog.this); + alert.dismiss(); + } + + runOnDismiss.run(); + + HelpLauncher.launchTroubleshooting(activity); + } + }); + alert.setOnShowListener(new DialogInterface.OnShowListener(){ + + @Override + public void onShow(DialogInterface dialog) { + // Set focus to the OK button by default + Button button = alert.getButton(AlertDialog.BUTTON_POSITIVE); + button.setFocusable(true); + button.setFocusableInTouchMode(true); + button.requestFocus(); + } + }); + + synchronized (rundownDialogs) { + rundownDialogs.add(this); + alert.show(); + } + } + +} diff --git a/app/src/main/java/com/limelight/utils/FileUriUtils.java b/app/src/main/java/com/limelight/utils/FileUriUtils.java new file mode 100755 index 0000000000..8c2e6e05e7 --- /dev/null +++ b/app/src/main/java/com/limelight/utils/FileUriUtils.java @@ -0,0 +1,102 @@ +package com.limelight.utils; + +import android.content.Context; +import android.net.Uri; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.Reader; + +/** + * Description + * Date: 2024-03-20 + * Time: 13:46 + */ +public class FileUriUtils { + public static String openUriForRead(Context context, Uri uri) { + if (uri == null) + return ""; + InputStream inputStream = null; + Reader reader = null; + BufferedReader bufferedReader = null; + StringBuilder result = new StringBuilder(); + try { + inputStream = context.getContentResolver().openInputStream(uri); + reader = new InputStreamReader(inputStream); + bufferedReader = new BufferedReader(reader); + String temp; + while ((temp = bufferedReader.readLine()) != null) { + result.append(temp); + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + if (reader != null) { + try { + reader.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + if (inputStream != null) { + try { + inputStream.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + if (bufferedReader != null) { + try { + bufferedReader.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + return result.toString(); + } + + public static boolean openUriForWrite(Context context, Uri uri, String content) { + if (uri == null) { + return false; + } + + try { + //从uri构造输出流 + OutputStream outputStream = context.getContentResolver().openOutputStream(uri); + //写入文件 + outputStream.write(content.getBytes()); + outputStream.flush(); + outputStream.close(); + return true; + } catch (Exception e) { + e.getLocalizedMessage(); + } + return false; + } + + public static boolean writerFileString(File file, String content) { + FileOutputStream fileOutputStream = null; + try { + fileOutputStream = new FileOutputStream(file); + fileOutputStream.write(content.getBytes()); + } catch (Exception e) { + e.printStackTrace(); + return false; + } finally { + if (fileOutputStream != null) { + try { + fileOutputStream.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + return true; + } +} diff --git a/app/src/main/java/com/limelight/utils/HelpLauncher.java b/app/src/main/java/com/limelight/utils/HelpLauncher.java old mode 100644 new mode 100755 index cae80cdde8..89f28e3ecb --- a/app/src/main/java/com/limelight/utils/HelpLauncher.java +++ b/app/src/main/java/com/limelight/utils/HelpLauncher.java @@ -1,51 +1,59 @@ -package com.limelight.utils; - -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.net.Uri; - -import com.limelight.HelpActivity; - -public class HelpLauncher { - public static void launchUrl(Context context, String url) { - // Try to launch the default browser - try { - Intent i = new Intent(Intent.ACTION_VIEW); - i.setData(Uri.parse(url)); - - // Several Android TV devices will lie and say they do have a browser even though the OS - // just shows an error dialog if we try to use it. We used to try to be clever and check - // the package name of the resolved intent, but it's not worth it anymore with Android 11's - // package visibility changes. We'll just always use the WebView on Android TV. - if (!context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK)) { - context.startActivity(i); - return; - } - } catch (Exception e) { - // This is only supposed to throw ActivityNotFoundException but - // it can (at least) also throw SecurityException if a user's default - // browser is not exported. We'll catch everything to workaround this. - - // Fall through - } - - // This platform has no browser (possibly a leanback device) - // We'll launch our WebView activity - Intent i = new Intent(context, HelpActivity.class); - i.setData(Uri.parse(url)); - context.startActivity(i); - } - - public static void launchSetupGuide(Context context) { - launchUrl(context, "https://github.com/moonlight-stream/moonlight-docs/wiki/Setup-Guide"); - } - - public static void launchTroubleshooting(Context context) { - launchUrl(context, "https://github.com/moonlight-stream/moonlight-docs/wiki/Troubleshooting"); - } - - public static void launchGameStreamEolFaq(Context context) { - launchUrl(context, "https://github.com/moonlight-stream/moonlight-docs/wiki/NVIDIA-GameStream-End-Of-Service-Announcement-FAQ"); - } -} +package com.limelight.utils; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; + +import com.limelight.HelpActivity; + +public class HelpLauncher { + public static void launchUrl(Context context, String url) { + if (url.startsWith("@")) { + try { + int resId = Integer.parseInt(url.substring(1)); + url = context.getString(resId); + } catch (Exception ignored) { + + } + } + // Try to launch the default browser + try { + Intent i = new Intent(Intent.ACTION_VIEW); + i.setData(Uri.parse(url)); + + // Several Android TV devices will lie and say they do have a browser even though the OS + // just shows an error dialog if we try to use it. We used to try to be clever and check + // the package name of the resolved intent, but it's not worth it anymore with Android 11's + // package visibility changes. We'll just always use the WebView on Android TV. + if (!context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK)) { + context.startActivity(i); + return; + } + } catch (Exception e) { + // This is only supposed to throw ActivityNotFoundException but + // it can (at least) also throw SecurityException if a user's default + // browser is not exported. We'll catch everything to workaround this. + + // Fall through + } + + // This platform has no browser (possibly a leanback device) + // We'll launch our WebView activity + Intent i = new Intent(context, HelpActivity.class); + i.setData(Uri.parse(url)); + context.startActivity(i); + } + + public static void launchSetupGuide(Context context) { + launchUrl(context, "https://github.com/moonlight-stream/moonlight-docs/wiki/Setup-Guide"); + } + + public static void launchTroubleshooting(Context context) { + launchUrl(context, "https://github.com/moonlight-stream/moonlight-docs/wiki/Troubleshooting"); + } + + public static void launchGameStreamEolFaq(Context context) { + launchUrl(context, "https://github.com/moonlight-stream/moonlight-docs/wiki/NVIDIA-GameStream-End-Of-Service-Announcement-FAQ"); + } +} diff --git a/app/src/main/java/com/limelight/utils/KeyMapper.java b/app/src/main/java/com/limelight/utils/KeyMapper.java new file mode 100644 index 0000000000..2537c9a715 --- /dev/null +++ b/app/src/main/java/com/limelight/utils/KeyMapper.java @@ -0,0 +1,1076 @@ +package com.limelight.utils; + +public class KeyMapper { + /* Linux Key Codes + * From https://github.com/torvalds/linux/blob/master/include/uapi/linux/input-event-codes.h + */ + public static int KEY_RESERVED = 0; + public static int KEY_ESC = 1; + public static int KEY_1 = 2; + public static int KEY_2 = 3; + public static int KEY_3 = 4; + public static int KEY_4 = 5; + public static int KEY_5 = 6; + public static int KEY_6 = 7; + public static int KEY_7 = 8; + public static int KEY_8 = 9; + public static int KEY_9 = 10; + public static int KEY_0 = 11; + public static int KEY_MINUS = 12; + public static int KEY_EQUAL = 13; + public static int KEY_BACKSPACE = 14; + public static int KEY_TAB = 15; + public static int KEY_Q = 16; + public static int KEY_W = 17; + public static int KEY_E = 18; + public static int KEY_R = 19; + public static int KEY_T = 20; + public static int KEY_Y = 21; + public static int KEY_U = 22; + public static int KEY_I = 23; + public static int KEY_O = 24; + public static int KEY_P = 25; + public static int KEY_LEFTBRACE = 26; + public static int KEY_RIGHTBRACE = 27; + public static int KEY_ENTER = 28; + public static int KEY_LEFTCTRL = 29; + public static int KEY_A = 30; + public static int KEY_S = 31; + public static int KEY_D = 32; + public static int KEY_F = 33; + public static int KEY_G = 34; + public static int KEY_H = 35; + public static int KEY_J = 36; + public static int KEY_K = 37; + public static int KEY_L = 38; + public static int KEY_SEMICOLON = 39; + public static int KEY_APOSTROPHE = 40; + public static int KEY_GRAVE = 41; + public static int KEY_LEFTSHIFT = 42; + public static int KEY_BACKSLASH = 43; + public static int KEY_Z = 44; + public static int KEY_X = 45; + public static int KEY_C = 46; + public static int KEY_V = 47; + public static int KEY_B = 48; + public static int KEY_N = 49; + public static int KEY_M = 50; + public static int KEY_COMMA = 51; + public static int KEY_DOT = 52; + public static int KEY_SLASH = 53; + public static int KEY_RIGHTSHIFT = 54; + public static int KEY_KPASTERISK = 55; + public static int KEY_LEFTALT = 56; + public static int KEY_SPACE = 57; + public static int KEY_CAPSLOCK = 58; + public static int KEY_F1 = 59; + public static int KEY_F2 = 60; + public static int KEY_F3 = 61; + public static int KEY_F4 = 62; + public static int KEY_F5 = 63; + public static int KEY_F6 = 64; + public static int KEY_F7 = 65; + public static int KEY_F8 = 66; + public static int KEY_F9 = 67; + public static int KEY_F10 = 68; + public static int KEY_NUMLOCK = 69; + public static int KEY_SCROLLLOCK = 70; + public static int KEY_KP7 = 71; + public static int KEY_KP8 = 72; + public static int KEY_KP9 = 73; + public static int KEY_KPMINUS = 74; + public static int KEY_KP4 = 75; + public static int KEY_KP5 = 76; + public static int KEY_KP6 = 77; + public static int KEY_KPPLUS = 78; + public static int KEY_KP1 = 79; + public static int KEY_KP2 = 80; + public static int KEY_KP3 = 81; + public static int KEY_KP0 = 82; + public static int KEY_KPDOT = 83; + + public static int KEY_ZENKAKUHANKAKU = 85; + public static int KEY_102ND = 86; + public static int KEY_F11 = 87; + public static int KEY_F12 = 88; + public static int KEY_RO = 89; + public static int KEY_KATAKANA = 90; + public static int KEY_HIRAGANA = 91; + public static int KEY_HENKAN = 92; + public static int KEY_KATAKANAHIRAGANA = 93; + public static int KEY_MUHENKAN = 94; + public static int KEY_KPJPCOMMA = 95; + public static int KEY_KPENTER = 96; + public static int KEY_RIGHTCTRL = 97; + public static int KEY_KPSLASH = 98; + public static int KEY_SYSRQ = 99; + public static int KEY_RIGHTALT = 100; + public static int KEY_LINEFEED = 101; + public static int KEY_HOME = 102; + public static int KEY_UP = 103; + public static int KEY_PAGEUP = 104; + public static int KEY_LEFT = 105; + public static int KEY_RIGHT = 106; + public static int KEY_END = 107; + public static int KEY_DOWN = 108; + public static int KEY_PAGEDOWN = 109; + public static int KEY_INSERT = 110; + public static int KEY_DELETE = 111; + public static int KEY_MACRO = 112; + public static int KEY_MUTE = 113; + public static int KEY_VOLUMEDOWN = 114; + public static int KEY_VOLUMEUP = 115; + public static int KEY_POWER = 116; /* SC System Power Down */ + public static int KEY_KPEQUAL = 117; + public static int KEY_KPPLUSMINUS = 118; + public static int KEY_PAUSE = 119; + public static int KEY_SCALE = 120; /* AL Compiz Scale (Expose) */ + + public static int KEY_KPCOMMA = 121; + public static int KEY_HANGEUL = 122; + public static int KEY_HANGUEL = KEY_HANGEUL; + public static int KEY_HANJA = 123; + public static int KEY_YEN = 124; + public static int KEY_LEFTMETA = 125; + public static int KEY_RIGHTMETA = 126; + public static int KEY_COMPOSE = 127; + + public static int KEY_STOP = 128; /* AC Stop */ + public static int KEY_AGAIN = 129; + public static int KEY_PROPS = 130; /* AC Properties */ + public static int KEY_UNDO = 131; /* AC Undo */ + public static int KEY_FRONT = 132; + public static int KEY_COPY = 133; /* AC Copy */ + public static int KEY_OPEN = 134; /* AC Open */ + public static int KEY_PASTE = 135; /* AC Paste */ + public static int KEY_FIND = 136; /* AC Search */ + public static int KEY_CUT = 137; /* AC Cut */ + public static int KEY_HELP = 138; /* AL Integrated Help Center */ + public static int KEY_MENU = 139; /* Menu (show menu) */ + public static int KEY_CALC = 140; /* AL Calculator */ + public static int KEY_SETUP = 141; + public static int KEY_SLEEP = 142; /* SC System Sleep */ + public static int KEY_WAKEUP = 143; /* System Wake Up */ + public static int KEY_FILE = 144; /* AL Local Machine Browser */ + public static int KEY_SENDFILE = 145; + public static int KEY_DELETEFILE = 146; + public static int KEY_XFER = 147; + public static int KEY_PROG1 = 148; + public static int KEY_PROG2 = 149; + public static int KEY_WWW = 150; /* AL Internet Browser */ + public static int KEY_MSDOS = 151; + public static int KEY_COFFEE = 152; /* AL Terminal Lock/Screensaver */ + public static int KEY_SCREENLOCK = KEY_COFFEE; + public static int KEY_ROTATE_DISPLAY = 153; /* Display orientation for e.g. tablets */ + public static int KEY_DIRECTION = KEY_ROTATE_DISPLAY; + public static int KEY_CYCLEWINDOWS = 154; + public static int KEY_MAIL = 155; + public static int KEY_BOOKMARKS = 156; /* AC Bookmarks */ + public static int KEY_COMPUTER = 157; + public static int KEY_BACK = 158; /* AC Back */ + public static int KEY_FORWARD = 159; /* AC Forward */ + public static int KEY_CLOSECD = 160; + public static int KEY_EJECTCD = 161; + public static int KEY_EJECTCLOSECD = 162; + public static int KEY_NEXTSONG = 163; + public static int KEY_PLAYPAUSE = 164; + public static int KEY_PREVIOUSSONG = 165; + public static int KEY_STOPCD = 166; + public static int KEY_RECORD = 167; + public static int KEY_REWIND = 168; + public static int KEY_PHONE = 169; /* Media Select Telephone */ + public static int KEY_ISO = 170; + public static int KEY_CONFIG = 171; /* AL Consumer Control Configuration */ + public static int KEY_HOMEPAGE = 172; /* AC Home */ + public static int KEY_REFRESH = 173; /* AC Refresh */ + public static int KEY_EXIT = 174; /* AC Exit */ + public static int KEY_MOVE = 175; + public static int KEY_EDIT = 176; + public static int KEY_SCROLLUP = 177; + public static int KEY_SCROLLDOWN = 178; + public static int KEY_KPLEFTPAREN = 179; + public static int KEY_KPRIGHTPAREN = 180; + public static int KEY_NEW = 181; /* AC New */ + public static int KEY_REDO = 182; /* AC Redo/Repeat */ + + public static int KEY_F13 = 183; + public static int KEY_F14 = 184; + public static int KEY_F15 = 185; + public static int KEY_F16 = 186; + public static int KEY_F17 = 187; + public static int KEY_F18 = 188; + public static int KEY_F19 = 189; + public static int KEY_F20 = 190; + public static int KEY_F21 = 191; + public static int KEY_F22 = 192; + public static int KEY_F23 = 193; + public static int KEY_F24 = 194; + + public static int KEY_PLAYCD = 200; + public static int KEY_PAUSECD = 201; + public static int KEY_PROG3 = 202; + public static int KEY_PROG4 = 203; + public static int KEY_ALL_APPLICATIONS = 204; /* AC Desktop Show All Applications */ + public static int KEY_DASHBOARD = KEY_ALL_APPLICATIONS; + public static int KEY_SUSPEND = 205; + public static int KEY_CLOSE = 206; /* AC Close */ + public static int KEY_PLAY = 207; + public static int KEY_FASTFORWARD = 208; + public static int KEY_BASSBOOST = 209; + public static int KEY_PRINT = 210; /* AC Print */ + public static int KEY_HP = 211; + public static int KEY_CAMERA = 212; + public static int KEY_SOUND = 213; + public static int KEY_QUESTION = 214; + public static int KEY_EMAIL = 215; + public static int KEY_CHAT = 216; + public static int KEY_SEARCH = 217; + public static int KEY_CONNECT = 218; + public static int KEY_FINANCE = 219; /* AL Checkbook/Finance */ + public static int KEY_SPORT = 220; + public static int KEY_SHOP = 221; + public static int KEY_ALTERASE = 222; + public static int KEY_CANCEL = 223; /* AC Cancel */ + public static int KEY_BRIGHTNESSDOWN = 224; + public static int KEY_BRIGHTNESSUP = 225; + public static int KEY_MEDIA = 226; + + public static int KEY_SWITCHVIDEOMODE = 227; /* Cycle between available video + outputs (Monitor/LCD/TV-out/etc) */ + public static int KEY_KBDILLUMTOGGLE = 228; + public static int KEY_KBDILLUMDOWN = 229; + public static int KEY_KBDILLUMUP = 230; + + public static int KEY_SEND = 231; /* AC Send */ + public static int KEY_REPLY = 232; /* AC Reply */ + public static int KEY_FORWARDMAIL = 233; /* AC Forward Msg */ + public static int KEY_SAVE = 234; /* AC Save */ + public static int KEY_DOCUMENTS = 235; + + public static int KEY_BATTERY = 236; + + public static int KEY_BLUETOOTH = 237; + public static int KEY_WLAN = 238; + public static int KEY_UWB = 239; + + public static int KEY_UNKNOWN = 240; + + public static int KEY_VIDEO_NEXT = 241; /* drive next video source */ + public static int KEY_VIDEO_PREV = 242; /* drive previous video source */ + public static int KEY_BRIGHTNESS_CYCLE = 243; /* brightness up, after max is min */ + public static int KEY_BRIGHTNESS_AUTO = 244; /* Set Auto Brightness: manual + brightness control is off, + rely on ambient */ + public static int KEY_BRIGHTNESS_ZERO = KEY_BRIGHTNESS_AUTO; + public static int KEY_DISPLAY_OFF = 245; /* display device to off state */ + + public static int KEY_WWAN = 246; /* Wireless WAN (LTE, UMTS, GSM, etc.) */ + public static int KEY_WIMAX = KEY_WWAN; + public static int KEY_RFKILL = 247; /* Key that controls all radios */ + + public static int KEY_MICMUTE = 248; /* Mute / unmute the microphone */ + +/* Code 255 is reserved for special needs of AT keyboard driver */ + + public static int BTN_MISC = 0x100; + public static int BTN_0 = 0x100; + public static int BTN_1 = 0x101; + public static int BTN_2 = 0x102; + public static int BTN_3 = 0x103; + public static int BTN_4 = 0x104; + public static int BTN_5 = 0x105; + public static int BTN_6 = 0x106; + public static int BTN_7 = 0x107; + public static int BTN_8 = 0x108; + public static int BTN_9 = 0x109; + + public static int BTN_MOUSE = 0x110; + public static int BTN_LEFT = 0x110; + public static int BTN_RIGHT = 0x111; + public static int BTN_MIDDLE = 0x112; + public static int BTN_SIDE = 0x113; + public static int BTN_EXTRA = 0x114; + public static int BTN_FORWARD = 0x115; + public static int BTN_BACK = 0x116; + public static int BTN_TASK = 0x117; + + public static int BTN_JOYSTICK = 0x120; + public static int BTN_TRIGGER = 0x120; + public static int BTN_THUMB = 0x121; + public static int BTN_THUMB2 = 0x122; + public static int BTN_TOP = 0x123; + public static int BTN_TOP2 = 0x124; + public static int BTN_PINKIE = 0x125; + public static int BTN_BASE = 0x126; + public static int BTN_BASE2 = 0x127; + public static int BTN_BASE3 = 0x128; + public static int BTN_BASE4 = 0x129; + public static int BTN_BASE5 = 0x12a; + public static int BTN_BASE6 = 0x12b; + public static int BTN_DEAD = 0x12f; + + public static int BTN_GAMEPAD = 0x130; + public static int BTN_SOUTH = 0x130; + public static int BTN_A = BTN_SOUTH; + public static int BTN_EAST = 0x131; + public static int BTN_B = BTN_EAST; + public static int BTN_C = 0x132; + public static int BTN_NORTH = 0x133; + public static int BTN_X = BTN_NORTH; + public static int BTN_WEST = 0x134; + public static int BTN_Y = BTN_WEST; + public static int BTN_Z = 0x135; + public static int BTN_TL = 0x136; + public static int BTN_TR = 0x137; + public static int BTN_TL2 = 0x138; + public static int BTN_TR2 = 0x139; + public static int BTN_SELECT = 0x13a; + public static int BTN_START = 0x13b; + public static int BTN_MODE = 0x13c; + public static int BTN_THUMBL = 0x13d; + public static int BTN_THUMBR = 0x13e; + + public static int BTN_DIGI = 0x140; + public static int BTN_TOOL_PEN = 0x140; + public static int BTN_TOOL_RUBBER = 0x141; + public static int BTN_TOOL_BRUSH = 0x142; + public static int BTN_TOOL_PENCIL = 0x143; + public static int BTN_TOOL_AIRBRUSH = 0x144; + public static int BTN_TOOL_FINGER = 0x145; + public static int BTN_TOOL_MOUSE = 0x146; + public static int BTN_TOOL_LENS = 0x147; + public static int BTN_TOOL_QUINTTAP = 0x148; /* Five fingers on trackpad */ + public static int BTN_STYLUS3 = 0x149; + public static int BTN_TOUCH = 0x14a; + public static int BTN_STYLUS = 0x14b; + public static int BTN_STYLUS2 = 0x14c; + public static int BTN_TOOL_DOUBLETAP = 0x14d; + public static int BTN_TOOL_TRIPLETAP = 0x14e; + public static int BTN_TOOL_QUADTAP = 0x14f; /* Four fingers on trackpad */ + + public static int BTN_WHEEL = 0x150; + public static int BTN_GEAR_DOWN = 0x150; + public static int BTN_GEAR_UP = 0x151; + + public static int KEY_OK = 0x160; + public static int KEY_SELECT = 0x161; + public static int KEY_GOTO = 0x162; + public static int KEY_CLEAR = 0x163; + public static int KEY_POWER2 = 0x164; + public static int KEY_OPTION = 0x165; + public static int KEY_INFO = 0x166; /* AL OEM Features/Tips/Tutorial */ + public static int KEY_TIME = 0x167; + public static int KEY_VENDOR = 0x168; + public static int KEY_ARCHIVE = 0x169; + public static int KEY_PROGRAM = 0x16a; /* Media Select Program Guide */ + public static int KEY_CHANNEL = 0x16b; + public static int KEY_FAVORITES = 0x16c; + public static int KEY_EPG = 0x16d; + public static int KEY_PVR = 0x16e; /* Media Select Home */ + public static int KEY_MHP = 0x16f; + public static int KEY_LANGUAGE = 0x170; + public static int KEY_TITLE = 0x171; + public static int KEY_SUBTITLE = 0x172; + public static int KEY_ANGLE = 0x173; + public static int KEY_FULL_SCREEN = 0x174; /* AC View Toggle */ + public static int KEY_ZOOM = KEY_FULL_SCREEN; + public static int KEY_MODE = 0x175; + public static int KEY_KEYBOARD = 0x176; + public static int KEY_ASPECT_RATIO = 0x177; /* HUTRR37: Aspect */ + public static int KEY_SCREEN = KEY_ASPECT_RATIO; + public static int KEY_PC = 0x178; /* Media Select Computer */ + public static int KEY_TV = 0x179; /* Media Select TV */ + public static int KEY_TV2 = 0x17a; /* Media Select Cable */ + public static int KEY_VCR = 0x17b; /* Media Select VCR */ + public static int KEY_VCR2 = 0x17c; /* VCR Plus */ + public static int KEY_SAT = 0x17d; /* Media Select Satellite */ + public static int KEY_SAT2 = 0x17e; + public static int KEY_CD = 0x17f; /* Media Select CD */ + public static int KEY_TAPE = 0x180; /* Media Select Tape */ + public static int KEY_RADIO = 0x181; + public static int KEY_TUNER = 0x182; /* Media Select Tuner */ + public static int KEY_PLAYER = 0x183; + public static int KEY_TEXT = 0x184; + public static int KEY_DVD = 0x185; /* Media Select DVD */ + public static int KEY_AUX = 0x186; + public static int KEY_MP3 = 0x187; + public static int KEY_AUDIO = 0x188; /* AL Audio Browser */ + public static int KEY_VIDEO = 0x189; /* AL Movie Browser */ + public static int KEY_DIRECTORY = 0x18a; + public static int KEY_LIST = 0x18b; + public static int KEY_MEMO = 0x18c; /* Media Select Messages */ + public static int KEY_CALENDAR = 0x18d; + public static int KEY_RED = 0x18e; + public static int KEY_GREEN = 0x18f; + public static int KEY_YELLOW = 0x190; + public static int KEY_BLUE = 0x191; + public static int KEY_CHANNELUP = 0x192; /* Channel Increment */ + public static int KEY_CHANNELDOWN = 0x193; /* Channel Decrement */ + public static int KEY_FIRST = 0x194; + public static int KEY_LAST = 0x195; /* Recall Last */ + public static int KEY_AB = 0x196; + public static int KEY_NEXT = 0x197; + public static int KEY_RESTART = 0x198; + public static int KEY_SLOW = 0x199; + public static int KEY_SHUFFLE = 0x19a; + public static int KEY_BREAK = 0x19b; + public static int KEY_PREVIOUS = 0x19c; + public static int KEY_DIGITS = 0x19d; + public static int KEY_TEEN = 0x19e; + public static int KEY_TWEN = 0x19f; + public static int KEY_VIDEOPHONE = 0x1a0; /* Media Select Video Phone */ + public static int KEY_GAMES = 0x1a1; /* Media Select Games */ + public static int KEY_ZOOMIN = 0x1a2; /* AC Zoom In */ + public static int KEY_ZOOMOUT = 0x1a3; /* AC Zoom Out */ + public static int KEY_ZOOMRESET = 0x1a4; /* AC Zoom */ + public static int KEY_WORDPROCESSOR = 0x1a5; /* AL Word Processor */ + public static int KEY_EDITOR = 0x1a6; /* AL Text Editor */ + public static int KEY_SPREADSHEET = 0x1a7; /* AL Spreadsheet */ + public static int KEY_GRAPHICSEDITOR = 0x1a8; /* AL Graphics Editor */ + public static int KEY_PRESENTATION = 0x1a9; /* AL Presentation App */ + public static int KEY_DATABASE = 0x1aa; /* AL Database App */ + public static int KEY_NEWS = 0x1ab; /* AL Newsreader */ + public static int KEY_VOICEMAIL = 0x1ac; /* AL Voicemail */ + public static int KEY_ADDRESSBOOK = 0x1ad; /* AL Contacts/Address Book */ + public static int KEY_MESSENGER = 0x1ae; /* AL Instant Messaging */ + public static int KEY_DISPLAYTOGGLE = 0x1af; /* Turn display (LCD) on and off */ + public static int KEY_BRIGHTNESS_TOGGLE = KEY_DISPLAYTOGGLE; + public static int KEY_SPELLCHECK = 0x1b0; /* AL Spell Check */ + public static int KEY_LOGOFF = 0x1b1; /* AL Logoff */ + + public static int KEY_DOLLAR = 0x1b2; + public static int KEY_EURO = 0x1b3; + + public static int KEY_FRAMEBACK = 0x1b4; /* Consumer - transport controls */ + public static int KEY_FRAMEFORWARD = 0x1b5; + public static int KEY_CONTEXT_MENU = 0x1b6; /* GenDesc - system context menu */ + public static int KEY_MEDIA_REPEAT = 0x1b7; /* Consumer - transport control */ + public static int KEY_10CHANNELSUP = 0x1b8; /* 10 channels up (10+) */ + public static int KEY_10CHANNELSDOWN = 0x1b9; /* 10 channels down (10-) */ + public static int KEY_IMAGES = 0x1ba; /* AL Image Browser */ + public static int KEY_NOTIFICATION_CENTER = 0x1bc; /* Show/hide the notification center */ + public static int KEY_PICKUP_PHONE = 0x1bd; /* Answer incoming call */ + public static int KEY_HANGUP_PHONE = 0x1be; /* Decline incoming call */ + + public static int KEY_DEL_EOL = 0x1c0; + public static int KEY_DEL_EOS = 0x1c1; + public static int KEY_INS_LINE = 0x1c2; + public static int KEY_DEL_LINE = 0x1c3; + + public static int KEY_FN = 0x1d0; + public static int KEY_FN_ESC = 0x1d1; + public static int KEY_FN_F1 = 0x1d2; + public static int KEY_FN_F2 = 0x1d3; + public static int KEY_FN_F3 = 0x1d4; + public static int KEY_FN_F4 = 0x1d5; + public static int KEY_FN_F5 = 0x1d6; + public static int KEY_FN_F6 = 0x1d7; + public static int KEY_FN_F7 = 0x1d8; + public static int KEY_FN_F8 = 0x1d9; + public static int KEY_FN_F9 = 0x1da; + public static int KEY_FN_F10 = 0x1db; + public static int KEY_FN_F11 = 0x1dc; + public static int KEY_FN_F12 = 0x1dd; + public static int KEY_FN_1 = 0x1de; + public static int KEY_FN_2 = 0x1df; + public static int KEY_FN_D = 0x1e0; + public static int KEY_FN_E = 0x1e1; + public static int KEY_FN_F = 0x1e2; + public static int KEY_FN_S = 0x1e3; + public static int KEY_FN_B = 0x1e4; + public static int KEY_FN_RIGHT_SHIFT = 0x1e5; + + public static int KEY_BRL_DOT1 = 0x1f1; + public static int KEY_BRL_DOT2 = 0x1f2; + public static int KEY_BRL_DOT3 = 0x1f3; + public static int KEY_BRL_DOT4 = 0x1f4; + public static int KEY_BRL_DOT5 = 0x1f5; + public static int KEY_BRL_DOT6 = 0x1f6; + public static int KEY_BRL_DOT7 = 0x1f7; + public static int KEY_BRL_DOT8 = 0x1f8; + public static int KEY_BRL_DOT9 = 0x1f9; + public static int KEY_BRL_DOT10 = 0x1fa; + + public static int KEY_NUMERIC_0 = 0x200; /* used by phones, remote controls, */ + public static int KEY_NUMERIC_1 = 0x201; /* and other keypads */ + public static int KEY_NUMERIC_2 = 0x202; + public static int KEY_NUMERIC_3 = 0x203; + public static int KEY_NUMERIC_4 = 0x204; + public static int KEY_NUMERIC_5 = 0x205; + public static int KEY_NUMERIC_6 = 0x206; + public static int KEY_NUMERIC_7 = 0x207; + public static int KEY_NUMERIC_8 = 0x208; + public static int KEY_NUMERIC_9 = 0x209; + public static int KEY_NUMERIC_STAR = 0x20a; + public static int KEY_NUMERIC_POUND = 0x20b; + public static int KEY_NUMERIC_A = 0x20c; /* Phone key A - HUT Telephony 0xb9 */ + public static int KEY_NUMERIC_B = 0x20d; + public static int KEY_NUMERIC_C = 0x20e; + public static int KEY_NUMERIC_D = 0x20f; + + public static int KEY_CAMERA_FOCUS = 0x210; + public static int KEY_WPS_BUTTON = 0x211; /* WiFi Protected Setup key */ + + public static int KEY_TOUCHPAD_TOGGLE = 0x212; /* Request switch touchpad on or off */ + public static int KEY_TOUCHPAD_ON = 0x213; + public static int KEY_TOUCHPAD_OFF = 0x214; + + public static int KEY_CAMERA_ZOOMIN = 0x215; + public static int KEY_CAMERA_ZOOMOUT = 0x216; + public static int KEY_CAMERA_UP = 0x217; + public static int KEY_CAMERA_DOWN = 0x218; + public static int KEY_CAMERA_LEFT = 0x219; + public static int KEY_CAMERA_RIGHT = 0x21a; + + public static int KEY_ATTENDANT_ON = 0x21b; + public static int KEY_ATTENDANT_OFF = 0x21c; + public static int KEY_ATTENDANT_TOGGLE = 0x21d; /* Attendant call on or off */ + public static int KEY_LIGHTS_TOGGLE = 0x21e; /* Reading light on or off */ + + public static int BTN_DPAD_UP = 0x220; + public static int BTN_DPAD_DOWN = 0x221; + public static int BTN_DPAD_LEFT = 0x222; + public static int BTN_DPAD_RIGHT = 0x223; + + public static int KEY_ALS_TOGGLE = 0x230; /* Ambient light sensor */ + public static int KEY_ROTATE_LOCK_TOGGLE = 0x231; /* Display rotation lock */ + public static int KEY_REFRESH_RATE_TOGGLE = 0x232; /* Display refresh rate toggle */ + + public static int KEY_BUTTONCONFIG = 0x240; /* AL Button Configuration */ + public static int KEY_TASKMANAGER = 0x241; /* AL Task/Project Manager */ + public static int KEY_JOURNAL = 0x242; /* AL Log/Journal/Timecard */ + public static int KEY_CONTROLPANEL = 0x243; /* AL Control Panel */ + public static int KEY_APPSELECT = 0x244; /* AL Select Task/Application */ + public static int KEY_SCREENSAVER = 0x245; /* AL Screen Saver */ + public static int KEY_VOICECOMMAND = 0x246; /* Listening Voice Command */ + public static int KEY_ASSISTANT = 0x247; /* AL Context-aware desktop assistant */ + public static int KEY_KBD_LAYOUT_NEXT = 0x248; /* AC Next Keyboard Layout Select */ + public static int KEY_EMOJI_PICKER = 0x249; /* Show/hide emoji picker (HUTRR101) */ + public static int KEY_DICTATE = 0x24a; /* Start or Stop Voice Dictation Session (HUTRR99) */ + public static int KEY_CAMERA_ACCESS_ENABLE = 0x24b; /* Enables programmatic access to camera devices. (HUTRR72) */ + public static int KEY_CAMERA_ACCESS_DISABLE = 0x24c; /* Disables programmatic access to camera devices. (HUTRR72) */ + public static int KEY_CAMERA_ACCESS_TOGGLE = 0x24d; /* Toggles the current state of the camera access control. (HUTRR72) */ + public static int KEY_ACCESSIBILITY = 0x24e; /* Toggles the system bound accessibility UI/command (HUTRR116) */ + public static int KEY_DO_NOT_DISTURB = 0x24f; /* Toggles the system-wide "Do Not Disturb" control (HUTRR94)*/ + + public static int KEY_BRIGHTNESS_MIN = 0x250; /* Set Brightness to Minimum */ + public static int KEY_BRIGHTNESS_MAX = 0x251; /* Set Brightness to Maximum */ + + public static int KEY_KBDINPUTASSIST_PREV = 0x260; + public static int KEY_KBDINPUTASSIST_NEXT = 0x261; + public static int KEY_KBDINPUTASSIST_PREVGROUP = 0x262; + public static int KEY_KBDINPUTASSIST_NEXTGROUP = 0x263; + public static int KEY_KBDINPUTASSIST_ACCEPT = 0x264; + public static int KEY_KBDINPUTASSIST_CANCEL = 0x265; + +/* Diagonal movement keys */ + public static int KEY_RIGHT_UP = 0x266; + public static int KEY_RIGHT_DOWN = 0x267; + public static int KEY_LEFT_UP = 0x268; + public static int KEY_LEFT_DOWN = 0x269; + + public static int KEY_ROOT_MENU = 0x26a; /* Show Device's Root Menu */ +/* Show Top Menu of the Media (e.g. DVD) */ + public static int KEY_MEDIA_TOP_MENU = 0x26b; + public static int KEY_NUMERIC_11 = 0x26c; + public static int KEY_NUMERIC_12 = 0x26d; +/* + * Toggle Audio Description: refers to an audio service that helps blind and + * visually impaired consumers understand the action in a program. Note: in + * some countries this is referred to as "Video Description". + */ + public static int KEY_AUDIO_DESC = 0x26e; + public static int KEY_3D_MODE = 0x26f; + public static int KEY_NEXT_FAVORITE = 0x270; + public static int KEY_STOP_RECORD = 0x271; + public static int KEY_PAUSE_RECORD = 0x272; + public static int KEY_VOD = 0x273; /* Video on Demand */ + public static int KEY_UNMUTE = 0x274; + public static int KEY_FASTREVERSE = 0x275; + public static int KEY_SLOWREVERSE = 0x276; +/* + * Control a data application associated with the currently viewed channel, + * e.g. teletext or data broadcast application (MHEG, MHP, HbbTV, etc.) + */ + public static int KEY_DATA = 0x277; + public static int KEY_ONSCREEN_KEYBOARD = 0x278; +/* Electronic privacy screen control */ + public static int KEY_PRIVACY_SCREEN_TOGGLE = 0x279; + +/* Select an area of screen to be copied */ + public static int KEY_SELECTIVE_SCREENSHOT = 0x27a; + +/* Move the focus to the next or previous user controllable element within a UI container */ + public static int KEY_NEXT_ELEMENT = 0x27b; + public static int KEY_PREVIOUS_ELEMENT = 0x27c; + +/* Toggle Autopilot engagement */ + public static int KEY_AUTOPILOT_ENGAGE_TOGGLE = 0x27d; + +/* Shortcut Keys */ + public static int KEY_MARK_WAYPOINT = 0x27e; + public static int KEY_SOS = 0x27f; + public static int KEY_NAV_CHART = 0x280; + public static int KEY_FISHING_CHART = 0x281; + public static int KEY_SINGLE_RANGE_RADAR = 0x282; + public static int KEY_DUAL_RANGE_RADAR = 0x283; + public static int KEY_RADAR_OVERLAY = 0x284; + public static int KEY_TRADITIONAL_SONAR = 0x285; + public static int KEY_CLEARVU_SONAR = 0x286; + public static int KEY_SIDEVU_SONAR = 0x287; + public static int KEY_NAV_INFO = 0x288; + public static int KEY_BRIGHTNESS_MENU = 0x289; + +/* + * Some keyboards have keys which do not have a defined meaning, these keys + * are intended to be programmed / bound to macros by the user. For most + * keyboards with these macro-keys the key-sequence to inject, or action to + * take, is all handled by software on the host side. So from the kernel's + * point of view these are just normal keys. + * + * The KEY_MACRO# codes below are intended for such keys, which may be labeled + * e.g. G1-G18, or S1 - S30. The KEY_MACRO# codes MUST NOT be used for keys + * where the marking on the key does indicate a defined meaning / purpose. + * + * The KEY_MACRO# codes MUST also NOT be used as fallback for when no existing + * KEY_FOO define matches the marking / purpose. In this case a new KEY_FOO + * define MUST be added. + */ + public static int KEY_MACRO1 = 0x290; + public static int KEY_MACRO2 = 0x291; + public static int KEY_MACRO3 = 0x292; + public static int KEY_MACRO4 = 0x293; + public static int KEY_MACRO5 = 0x294; + public static int KEY_MACRO6 = 0x295; + public static int KEY_MACRO7 = 0x296; + public static int KEY_MACRO8 = 0x297; + public static int KEY_MACRO9 = 0x298; + public static int KEY_MACRO10 = 0x299; + public static int KEY_MACRO11 = 0x29a; + public static int KEY_MACRO12 = 0x29b; + public static int KEY_MACRO13 = 0x29c; + public static int KEY_MACRO14 = 0x29d; + public static int KEY_MACRO15 = 0x29e; + public static int KEY_MACRO16 = 0x29f; + public static int KEY_MACRO17 = 0x2a0; + public static int KEY_MACRO18 = 0x2a1; + public static int KEY_MACRO19 = 0x2a2; + public static int KEY_MACRO20 = 0x2a3; + public static int KEY_MACRO21 = 0x2a4; + public static int KEY_MACRO22 = 0x2a5; + public static int KEY_MACRO23 = 0x2a6; + public static int KEY_MACRO24 = 0x2a7; + public static int KEY_MACRO25 = 0x2a8; + public static int KEY_MACRO26 = 0x2a9; + public static int KEY_MACRO27 = 0x2aa; + public static int KEY_MACRO28 = 0x2ab; + public static int KEY_MACRO29 = 0x2ac; + public static int KEY_MACRO30 = 0x2ad; + +/* + * Some keyboards with the macro-keys described above have some extra keys + * for controlling the host-side software responsible for the macro handling: + * -A macro recording start/stop key. Note that not all keyboards which emit + * KEY_MACRO_RECORD_START will also emit KEY_MACRO_RECORD_STOP if + * KEY_MACRO_RECORD_STOP is not advertised, then KEY_MACRO_RECORD_START + * should be interpreted as a recording start/stop toggle; + * -Keys for switching between different macro (pre)sets, either a key for + * cycling through the configured presets or keys to directly select a preset. + */ + public static int KEY_MACRO_RECORD_START = 0x2b0; + public static int KEY_MACRO_RECORD_STOP = 0x2b1; + public static int KEY_MACRO_PRESET_CYCLE = 0x2b2; + public static int KEY_MACRO_PRESET1 = 0x2b3; + public static int KEY_MACRO_PRESET2 = 0x2b4; + public static int KEY_MACRO_PRESET3 = 0x2b5; + +/* + * Some keyboards have a buildin LCD panel where the contents are controlled + * by the host. Often these have a number of keys directly below the LCD + * intended for controlling a menu shown on the LCD. These keys often don't + * have any labeling so we just name them KEY_KBD_LCD_MENU# + */ + public static int KEY_KBD_LCD_MENU1 = 0x2b8; + public static int KEY_KBD_LCD_MENU2 = 0x2b9; + public static int KEY_KBD_LCD_MENU3 = 0x2ba; + public static int KEY_KBD_LCD_MENU4 = 0x2bb; + public static int KEY_KBD_LCD_MENU5 = 0x2bc; + + public static int BTN_TRIGGER_HAPPY = 0x2c0; + public static int BTN_TRIGGER_HAPPY1 = 0x2c0; + public static int BTN_TRIGGER_HAPPY2 = 0x2c1; + public static int BTN_TRIGGER_HAPPY3 = 0x2c2; + public static int BTN_TRIGGER_HAPPY4 = 0x2c3; + public static int BTN_TRIGGER_HAPPY5 = 0x2c4; + public static int BTN_TRIGGER_HAPPY6 = 0x2c5; + public static int BTN_TRIGGER_HAPPY7 = 0x2c6; + public static int BTN_TRIGGER_HAPPY8 = 0x2c7; + public static int BTN_TRIGGER_HAPPY9 = 0x2c8; + public static int BTN_TRIGGER_HAPPY10 = 0x2c9; + public static int BTN_TRIGGER_HAPPY11 = 0x2ca; + public static int BTN_TRIGGER_HAPPY12 = 0x2cb; + public static int BTN_TRIGGER_HAPPY13 = 0x2cc; + public static int BTN_TRIGGER_HAPPY14 = 0x2cd; + public static int BTN_TRIGGER_HAPPY15 = 0x2ce; + public static int BTN_TRIGGER_HAPPY16 = 0x2cf; + public static int BTN_TRIGGER_HAPPY17 = 0x2d0; + public static int BTN_TRIGGER_HAPPY18 = 0x2d1; + public static int BTN_TRIGGER_HAPPY19 = 0x2d2; + public static int BTN_TRIGGER_HAPPY20 = 0x2d3; + public static int BTN_TRIGGER_HAPPY21 = 0x2d4; + public static int BTN_TRIGGER_HAPPY22 = 0x2d5; + public static int BTN_TRIGGER_HAPPY23 = 0x2d6; + public static int BTN_TRIGGER_HAPPY24 = 0x2d7; + public static int BTN_TRIGGER_HAPPY25 = 0x2d8; + public static int BTN_TRIGGER_HAPPY26 = 0x2d9; + public static int BTN_TRIGGER_HAPPY27 = 0x2da; + public static int BTN_TRIGGER_HAPPY28 = 0x2db; + public static int BTN_TRIGGER_HAPPY29 = 0x2dc; + public static int BTN_TRIGGER_HAPPY30 = 0x2dd; + public static int BTN_TRIGGER_HAPPY31 = 0x2de; + public static int BTN_TRIGGER_HAPPY32 = 0x2df; + public static int BTN_TRIGGER_HAPPY33 = 0x2e0; + public static int BTN_TRIGGER_HAPPY34 = 0x2e1; + public static int BTN_TRIGGER_HAPPY35 = 0x2e2; + public static int BTN_TRIGGER_HAPPY36 = 0x2e3; + public static int BTN_TRIGGER_HAPPY37 = 0x2e4; + public static int BTN_TRIGGER_HAPPY38 = 0x2e5; + public static int BTN_TRIGGER_HAPPY39 = 0x2e6; + public static int BTN_TRIGGER_HAPPY40 = 0x2e7; + +/* We avoid low common keys in module aliases so they don't get huge. */ + public static int KEY_MIN_INTERESTING = KEY_MUTE; + public static int KEY_MAX = 0x2ff; + public static int KEY_CNT = (KEY_MAX+1); + + +/* Windows Virtual Key Codes + * From https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes +*/ + public static int VK_LBUTTON = 0x01; // Left mouse button + public static int VK_RBUTTON = 0x02; // Right mouse button + public static int VK_CANCEL = 0x03; // Control-break processing + public static int VK_MBUTTON = 0x04; // Middle mouse button + public static int VK_XBUTTON1 = 0x05; // X1 mouse button + public static int VK_XBUTTON2 = 0x06; // X2 mouse button + public static int VK_BACK = 0x08; // BACKSPACE key + public static int VK_TAB = 0x09; // TAB key + public static int VK_CLEAR = 0x0C; // CLEAR key + public static int VK_RETURN = 0x0D; // ENTER key + public static int VK_SHIFT = 0x10; // SHIFT key + public static int VK_CONTROL = 0x11; // CTRL key + public static int VK_MENU = 0x12; // ALT key + public static int VK_PAUSE = 0x13; // PAUSE key + public static int VK_CAPITAL = 0x14; // CAPS LOCK key + public static int VK_KANA = 0x15; // IME Kana mode + public static int VK_HANGUL = 0x15; // IME Hangul mode + public static int VK_IME_ON = 0x16; // IME On + public static int VK_JUNJA = 0x17; // IME Junja mode + public static int VK_FINAL = 0x18; // IME mode + public static int VK_HANJA = 0x19; // IME Hanja mode + public static int VK_KANJI = 0x19; // IME Kanji mode + public static int VK_IME_OFF = 0x1A; // IME Off + public static int VK_ESCAPE = 0x1B; // ESC key + public static int VK_CONVERT = 0x1C; // IME convert + public static int VK_NONCONVERT = 0x1D; // IME nonconvert + public static int VK_ACCEPT = 0x1E; // IME accept + public static int VK_MODECHANGE = 0x1F; // IME mode change request + public static int VK_SPACE = 0x20; // SPACEBAR + public static int VK_PRIOR = 0x21; // PAGE UP key + public static int VK_NEXT = 0x22; // PAGE DOWN key + public static int VK_END = 0x23; // END key + public static int VK_HOME = 0x24; // HOME key + public static int VK_LEFT = 0x25; // LEFT ARROW key + public static int VK_UP = 0x26; // UP ARROW key + public static int VK_RIGHT = 0x27; // RIGHT ARROW key + public static int VK_DOWN = 0x28; // DOWN ARROW key + public static int VK_SELECT = 0x29; // SELECT key + public static int VK_PRINT = 0x2A; // PRINT key + public static int VK_EXECUTE = 0x2B; // EXECUTE key + public static int VK_SNAPSHOT = 0x2C; // PRINT SCREEN key + public static int VK_INSERT = 0x2D; // INS key + public static int VK_DELETE = 0x2E; // DEL key + public static int VK_HELP = 0x2F; // HELP key + public static int VK_0 = 0x30; // 0 key + public static int VK_1 = 0x31; // 1 key + public static int VK_2 = 0x32; // 2 key + public static int VK_3 = 0x33; // 3 key + public static int VK_4 = 0x34; // 4 key + public static int VK_5 = 0x35; // 5 key + public static int VK_6 = 0x36; // 6 key + public static int VK_7 = 0x37; // 7 key + public static int VK_8 = 0x38; // 8 key + public static int VK_9 = 0x39; // 9 key + // 0x3A-40 Undefined + public static int VK_A = 0x41; // A key + public static int VK_B = 0x42; // B key + public static int VK_C = 0x43; // C key + public static int VK_D = 0x44; // D key + public static int VK_E = 0x45; // E key + public static int VK_F = 0x46; // F key + public static int VK_G = 0x47; // G key + public static int VK_H = 0x48; // H key + public static int VK_I = 0x49; // I key + public static int VK_J = 0x4A; // J key + public static int VK_K = 0x4B; // K key + public static int VK_L = 0x4C; // L key + public static int VK_M = 0x4D; // M key + public static int VK_N = 0x4E; // N key + public static int VK_O = 0x4F; // O key + public static int VK_P = 0x50; // P key + public static int VK_Q = 0x51; // Q key + public static int VK_R = 0x52; // R key + public static int VK_S = 0x53; // S key + public static int VK_T = 0x54; // T key + public static int VK_U = 0x55; // U key + public static int VK_V = 0x56; // V key + public static int VK_W = 0x57; // W key + public static int VK_X = 0x58; // X key + public static int VK_Y = 0x59; // Y key + public static int VK_Z = 0x5A; // Z key + public static int VK_LWIN = 0x5B; // Left Windows key + public static int VK_RWIN = 0x5C; // Right Windows key + public static int VK_APPS = 0x5D; // Applications key + // 0x5E Reserved + public static int VK_SLEEP = 0x5F; // Computer Sleep key + public static int VK_NUMPAD0 = 0x60; // Numeric keypad 0 key + public static int VK_NUMPAD1 = 0x61; // Numeric keypad 1 key + public static int VK_NUMPAD2 = 0x62; // Numeric keypad 2 key + public static int VK_NUMPAD3 = 0x63; // Numeric keypad 3 key + public static int VK_NUMPAD4 = 0x64; // Numeric keypad 4 key + public static int VK_NUMPAD5 = 0x65; // Numeric keypad 5 key + public static int VK_NUMPAD6 = 0x66; // Numeric keypad 6 key + public static int VK_NUMPAD7 = 0x67; // Numeric keypad 7 key + public static int VK_NUMPAD8 = 0x68; // Numeric keypad 8 key + public static int VK_NUMPAD9 = 0x69; // Numeric keypad 9 key + public static int VK_MULTIPLY = 0x6A; // Multiply key + public static int VK_ADD = 0x6B; // Add key + public static int VK_SEPARATOR = 0x6C; // Separator key + public static int VK_SUBTRACT = 0x6D; // Subtract key + public static int VK_DECIMAL = 0x6E; // Decimal key + public static int VK_DIVIDE = 0x6F; // Divide key + public static int VK_F1 = 0x70; // F1 key + public static int VK_F2 = 0x71; // F2 key + public static int VK_F3 = 0x72; // F3 key + public static int VK_F4 = 0x73; // F4 key + public static int VK_F5 = 0x74; // F5 key + public static int VK_F6 = 0x75; // F6 key + public static int VK_F7 = 0x76; // F7 key + public static int VK_F8 = 0x77; // F8 key + public static int VK_F9 = 0x78; // F9 key + public static int VK_F10 = 0x79; // F10 key + public static int VK_F11 = 0x7A; // F11 key + public static int VK_F12 = 0x7B; // F12 key + public static int VK_F13 = 0x7C; // F13 key + public static int VK_F14 = 0x7D; // F14 key + public static int VK_F15 = 0x7E; // F15 key + public static int VK_F16 = 0x7F; // F16 key + public static int VK_F17 = 0x80; // F17 key + public static int VK_F18 = 0x81; // F18 key + public static int VK_F19 = 0x82; // F19 key + public static int VK_F20 = 0x83; // F20 key + public static int VK_F21 = 0x84; // F21 key + public static int VK_F22 = 0x85; // F22 key + public static int VK_F23 = 0x86; // F23 key + public static int VK_F24 = 0x87; // F24 key + // 0x88-8F Reserved + public static int VK_NUMLOCK = 0x90; // NUM LOCK key + public static int VK_SCROLL = 0x91; // SCROLL LOCK key + // 0x92-96 OEM specific + // 0x97-9F Unassigned + public static int VK_LSHIFT = 0xA0; // Left SHIFT key + public static int VK_RSHIFT = 0xA1; // Right SHIFT key + public static int VK_LCONTROL = 0xA2; // Left CONTROL key + public static int VK_RCONTROL = 0xA3; // Right CONTROL key + public static int VK_LMENU = 0xA4; // Left ALT key + public static int VK_RMENU = 0xA5; // Right ALT key + public static int VK_BROWSER_BACK = 0xA6; // Browser Back key + public static int VK_BROWSER_FORWARD = 0xA7; // Browser Forward key + public static int VK_BROWSER_REFRESH = 0xA8; // Browser Refresh key + public static int VK_BROWSER_STOP = 0xA9; // Browser Stop key + public static int VK_BROWSER_SEARCH = 0xAA; // Browser Search key + public static int VK_BROWSER_FAVORITES = 0xAB; // Browser Favorites key + public static int VK_BROWSER_HOME = 0xAC; // Browser Start and Home key + public static int VK_VOLUME_MUTE = 0xAD; // Volume Mute key + public static int VK_VOLUME_DOWN = 0xAE; // Volume Down key + public static int VK_VOLUME_UP = 0xAF; // Volume Up key + public static int VK_MEDIA_NEXT_TRACK = 0xB0; // Next Track key + public static int VK_MEDIA_PREV_TRACK = 0xB1; // Previous Track key + public static int VK_MEDIA_STOP = 0xB2; // Stop Media key + public static int VK_MEDIA_PLAY_PAUSE = 0xB3; // Play/Pause Media key + public static int VK_LAUNCH_MAIL = 0xB4; // Start Mail key + public static int VK_LAUNCH_MEDIA_SELECT = 0xB5; // Select Media key + public static int VK_LAUNCH_APP1 = 0xB6; // Start Application 1 key + public static int VK_LAUNCH_APP2 = 0xB7; // Start Application 2 key + // 0xB8-B9 Reserved + public static int VK_OEM_1 = 0xBA; // Used for miscellaneous characters; it can vary by keyboard. For the US standard keyboard, the ;: key + public static int VK_OEM_PLUS = 0xBB; // For any country/region, the + key + public static int VK_OEM_COMMA = 0xBC; // For any country/region, the , key + public static int VK_OEM_MINUS = 0xBD; // For any country/region, the - key + public static int VK_OEM_PERIOD = 0xBE; // For any country/region, the . key + public static int VK_OEM_2 = 0xBF; // Used for miscellaneous characters; it can vary by keyboard. For the US standard keyboard, the /? key + public static int VK_OEM_3 = 0xC0; // Used for miscellaneous characters; it can vary by keyboard. For the US standard keyboard, the `~ key + // 0xC1-DA Reserved + public static int VK_OEM_4 = 0xDB; // Used for miscellaneous characters; it can vary by keyboard. For the US standard keyboard, the [{ key + public static int VK_OEM_5 = 0xDC; // Used for miscellaneous characters; it can vary by keyboard. For the US standard keyboard, the \| key + public static int VK_OEM_6 = 0xDD; // Used for miscellaneous characters; it can vary by keyboard. For the US standard keyboard, the ]} key + public static int VK_OEM_7 = 0xDE; // Used for miscellaneous characters; it can vary by keyboard. For the US standard keyboard, the '" key + public static int VK_OEM_8 = 0xDF; // Used for miscellaneous characters; it can vary by keyboard. + // 0xE0 Reserved + // 0xE1 OEM specific + public static int VK_OEM_102 = 0xE2; // The <> keys on the US standard keyboard, or the \| key on the non-US 102-key keyboard + // 0xE3-E4 OEM specific + public static int VK_PROCESSKEY = 0xE5; // IME PROCESS key + // 0xE6 OEM specific + public static int VK_PACKET = 0xE7; // Used to pass Unicode characters as if they were keystrokes. The VK_PACKET key is the low word of a 32-bit Virtual Key value used for non-keyboard input methods. For more information, see Remark in KEYBDINPUT, SendInput, WM_KEYDOWN, and WM_KEYUP + // 0xE8 Unassigned + // 0xE9-F5 OEM specific + public static int VK_ATTN = 0xF6; // Attn key + public static int VK_CRSEL = 0xF7; // CrSel key + public static int VK_EXSEL = 0xF8; // ExSel key + public static int VK_EREOF = 0xF9; // Erase EOF key + public static int VK_PLAY = 0xFA; // Play key + public static int VK_ZOOM = 0xFB; // Zoom key + public static int VK_NONAME = 0xFC; // Reserved + public static int VK_PA1 = 0xFD; // PA1 key + public static int VK_OEM_CLEAR = 0xFE; // Clear key + + private static int[] linuxToWindowsKeyMap = new int[KEY_CNT]; + + static { + // Initialize all mappings to -1 (invalid/unmapped) + for (int i = 0; i < KEY_CNT; i++) { + linuxToWindowsKeyMap[i] = -1; + } + + // Define mappings + linuxToWindowsKeyMap[KEY_ESC] = VK_ESCAPE; + linuxToWindowsKeyMap[KEY_1] = VK_1; + linuxToWindowsKeyMap[KEY_2] = VK_2; + linuxToWindowsKeyMap[KEY_3] = VK_3; + linuxToWindowsKeyMap[KEY_4] = VK_4; + linuxToWindowsKeyMap[KEY_5] = VK_5; + linuxToWindowsKeyMap[KEY_6] = VK_6; + linuxToWindowsKeyMap[KEY_7] = VK_7; + linuxToWindowsKeyMap[KEY_8] = VK_8; + linuxToWindowsKeyMap[KEY_9] = VK_9; + linuxToWindowsKeyMap[KEY_0] = VK_0; + linuxToWindowsKeyMap[KEY_MINUS] = VK_OEM_MINUS; + linuxToWindowsKeyMap[KEY_EQUAL] = VK_OEM_PLUS; + linuxToWindowsKeyMap[KEY_BACKSPACE] = VK_BACK; + linuxToWindowsKeyMap[KEY_TAB] = VK_TAB; + linuxToWindowsKeyMap[KEY_Q] = VK_Q; + linuxToWindowsKeyMap[KEY_W] = VK_W; + linuxToWindowsKeyMap[KEY_E] = VK_E; + linuxToWindowsKeyMap[KEY_R] = VK_R; + linuxToWindowsKeyMap[KEY_T] = VK_T; + linuxToWindowsKeyMap[KEY_Y] = VK_Y; + linuxToWindowsKeyMap[KEY_U] = VK_U; + linuxToWindowsKeyMap[KEY_I] = VK_I; + linuxToWindowsKeyMap[KEY_O] = VK_O; + linuxToWindowsKeyMap[KEY_P] = VK_P; + linuxToWindowsKeyMap[KEY_LEFTBRACE] = VK_OEM_4; + linuxToWindowsKeyMap[KEY_RIGHTBRACE] = VK_OEM_6; + linuxToWindowsKeyMap[KEY_ENTER] = VK_RETURN; + linuxToWindowsKeyMap[KEY_A] = VK_A; + linuxToWindowsKeyMap[KEY_S] = VK_S; + linuxToWindowsKeyMap[KEY_D] = VK_D; + linuxToWindowsKeyMap[KEY_F] = VK_F; + linuxToWindowsKeyMap[KEY_G] = VK_G; + linuxToWindowsKeyMap[KEY_H] = VK_H; + linuxToWindowsKeyMap[KEY_J] = VK_J; + linuxToWindowsKeyMap[KEY_K] = VK_K; + linuxToWindowsKeyMap[KEY_L] = VK_L; + linuxToWindowsKeyMap[KEY_SEMICOLON] = VK_OEM_1; + linuxToWindowsKeyMap[KEY_APOSTROPHE] = VK_OEM_7; + linuxToWindowsKeyMap[KEY_GRAVE] = VK_OEM_3; + linuxToWindowsKeyMap[KEY_LEFTCTRL] = VK_LCONTROL; + linuxToWindowsKeyMap[KEY_RIGHTCTRL] = VK_RCONTROL; + linuxToWindowsKeyMap[KEY_LEFTSHIFT] = VK_LSHIFT; + linuxToWindowsKeyMap[KEY_RIGHTSHIFT] = VK_RSHIFT; + linuxToWindowsKeyMap[KEY_LEFTALT] = VK_LMENU; + linuxToWindowsKeyMap[KEY_RIGHTALT] = VK_RMENU; + linuxToWindowsKeyMap[KEY_LEFTMETA] = VK_LWIN; + linuxToWindowsKeyMap[KEY_RIGHTMETA] = VK_RWIN; + linuxToWindowsKeyMap[KEY_BACKSLASH] = VK_OEM_5; + linuxToWindowsKeyMap[KEY_Z] = VK_Z; + linuxToWindowsKeyMap[KEY_X] = VK_X; + linuxToWindowsKeyMap[KEY_C] = VK_C; + linuxToWindowsKeyMap[KEY_V] = VK_V; + linuxToWindowsKeyMap[KEY_B] = VK_B; + linuxToWindowsKeyMap[KEY_N] = VK_N; + linuxToWindowsKeyMap[KEY_M] = VK_M; + linuxToWindowsKeyMap[KEY_COMMA] = VK_OEM_COMMA; + linuxToWindowsKeyMap[KEY_DOT] = VK_OEM_PERIOD; + linuxToWindowsKeyMap[KEY_SLASH] = VK_OEM_2; + linuxToWindowsKeyMap[KEY_KPASTERISK] = VK_MULTIPLY; + linuxToWindowsKeyMap[KEY_SPACE] = VK_SPACE; + linuxToWindowsKeyMap[KEY_CAPSLOCK] = VK_CAPITAL; + linuxToWindowsKeyMap[KEY_F1] = VK_F1; + linuxToWindowsKeyMap[KEY_F2] = VK_F2; + linuxToWindowsKeyMap[KEY_F3] = VK_F3; + linuxToWindowsKeyMap[KEY_F4] = VK_F4; + linuxToWindowsKeyMap[KEY_F5] = VK_F5; + linuxToWindowsKeyMap[KEY_F6] = VK_F6; + linuxToWindowsKeyMap[KEY_F7] = VK_F7; + linuxToWindowsKeyMap[KEY_F8] = VK_F8; + linuxToWindowsKeyMap[KEY_F9] = VK_F9; + linuxToWindowsKeyMap[KEY_F10] = VK_F10; + linuxToWindowsKeyMap[KEY_F11] = VK_F11; + linuxToWindowsKeyMap[KEY_F12] = VK_F12; + linuxToWindowsKeyMap[KEY_F13] = VK_F13; + linuxToWindowsKeyMap[KEY_F14] = VK_F14; + linuxToWindowsKeyMap[KEY_F15] = VK_F15; + linuxToWindowsKeyMap[KEY_F16] = VK_F16; + linuxToWindowsKeyMap[KEY_F17] = VK_F17; + linuxToWindowsKeyMap[KEY_F18] = VK_F18; + linuxToWindowsKeyMap[KEY_F19] = VK_F19; + linuxToWindowsKeyMap[KEY_F20] = VK_F20; + linuxToWindowsKeyMap[KEY_F21] = VK_F21; + linuxToWindowsKeyMap[KEY_F22] = VK_F22; + linuxToWindowsKeyMap[KEY_F23] = VK_F23; + linuxToWindowsKeyMap[KEY_F24] = VK_F24; + linuxToWindowsKeyMap[KEY_SYSRQ] = VK_PRINT; + linuxToWindowsKeyMap[KEY_SCROLLLOCK] = VK_SCROLL; + linuxToWindowsKeyMap[KEY_PAUSE] = VK_PAUSE; + linuxToWindowsKeyMap[KEY_INSERT] = VK_INSERT; + linuxToWindowsKeyMap[KEY_HOME] = VK_HOME; + linuxToWindowsKeyMap[KEY_PAGEUP] = VK_PRIOR; + linuxToWindowsKeyMap[KEY_DELETE] = VK_DELETE; + linuxToWindowsKeyMap[KEY_END] = VK_END; + linuxToWindowsKeyMap[KEY_PAGEDOWN] = VK_NEXT; + linuxToWindowsKeyMap[KEY_RIGHT] = VK_RIGHT; + linuxToWindowsKeyMap[KEY_LEFT] = VK_LEFT; + linuxToWindowsKeyMap[KEY_DOWN] = VK_DOWN; + linuxToWindowsKeyMap[KEY_UP] = VK_UP; + linuxToWindowsKeyMap[KEY_NUMLOCK] = VK_NUMLOCK; + linuxToWindowsKeyMap[KEY_KP7] = VK_NUMPAD7; + linuxToWindowsKeyMap[KEY_KP8] = VK_NUMPAD8; + linuxToWindowsKeyMap[KEY_KP9] = VK_NUMPAD9; + linuxToWindowsKeyMap[KEY_KPMINUS] = VK_SUBTRACT; + linuxToWindowsKeyMap[KEY_KP4] = VK_NUMPAD4; + linuxToWindowsKeyMap[KEY_KP5] = VK_NUMPAD5; + linuxToWindowsKeyMap[KEY_KP6] = VK_NUMPAD6; + linuxToWindowsKeyMap[KEY_KPPLUS] = VK_ADD; + linuxToWindowsKeyMap[KEY_KP1] = VK_NUMPAD1; + linuxToWindowsKeyMap[KEY_KP2] = VK_NUMPAD2; + linuxToWindowsKeyMap[KEY_KP3] = VK_NUMPAD3; + linuxToWindowsKeyMap[KEY_KP0] = VK_NUMPAD0; + linuxToWindowsKeyMap[KEY_KPDOT] = VK_DECIMAL; + linuxToWindowsKeyMap[KEY_102ND] = VK_OEM_102; + linuxToWindowsKeyMap[KEY_COMPOSE] = VK_PROCESSKEY; + } + + public static int getWindowsKeyCode(int linuxKeyCode) { + if (linuxKeyCode >= 0 && linuxKeyCode < KEY_CNT) { + return linuxToWindowsKeyMap[linuxKeyCode]; + } + return -1; // Return -1 for out-of-range or unmapped keys + } + + public static void setKeyMapping(int linuxKeyCode, int windowsKeyCode) { + if (linuxKeyCode >= 0 && linuxKeyCode < KEY_CNT) { + linuxToWindowsKeyMap[linuxKeyCode] = windowsKeyCode; + } + } +} diff --git a/app/src/main/java/com/limelight/utils/NetHelper.java b/app/src/main/java/com/limelight/utils/NetHelper.java old mode 100644 new mode 100755 index eb5512f8b6..d025bf271c --- a/app/src/main/java/com/limelight/utils/NetHelper.java +++ b/app/src/main/java/com/limelight/utils/NetHelper.java @@ -1,32 +1,32 @@ -package com.limelight.utils; - -import android.content.Context; -import android.net.ConnectivityManager; -import android.net.Network; -import android.net.NetworkCapabilities; -import android.net.NetworkInfo; -import android.os.Build; - -public class NetHelper { - public static boolean isActiveNetworkVpn(Context context) { - ConnectivityManager connMgr = (ConnectivityManager) context.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) { - return netCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) || - !netCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN); - } - } - } - else { - NetworkInfo activeNetworkInfo = connMgr.getActiveNetworkInfo(); - if (activeNetworkInfo != null) { - return activeNetworkInfo.getType() == ConnectivityManager.TYPE_VPN; - } - } - - return false; - } -} +package com.limelight.utils; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkInfo; +import android.os.Build; + +public class NetHelper { + public static boolean isActiveNetworkVpn(Context context) { + ConnectivityManager connMgr = (ConnectivityManager) context.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) { + return netCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) || + !netCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN); + } + } + } + else { + NetworkInfo activeNetworkInfo = connMgr.getActiveNetworkInfo(); + if (activeNetworkInfo != null) { + return activeNetworkInfo.getType() == ConnectivityManager.TYPE_VPN; + } + } + + return false; + } +} diff --git a/app/src/main/java/com/limelight/utils/PanZoomHandler.java b/app/src/main/java/com/limelight/utils/PanZoomHandler.java new file mode 100644 index 0000000000..201341f82e --- /dev/null +++ b/app/src/main/java/com/limelight/utils/PanZoomHandler.java @@ -0,0 +1,156 @@ +package com.limelight.utils; + +import android.content.Context; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; +import android.view.View; + +import com.limelight.Game; +import com.limelight.preferences.PreferenceConfiguration; + +public class PanZoomHandler { + static private final float MAX_SCALE = 10.0f; + + private final Game game; + private final View streamView; + private final PreferenceConfiguration prefConfig; + private final boolean isTopMode; + private final ScaleGestureDetector scaleGestureDetector; + private final GestureDetector gestureDetector; + private View parent; + private float scaleFactor = 1.0f; + private float childX, childY = 0; + private float parentWidth, parentHeight = 0; + private float childWidth, childHeight = 0; + + public PanZoomHandler(Context context, Game game, View streamView, PreferenceConfiguration prefConfig) { + this.game = game; + this.streamView = streamView; + this.prefConfig = prefConfig; + this.isTopMode = prefConfig.alignDisplayTopCenter; + scaleGestureDetector = new ScaleGestureDetector(context, new ScaleListener()); + gestureDetector = new GestureDetector(context, new GestureListener()); + + // Everything gets easier with 0,0 as the pivot point + streamView.setPivotX(0); + streamView.setPivotY(0); + } + + public void handleTouchEvent(MotionEvent motionEvent) { + scaleGestureDetector.onTouchEvent(motionEvent); + gestureDetector.onTouchEvent(motionEvent); + } + + private void updateDimensions() { + childHeight = streamView.getHeight() * scaleFactor; + childWidth = streamView.getWidth() * scaleFactor; + parentWidth = parent.getWidth(); + parentHeight = parent.getHeight(); + } + + private void constrainToBounds() { + updateDimensions(); + + if (parentWidth >= childWidth) { + childX = (parentWidth - childWidth) / 2; + } else { + float boundaryX = parentWidth - childWidth; + childX = Math.max(boundaryX, Math.min(childX, 0)); + } + + if (parentHeight >= childHeight) { + if (isTopMode) { + childY = 0; + } else { + childY = (parentHeight - childHeight) / 2; + } + } else { + float boundaryY = parentHeight - childHeight; + childY = Math.max(boundaryY, Math.min(childY, 0)); + } + + streamView.setX(childX); + streamView.setY(childY); + } + + public void handleSurfaceChange() { + if (childWidth == 0 || parent == null) { + // Retrieve parent, should handle both built-in display and external display + parent = (View)streamView.getParent(); + return; + } + + float prevChildWidth = childWidth; + float prevChildHeight = childHeight; + float prevParentWidth = parentWidth; + float prevParentHeight = parentHeight; + + updateDimensions(); + + float viewScaleX = childWidth / prevChildWidth; + float viewScaleY = childHeight / prevChildHeight; + + float dPivotX1 = childX - prevParentWidth / 2; + float dPivotY1 = childY - prevParentHeight / 2; + + float dPivotX2 = dPivotX1 * viewScaleX; + float dPivotY2 = dPivotY1 * viewScaleY; + + childX = dPivotX2 + parentWidth / 2; + childY = dPivotY2 + parentHeight / 2; + + streamView.setX(childX); + streamView.setY(childY); + + constrainToBounds(); + } + + private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener { + @Override + public boolean onScale(ScaleGestureDetector detector) { + float newScaleFactor = scaleFactor * detector.getScaleFactor(); + newScaleFactor = Math.max(1, Math.min(newScaleFactor, MAX_SCALE)); // Apply minimum scale + + // Calculate pivot point + float focusX = detector.getFocusX(); + float focusY = detector.getFocusY(); + + float dPivotX = (childX - focusX) / scaleFactor * newScaleFactor; + float dPivotY = (childY - focusY) / scaleFactor * newScaleFactor; + + childX = focusX + dPivotX; + childY = focusY + dPivotY; + + scaleFactor = newScaleFactor; + + streamView.setScaleX(scaleFactor); + streamView.setScaleY(scaleFactor); + + streamView.setX(childX); + streamView.setY(childY); + + constrainToBounds(); + return true; + } + + @Override + public void onScaleEnd(ScaleGestureDetector detector) { + game.updatePipAutoEnter(); + } + } + + private class GestureListener extends GestureDetector.SimpleOnGestureListener { + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + childX = streamView.getX() - distanceX; + childY = streamView.getY() - distanceY; + + streamView.setX(childX); + streamView.setY(childY); + + constrainToBounds(); + return true; + } + } +} diff --git a/app/src/main/java/com/limelight/utils/ServerHelper.java b/app/src/main/java/com/limelight/utils/ServerHelper.java old mode 100644 new mode 100755 index ef5a790297..a7e2aa0b1f --- a/app/src/main/java/com/limelight/utils/ServerHelper.java +++ b/app/src/main/java/com/limelight/utils/ServerHelper.java @@ -1,169 +1,209 @@ -package com.limelight.utils; - -import android.app.Activity; -import android.content.Intent; -import android.widget.Toast; - -import com.limelight.AppView; -import com.limelight.Game; -import com.limelight.R; -import com.limelight.ShortcutTrampoline; -import com.limelight.binding.PlatformBinding; -import com.limelight.computers.ComputerManagerService; -import com.limelight.nvstream.http.ComputerDetails; -import com.limelight.nvstream.http.HostHttpResponseException; -import com.limelight.nvstream.http.NvApp; -import com.limelight.nvstream.http.NvHTTP; -import com.limelight.nvstream.jni.MoonBridge; - -import org.xmlpull.v1.XmlPullParserException; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.net.UnknownHostException; -import java.security.cert.CertificateEncodingException; - -public class ServerHelper { - public static final String CONNECTION_TEST_SERVER = "android.conntest.moonlight-stream.org"; - - public static ComputerDetails.AddressTuple getCurrentAddressFromComputer(ComputerDetails computer) throws IOException { - if (computer.activeAddress == null) { - throw new IOException("No active address for "+computer.name); - } - return computer.activeAddress; - } - - public static Intent createPcShortcutIntent(Activity parent, ComputerDetails computer) { - Intent i = new Intent(parent, ShortcutTrampoline.class); - i.putExtra(AppView.NAME_EXTRA, computer.name); - i.putExtra(AppView.UUID_EXTRA, computer.uuid); - i.setAction(Intent.ACTION_DEFAULT); - return i; - } - - public static Intent createAppShortcutIntent(Activity parent, ComputerDetails computer, NvApp app) { - Intent i = new Intent(parent, ShortcutTrampoline.class); - i.putExtra(AppView.NAME_EXTRA, computer.name); - i.putExtra(AppView.UUID_EXTRA, computer.uuid); - i.putExtra(Game.EXTRA_APP_NAME, app.getAppName()); - i.putExtra(Game.EXTRA_APP_ID, ""+app.getAppId()); - i.putExtra(Game.EXTRA_APP_HDR, app.isHdrSupported()); - i.setAction(Intent.ACTION_DEFAULT); - return i; - } - - public static Intent createStartIntent(Activity parent, NvApp app, ComputerDetails computer, - ComputerManagerService.ComputerManagerBinder managerBinder) { - Intent intent = new Intent(parent, Game.class); - intent.putExtra(Game.EXTRA_HOST, computer.activeAddress.address); - intent.putExtra(Game.EXTRA_PORT, computer.activeAddress.port); - intent.putExtra(Game.EXTRA_HTTPS_PORT, computer.httpsPort); - intent.putExtra(Game.EXTRA_APP_NAME, app.getAppName()); - intent.putExtra(Game.EXTRA_APP_ID, app.getAppId()); - intent.putExtra(Game.EXTRA_APP_HDR, app.isHdrSupported()); - intent.putExtra(Game.EXTRA_UNIQUEID, managerBinder.getUniqueId()); - intent.putExtra(Game.EXTRA_PC_UUID, computer.uuid); - intent.putExtra(Game.EXTRA_PC_NAME, computer.name); - try { - if (computer.serverCert != null) { - intent.putExtra(Game.EXTRA_SERVER_CERT, computer.serverCert.getEncoded()); - } - } catch (CertificateEncodingException e) { - e.printStackTrace(); - } - return intent; - } - - public static void doStart(Activity parent, NvApp app, ComputerDetails computer, - ComputerManagerService.ComputerManagerBinder managerBinder) { - if (computer.state == ComputerDetails.State.OFFLINE || computer.activeAddress == null) { - Toast.makeText(parent, parent.getResources().getString(R.string.pair_pc_offline), Toast.LENGTH_SHORT).show(); - return; - } - parent.startActivity(createStartIntent(parent, app, computer, managerBinder)); - } - - public static void doNetworkTest(final Activity parent) { - new Thread(new Runnable() { - @Override - public void run() { - SpinnerDialog spinnerDialog = SpinnerDialog.displayDialog(parent, - parent.getResources().getString(R.string.nettest_title_waiting), - parent.getResources().getString(R.string.nettest_text_waiting), - false); - - int ret = MoonBridge.testClientConnectivity(CONNECTION_TEST_SERVER, 443, MoonBridge.ML_PORT_FLAG_ALL); - spinnerDialog.dismiss(); - - String dialogSummary; - if (ret == MoonBridge.ML_TEST_RESULT_INCONCLUSIVE) { - dialogSummary = parent.getResources().getString(R.string.nettest_text_inconclusive); - } - else if (ret == 0) { - dialogSummary = parent.getResources().getString(R.string.nettest_text_success); - } - else { - dialogSummary = parent.getResources().getString(R.string.nettest_text_failure); - dialogSummary += MoonBridge.stringifyPortFlags(ret, "\n"); - } - - Dialog.displayDialog(parent, - parent.getResources().getString(R.string.nettest_title_done), - dialogSummary, - false); - } - }).start(); - } - - public static void doQuit(final Activity parent, - final ComputerDetails computer, - final NvApp app, - final ComputerManagerService.ComputerManagerBinder managerBinder, - final Runnable onComplete) { - Toast.makeText(parent, parent.getResources().getString(R.string.applist_quit_app) + " " + app.getAppName() + "...", 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(parent)); - if (httpConn.quitApp()) { - message = parent.getResources().getString(R.string.applist_quit_success) + " " + app.getAppName(); - } else { - message = parent.getResources().getString(R.string.applist_quit_fail) + " " + app.getAppName(); - } - } catch (HostHttpResponseException e) { - if (e.getErrorCode() == 599) { - message = "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()+")"; - } - else { - message = e.getMessage(); - } - } catch (UnknownHostException e) { - message = parent.getResources().getString(R.string.error_unknown_host); - } catch (FileNotFoundException e) { - message = parent.getResources().getString(R.string.error_404); - } catch (IOException | XmlPullParserException e) { - message = e.getMessage(); - e.printStackTrace(); - } finally { - if (onComplete != null) { - onComplete.run(); - } - } - - final String toastMessage = message; - parent.runOnUiThread(new Runnable() { - @Override - public void run() { - Toast.makeText(parent, toastMessage, Toast.LENGTH_LONG).show(); - } - }); - } - }).start(); - } -} +package com.limelight.utils; + +import android.app.Activity; +import android.content.Intent; +import android.widget.Toast; + +import com.limelight.AppView; +import com.limelight.Game; +import com.limelight.R; +import com.limelight.ShortcutTrampoline; +import com.limelight.binding.PlatformBinding; +import com.limelight.computers.ComputerManagerService; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.HostHttpResponseException; +import com.limelight.nvstream.http.NvApp; +import com.limelight.nvstream.http.NvHTTP; +import com.limelight.nvstream.jni.MoonBridge; + +import org.xmlpull.v1.XmlPullParserException; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.UnknownHostException; +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; + +public class ServerHelper { + public static final String CONNECTION_TEST_SERVER = "android.conntest.moonlight-stream.org"; + + public static ComputerDetails.AddressTuple getCurrentAddressFromComputer(ComputerDetails computer) throws IOException { + if (computer.activeAddress == null) { + throw new IOException("No active address for "+computer.name); + } + return computer.activeAddress; + } + + public static Intent createPcShortcutIntent(Activity parent, ComputerDetails computer) { + Intent i = new Intent(parent, ShortcutTrampoline.class); + i.putExtra(AppView.NAME_EXTRA, computer.name); + i.putExtra(AppView.UUID_EXTRA, computer.uuid); + i.setAction(Intent.ACTION_DEFAULT); + return i; + } + + public static Intent createAppShortcutIntent(Activity parent, ComputerDetails computer, NvApp app) { + Intent i = new Intent(parent, ShortcutTrampoline.class); + i.putExtra(AppView.NAME_EXTRA, computer.name); + i.putExtra(AppView.UUID_EXTRA, computer.uuid); + i.putExtra(Game.EXTRA_APP_NAME, app.getAppName()); + i.putExtra(Game.EXTRA_APP_ID, ""+app.getAppId()); + i.putExtra(Game.EXTRA_APP_HDR, app.isHdrSupported()); + i.setAction(Intent.ACTION_DEFAULT); + return i; + } + + public static Intent createStartIntent(Activity parent, NvApp app, ComputerDetails computer, + ComputerManagerService.ComputerManagerBinder managerBinder, + boolean withVDisplay) { + + Intent intent = new Intent(parent, Game.class); + intent.putExtra(Game.EXTRA_HOST, computer.activeAddress.address); + intent.putExtra(Game.EXTRA_PORT, computer.activeAddress.port); + intent.putExtra(Game.EXTRA_HTTPS_PORT, computer.httpsPort); + intent.putExtra(Game.EXTRA_APP_NAME, app.getAppName()); + intent.putExtra(Game.EXTRA_APP_ID, app.getAppId()); + intent.putExtra(Game.EXTRA_APP_HDR, app.isHdrSupported()); + intent.putExtra(Game.EXTRA_UNIQUEID, managerBinder.getUniqueId()); + intent.putExtra(Game.EXTRA_PC_UUID, computer.uuid); + intent.putExtra(Game.EXTRA_PC_NAME, computer.name); + intent.putExtra(Game.EXTRA_VDISPLAY, withVDisplay); + intent.putExtra(Game.EXTRA_SERVER_COMMANDS, (ArrayList) computer.serverCommands); + try { + if (computer.serverCert != null) { + intent.putExtra(Game.EXTRA_SERVER_CERT, computer.serverCert.getEncoded()); + } + } catch (CertificateEncodingException e) { + e.printStackTrace(); + } + return intent; + } + + public static void doStart(Activity parent, NvApp app, ComputerDetails computer, + ComputerManagerService.ComputerManagerBinder managerBinder, boolean withVDisplay) { + if (computer.state == ComputerDetails.State.OFFLINE || computer.activeAddress == null) { + Toast.makeText(parent, parent.getResources().getString(R.string.pair_pc_offline), Toast.LENGTH_SHORT).show(); + return; + } + parent.startActivity(createStartIntent(parent, app, computer, managerBinder, withVDisplay)); + } + + public static void doNetworkTest(final Activity parent) { + new Thread(new Runnable() { + @Override + public void run() { + SpinnerDialog spinnerDialog = SpinnerDialog.displayDialog(parent, + parent.getResources().getString(R.string.nettest_title_waiting), + parent.getResources().getString(R.string.nettest_text_waiting), + false); + + int ret = MoonBridge.testClientConnectivity(CONNECTION_TEST_SERVER, 443, MoonBridge.ML_PORT_FLAG_ALL); + spinnerDialog.dismiss(); + + String dialogSummary; + if (ret == MoonBridge.ML_TEST_RESULT_INCONCLUSIVE) { + dialogSummary = parent.getResources().getString(R.string.nettest_text_inconclusive); + } + else if (ret == 0) { + dialogSummary = parent.getResources().getString(R.string.nettest_text_success); + } + else { + dialogSummary = parent.getResources().getString(R.string.nettest_text_failure); + dialogSummary += MoonBridge.stringifyPortFlags(ret, "\n"); + } + + Dialog.displayDialog(parent, + parent.getResources().getString(R.string.nettest_title_done), + dialogSummary, + false); + } + }).start(); + } + + public static void doQuit(final Activity parent, + final NvHTTP httpConn, + final String appName, + final Runnable onComplete, + final Runnable onFail + ) { + parent.runOnUiThread(() -> Toast.makeText(parent, parent.getResources().getString(R.string.applist_quit_app) + " " + appName + "...", Toast.LENGTH_SHORT).show()); + new Thread(new Runnable() { + @Override + public void run() { + String message; + boolean failed = false; + try { + if (httpConn.quitApp()) { + message = parent.getResources().getString(R.string.applist_quit_success) + " " + appName; + } else { + message = parent.getResources().getString(R.string.applist_quit_fail) + " " + appName; + } + } catch (HostHttpResponseException e) { + failed = true; + if (e.getErrorCode() == 599) { + message = "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()+")"; + } + else { + message = e.getMessage(); + } + } catch (UnknownHostException e) { + failed = true; + message = parent.getResources().getString(R.string.error_unknown_host); + } catch (FileNotFoundException e) { + failed = true; + message = parent.getResources().getString(R.string.error_404); + } catch (IOException | XmlPullParserException e) { + failed = true; + message = e.getMessage(); + e.printStackTrace(); + } finally { + if (failed) { + if (onFail != null) { + onFail.run(); + } + } else { + if (onComplete != null) { + onComplete.run(); + } + } + } + + final String toastMessage = message; + parent.runOnUiThread(() -> Toast.makeText(parent, toastMessage, Toast.LENGTH_LONG).show()); + } + }).start(); + + } + + public static void doQuit(final Activity parent, + final ComputerDetails computer, + final NvApp app, + final ComputerManagerService.ComputerManagerBinder managerBinder, + final Runnable onComplete + ) { + try { + NvHTTP httpConn = new NvHTTP( + ServerHelper.getCurrentAddressFromComputer(computer), + computer.httpsPort, + managerBinder.getUniqueId(), + computer.serverCert, + PlatformBinding.getCryptoProvider(parent) + ); + doQuit( + parent, + httpConn, + app.getAppName(), + onComplete, + null + ); + } catch (Exception e) { + e.printStackTrace(); + + final String toastMessage = e.getMessage(); + parent.runOnUiThread(() -> Toast.makeText(parent, toastMessage, Toast.LENGTH_LONG).show()); + } + } +} diff --git a/app/src/main/java/com/limelight/utils/ShortcutHelper.java b/app/src/main/java/com/limelight/utils/ShortcutHelper.java old mode 100644 new mode 100755 index 759ac9303b..6c2f095467 --- a/app/src/main/java/com/limelight/utils/ShortcutHelper.java +++ b/app/src/main/java/com/limelight/utils/ShortcutHelper.java @@ -1,209 +1,209 @@ -package com.limelight.utils; - -import android.annotation.TargetApi; -import android.app.Activity; -import android.content.pm.ShortcutInfo; -import android.content.pm.ShortcutManager; -import android.graphics.Bitmap; -import android.graphics.drawable.Icon; -import android.os.Build; - -import com.limelight.R; -import com.limelight.nvstream.http.ComputerDetails; -import com.limelight.nvstream.http.NvApp; - -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; - -public class ShortcutHelper { - - private final ShortcutManager sm; - private final Activity context; - private final TvChannelHelper tvChannelHelper; - - public ShortcutHelper(Activity context) { - this.context = context; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { - sm = context.getSystemService(ShortcutManager.class); - } - else { - sm = null; - } - this.tvChannelHelper = new TvChannelHelper(context); - } - - @TargetApi(Build.VERSION_CODES.N_MR1) - private void reapShortcutsForDynamicAdd() { - List dynamicShortcuts = sm.getDynamicShortcuts(); - while (!dynamicShortcuts.isEmpty() && dynamicShortcuts.size() >= sm.getMaxShortcutCountPerActivity()) { - ShortcutInfo maxRankShortcut = dynamicShortcuts.get(0); - for (ShortcutInfo scut : dynamicShortcuts) { - if (maxRankShortcut.getRank() < scut.getRank()) { - maxRankShortcut = scut; - } - } - sm.removeDynamicShortcuts(Collections.singletonList(maxRankShortcut.getId())); - } - } - - @TargetApi(Build.VERSION_CODES.N_MR1) - private List getAllShortcuts() { - LinkedList list = new LinkedList<>(); - list.addAll(sm.getDynamicShortcuts()); - list.addAll(sm.getPinnedShortcuts()); - return list; - } - - @TargetApi(Build.VERSION_CODES.N_MR1) - private ShortcutInfo getInfoForId(String id) { - List shortcuts = getAllShortcuts(); - - for (ShortcutInfo info : shortcuts) { - if (info.getId().equals(id)) { - return info; - } - } - - return null; - } - - @TargetApi(Build.VERSION_CODES.N_MR1) - private boolean isExistingDynamicShortcut(String id) { - for (ShortcutInfo si : sm.getDynamicShortcuts()) { - if (si.getId().equals(id)) { - return true; - } - } - - return false; - } - - public void reportComputerShortcutUsed(ComputerDetails computer) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { - if (getInfoForId(computer.uuid) != null) { - sm.reportShortcutUsed(computer.uuid); - } - } - } - - public void reportGameLaunched(ComputerDetails computer, NvApp app) { - tvChannelHelper.createTvChannel(computer); - tvChannelHelper.addGameToChannel(computer, app); - } - - public void createAppViewShortcut(ComputerDetails computer, boolean forceAdd, boolean newlyPaired) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { - ShortcutInfo sinfo = new ShortcutInfo.Builder(context, computer.uuid) - .setIntent(ServerHelper.createPcShortcutIntent(context, computer)) - .setShortLabel(computer.name) - .setLongLabel(computer.name) - .setIcon(Icon.createWithResource(context, R.mipmap.ic_pc_scut)) - .build(); - - ShortcutInfo existingSinfo = getInfoForId(computer.uuid); - if (existingSinfo != null) { - // Update in place - sm.updateShortcuts(Collections.singletonList(sinfo)); - sm.enableShortcuts(Collections.singletonList(computer.uuid)); - } - - // Reap shortcuts to make space for this if it's new - // NOTE: This CAN'T be an else on the above if, because it's - // possible that we have an existing shortcut but it's not a dynamic one. - if (!isExistingDynamicShortcut(computer.uuid)) { - // To avoid a random carousel of shortcuts popping in and out based on polling status, - // we only add shortcuts if it's not at the limit or the user made a conscious action - // to interact with this PC. - - if (forceAdd) { - // This should free an entry for us to add one below - reapShortcutsForDynamicAdd(); - } - - // We still need to check the maximum shortcut count even after reaping, - // because there's a possibility that it could be zero. - if (sm.getDynamicShortcuts().size() < sm.getMaxShortcutCountPerActivity()) { - // Add a shortcut if there is room - sm.addDynamicShortcuts(Collections.singletonList(sinfo)); - } - } - } - - if (newlyPaired) { - // Avoid hammering the channel API for each computer poll because it will throttle us - tvChannelHelper.createTvChannel(computer); - tvChannelHelper.requestChannelOnHomeScreen(computer); - } - } - - public void createAppViewShortcutForOnlineHost(ComputerDetails details) { - createAppViewShortcut(details, false, false); - } - - private String getShortcutIdForGame(ComputerDetails computer, NvApp app) { - return computer.uuid + app.getAppId(); - } - - @TargetApi(Build.VERSION_CODES.O) - public boolean createPinnedGameShortcut(ComputerDetails computer, NvApp app, Bitmap iconBits) { - if (sm.isRequestPinShortcutSupported()) { - Icon appIcon; - - if (iconBits != null) { - appIcon = Icon.createWithAdaptiveBitmap(iconBits); - } else { - appIcon = Icon.createWithResource(context, R.mipmap.ic_pc_scut); - } - - ShortcutInfo sInfo = new ShortcutInfo.Builder(context, getShortcutIdForGame(computer, app)) - .setIntent(ServerHelper.createAppShortcutIntent(context, computer, app)) - .setShortLabel(app.getAppName() + " (" + computer.name + ")") - .setIcon(appIcon) - .build(); - - return sm.requestPinShortcut(sInfo, null); - } else { - return false; - } - } - - public void disableComputerShortcut(ComputerDetails computer, CharSequence reason) { - tvChannelHelper.deleteChannel(computer); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { - // Delete the computer shortcut itself - if (getInfoForId(computer.uuid) != null) { - sm.disableShortcuts(Collections.singletonList(computer.uuid), reason); - } - - // Delete all associated app shortcuts too - List shortcuts = getAllShortcuts(); - LinkedList appShortcutIds = new LinkedList<>(); - for (ShortcutInfo info : shortcuts) { - if (info.getId().startsWith(computer.uuid)) { - appShortcutIds.add(info.getId()); - } - } - sm.disableShortcuts(appShortcutIds, reason); - } - } - - public void disableAppShortcut(ComputerDetails computer, NvApp app, CharSequence reason) { - tvChannelHelper.deleteProgram(computer, app); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { - String id = getShortcutIdForGame(computer, app); - if (getInfoForId(id) != null) { - sm.disableShortcuts(Collections.singletonList(id), reason); - } - } - } - - public void enableAppShortcut(ComputerDetails computer, NvApp app) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { - String id = getShortcutIdForGame(computer, app); - if (getInfoForId(id) != null) { - sm.enableShortcuts(Collections.singletonList(id)); - } - } - } -} +package com.limelight.utils; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.graphics.Bitmap; +import android.graphics.drawable.Icon; +import android.os.Build; + +import com.limelight.R; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.NvApp; + +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +public class ShortcutHelper { + + private final ShortcutManager sm; + private final Activity context; + private final TvChannelHelper tvChannelHelper; + + public ShortcutHelper(Activity context) { + this.context = context; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + sm = context.getSystemService(ShortcutManager.class); + } + else { + sm = null; + } + this.tvChannelHelper = new TvChannelHelper(context); + } + + @TargetApi(Build.VERSION_CODES.N_MR1) + private void reapShortcutsForDynamicAdd() { + List dynamicShortcuts = sm.getDynamicShortcuts(); + while (!dynamicShortcuts.isEmpty() && dynamicShortcuts.size() >= sm.getMaxShortcutCountPerActivity()) { + ShortcutInfo maxRankShortcut = dynamicShortcuts.get(0); + for (ShortcutInfo scut : dynamicShortcuts) { + if (maxRankShortcut.getRank() < scut.getRank()) { + maxRankShortcut = scut; + } + } + sm.removeDynamicShortcuts(Collections.singletonList(maxRankShortcut.getId())); + } + } + + @TargetApi(Build.VERSION_CODES.N_MR1) + private List getAllShortcuts() { + LinkedList list = new LinkedList<>(); + list.addAll(sm.getDynamicShortcuts()); + list.addAll(sm.getPinnedShortcuts()); + return list; + } + + @TargetApi(Build.VERSION_CODES.N_MR1) + private ShortcutInfo getInfoForId(String id) { + List shortcuts = getAllShortcuts(); + + for (ShortcutInfo info : shortcuts) { + if (info.getId().equals(id)) { + return info; + } + } + + return null; + } + + @TargetApi(Build.VERSION_CODES.N_MR1) + private boolean isExistingDynamicShortcut(String id) { + for (ShortcutInfo si : sm.getDynamicShortcuts()) { + if (si.getId().equals(id)) { + return true; + } + } + + return false; + } + + public void reportComputerShortcutUsed(ComputerDetails computer) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + if (getInfoForId(computer.uuid) != null) { + sm.reportShortcutUsed(computer.uuid); + } + } + } + + public void reportGameLaunched(ComputerDetails computer, NvApp app) { + tvChannelHelper.createTvChannel(computer); + tvChannelHelper.addGameToChannel(computer, app); + } + + public void createAppViewShortcut(ComputerDetails computer, boolean forceAdd, boolean newlyPaired) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + ShortcutInfo sinfo = new ShortcutInfo.Builder(context, computer.uuid) + .setIntent(ServerHelper.createPcShortcutIntent(context, computer)) + .setShortLabel(computer.name) + .setLongLabel(computer.name) + .setIcon(Icon.createWithResource(context, R.mipmap.ic_pc_scut)) + .build(); + + ShortcutInfo existingSinfo = getInfoForId(computer.uuid); + if (existingSinfo != null) { + // Update in place + sm.updateShortcuts(Collections.singletonList(sinfo)); + sm.enableShortcuts(Collections.singletonList(computer.uuid)); + } + + // Reap shortcuts to make space for this if it's new + // NOTE: This CAN'T be an else on the above if, because it's + // possible that we have an existing shortcut but it's not a dynamic one. + if (!isExistingDynamicShortcut(computer.uuid)) { + // To avoid a random carousel of shortcuts popping in and out based on polling status, + // we only add shortcuts if it's not at the limit or the user made a conscious action + // to interact with this PC. + + if (forceAdd) { + // This should free an entry for us to add one below + reapShortcutsForDynamicAdd(); + } + + // We still need to check the maximum shortcut count even after reaping, + // because there's a possibility that it could be zero. + if (sm.getDynamicShortcuts().size() < sm.getMaxShortcutCountPerActivity()) { + // Add a shortcut if there is room + sm.addDynamicShortcuts(Collections.singletonList(sinfo)); + } + } + } + + if (newlyPaired) { + // Avoid hammering the channel API for each computer poll because it will throttle us + tvChannelHelper.createTvChannel(computer); + tvChannelHelper.requestChannelOnHomeScreen(computer); + } + } + + public void createAppViewShortcutForOnlineHost(ComputerDetails details) { + createAppViewShortcut(details, false, false); + } + + private String getShortcutIdForGame(ComputerDetails computer, NvApp app) { + return computer.uuid + app.getAppId(); + } + + @TargetApi(Build.VERSION_CODES.O) + public boolean createPinnedGameShortcut(ComputerDetails computer, NvApp app, Bitmap iconBits) { + if (sm.isRequestPinShortcutSupported()) { + Icon appIcon; + + if (iconBits != null) { + appIcon = Icon.createWithAdaptiveBitmap(iconBits); + } else { + appIcon = Icon.createWithResource(context, R.mipmap.ic_pc_scut); + } + + ShortcutInfo sInfo = new ShortcutInfo.Builder(context, getShortcutIdForGame(computer, app)) + .setIntent(ServerHelper.createAppShortcutIntent(context, computer, app)) + .setShortLabel(app.getAppName() + " (" + computer.name + ")") + .setIcon(appIcon) + .build(); + + return sm.requestPinShortcut(sInfo, null); + } else { + return false; + } + } + + public void disableComputerShortcut(ComputerDetails computer, CharSequence reason) { + tvChannelHelper.deleteChannel(computer); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + // Delete the computer shortcut itself + if (getInfoForId(computer.uuid) != null) { + sm.disableShortcuts(Collections.singletonList(computer.uuid), reason); + } + + // Delete all associated app shortcuts too + List shortcuts = getAllShortcuts(); + LinkedList appShortcutIds = new LinkedList<>(); + for (ShortcutInfo info : shortcuts) { + if (info.getId().startsWith(computer.uuid)) { + appShortcutIds.add(info.getId()); + } + } + sm.disableShortcuts(appShortcutIds, reason); + } + } + + public void disableAppShortcut(ComputerDetails computer, NvApp app, CharSequence reason) { + tvChannelHelper.deleteProgram(computer, app); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + String id = getShortcutIdForGame(computer, app); + if (getInfoForId(id) != null) { + sm.disableShortcuts(Collections.singletonList(id), reason); + } + } + } + + public void enableAppShortcut(ComputerDetails computer, NvApp app) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + String id = getShortcutIdForGame(computer, app); + if (getInfoForId(id) != null) { + sm.enableShortcuts(Collections.singletonList(id)); + } + } + } +} diff --git a/app/src/main/java/com/limelight/utils/SpinnerDialog.java b/app/src/main/java/com/limelight/utils/SpinnerDialog.java old mode 100644 new mode 100755 index 01fe269956..c5a7152c41 --- a/app/src/main/java/com/limelight/utils/SpinnerDialog.java +++ b/app/src/main/java/com/limelight/utils/SpinnerDialog.java @@ -1,120 +1,120 @@ -package com.limelight.utils; - -import java.util.ArrayList; -import java.util.Iterator; - -import android.app.Activity; -import android.app.ProgressDialog; -import android.content.DialogInterface; -import android.content.DialogInterface.OnCancelListener; - -public class SpinnerDialog implements Runnable,OnCancelListener { - private final String title; - private final String message; - private final Activity activity; - private ProgressDialog progress; - private final boolean finish; - - private static final ArrayList rundownDialogs = new ArrayList<>(); - - private SpinnerDialog(Activity activity, String title, String message, boolean finish) - { - this.activity = activity; - this.title = title; - this.message = message; - this.progress = null; - this.finish = finish; - } - - public static SpinnerDialog displayDialog(Activity activity, String title, String message, boolean finish) - { - SpinnerDialog spinner = new SpinnerDialog(activity, title, message, finish); - activity.runOnUiThread(spinner); - return spinner; - } - - public static void closeDialogs(Activity activity) - { - synchronized (rundownDialogs) { - Iterator i = rundownDialogs.iterator(); - while (i.hasNext()) { - SpinnerDialog dialog = i.next(); - if (dialog.activity == activity) { - i.remove(); - if (dialog.progress.isShowing()) { - dialog.progress.dismiss(); - } - } - } - } - } - - public void dismiss() - { - // Running again with progress != null will destroy it - activity.runOnUiThread(this); - } - - public void setMessage(final String message) - { - activity.runOnUiThread(new Runnable() { - @Override - public void run() { - progress.setMessage(message); - } - }); - } - - @Override - public void run() { - - // If we're dying, don't bother doing anything - if (activity.isFinishing()) { - return; - } - - if (progress == null) - { - progress = new ProgressDialog(activity); - - progress.setTitle(title); - progress.setMessage(message); - progress.setProgressStyle(ProgressDialog.STYLE_SPINNER); - progress.setOnCancelListener(this); - - // If we want to finish the activity when this is killed, make it cancellable - if (finish) - { - progress.setCancelable(true); - progress.setCanceledOnTouchOutside(false); - } - else - { - progress.setCancelable(false); - } - - synchronized (rundownDialogs) { - rundownDialogs.add(this); - progress.show(); - } - } - else - { - synchronized (rundownDialogs) { - if (rundownDialogs.remove(this) && progress.isShowing()) { - progress.dismiss(); - } - } - } - } - - @Override - public void onCancel(DialogInterface dialog) { - synchronized (rundownDialogs) { - rundownDialogs.remove(this); - } - - // This will only be called if finish was true, so we don't need to check again - activity.finish(); - } -} +package com.limelight.utils; + +import java.util.ArrayList; +import java.util.Iterator; + +import android.app.Activity; +import android.app.ProgressDialog; +import android.content.DialogInterface; +import android.content.DialogInterface.OnCancelListener; + +public class SpinnerDialog implements Runnable,OnCancelListener { + private final String title; + private final String message; + private final Activity activity; + private ProgressDialog progress; + private final boolean finish; + + private static final ArrayList rundownDialogs = new ArrayList<>(); + + private SpinnerDialog(Activity activity, String title, String message, boolean finish) + { + this.activity = activity; + this.title = title; + this.message = message; + this.progress = null; + this.finish = finish; + } + + public static SpinnerDialog displayDialog(Activity activity, String title, String message, boolean finish) + { + SpinnerDialog spinner = new SpinnerDialog(activity, title, message, finish); + activity.runOnUiThread(spinner); + return spinner; + } + + public static void closeDialogs(Activity activity) + { + synchronized (rundownDialogs) { + Iterator i = rundownDialogs.iterator(); + while (i.hasNext()) { + SpinnerDialog dialog = i.next(); + if (dialog.activity == activity) { + i.remove(); + if (dialog.progress.isShowing()) { + dialog.progress.dismiss(); + } + } + } + } + } + + public void dismiss() + { + // Running again with progress != null will destroy it + activity.runOnUiThread(this); + } + + public void setMessage(final String message) + { + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + progress.setMessage(message); + } + }); + } + + @Override + public void run() { + + // If we're dying, don't bother doing anything + if (activity.isFinishing()) { + return; + } + + if (progress == null) + { + progress = new ProgressDialog(activity); + + progress.setTitle(title); + progress.setMessage(message); + progress.setProgressStyle(ProgressDialog.STYLE_SPINNER); + progress.setOnCancelListener(this); + + // If we want to finish the activity when this is killed, make it cancellable + if (finish) + { + progress.setCancelable(true); + progress.setCanceledOnTouchOutside(false); + } + else + { + progress.setCancelable(false); + } + + synchronized (rundownDialogs) { + rundownDialogs.add(this); + progress.show(); + } + } + else + { + synchronized (rundownDialogs) { + if (rundownDialogs.remove(this) && progress.isShowing()) { + progress.dismiss(); + } + } + } + } + + @Override + public void onCancel(DialogInterface dialog) { + synchronized (rundownDialogs) { + rundownDialogs.remove(this); + } + + // This will only be called if finish was true, so we don't need to check again + activity.finish(); + } +} diff --git a/app/src/main/java/com/limelight/utils/TrafficStatsHelper.java b/app/src/main/java/com/limelight/utils/TrafficStatsHelper.java new file mode 100755 index 0000000000..38cb6c0888 --- /dev/null +++ b/app/src/main/java/com/limelight/utils/TrafficStatsHelper.java @@ -0,0 +1,37 @@ +package com.limelight.utils; + +import android.net.TrafficStats; + +public class TrafficStatsHelper { + public static long getAllRxBytes() { + return TrafficStats.getTotalRxBytes(); + } + + public static long getAllTxBytes() { + return TrafficStats.getTotalTxBytes(); + } + + public static long getAllRxBytesMobile() { + return TrafficStats.getMobileRxBytes(); + } + + public static long getAllTxBytesMobile() { + return TrafficStats.getMobileTxBytes(); + } + + public static long getAllRxBytesWifi() { + return TrafficStats.getTotalRxBytes() - TrafficStats.getMobileRxBytes(); + } + + public static long getAllTxBytesWifi() { + return TrafficStats.getTotalTxBytes() - TrafficStats.getMobileTxBytes(); + } + + public static long getPackageRxBytes(int uid) { + return TrafficStats.getUidRxBytes(uid); + } + + public static long getPackageTxBytes(int uid) { + return TrafficStats.getUidTxBytes(uid); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/limelight/utils/TvChannelHelper.java b/app/src/main/java/com/limelight/utils/TvChannelHelper.java old mode 100644 new mode 100755 index 0a144b20d7..5e922b17c9 --- a/app/src/main/java/com/limelight/utils/TvChannelHelper.java +++ b/app/src/main/java/com/limelight/utils/TvChannelHelper.java @@ -1,366 +1,366 @@ -package com.limelight.utils; - -import android.annotation.TargetApi; -import android.app.Activity; -import android.content.ActivityNotFoundException; -import android.content.ContentUris; -import android.content.ContentValues; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.database.Cursor; -import android.database.sqlite.SQLiteException; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.drawable.Drawable; -import android.media.tv.TvContract; -import android.net.Uri; -import android.os.Build; - -import com.limelight.LimeLog; -import com.limelight.PosterContentProvider; -import com.limelight.R; -import com.limelight.nvstream.http.ComputerDetails; -import com.limelight.nvstream.http.NvApp; - -import java.io.IOException; -import java.io.OutputStream; - -public class TvChannelHelper { - - private static final int ASPECT_RATIO_MOVIE_POSTER = 5; - private static final int TYPE_GAME = 12; - private static final int INTERNAL_PROVIDER_ID_INDEX = 1; - private static final int PROGRAM_BROWSABLE_INDEX = 2; - private static final int ID_INDEX = 0; - private Activity context; - - public TvChannelHelper(Activity context) { - this.context = context; - } - - void requestChannelOnHomeScreen(ComputerDetails computer) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (!isAndroidTV()) { - return; - } - - Long channelId = getChannelId(computer.uuid); - if (channelId == null) { - return; - } - - Intent intent = new Intent(TvContract.ACTION_REQUEST_CHANNEL_BROWSABLE); - intent.putExtra(TvContract.EXTRA_CHANNEL_ID, getChannelId(computer.uuid)); - try { - context.startActivityForResult(intent, 0); - } catch (Exception ignored) { - // ActivityNotFoundException is the only officially documented - // exception that can result from this call. However some buggy - // devices throw others. - // See https://github.com/moonlight-stream/moonlight-android/issues/1302 - } - } - } - - void createTvChannel(ComputerDetails computer) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (!isAndroidTV()) { - return; - } - - ChannelBuilder builder = new ChannelBuilder() - .setType(TvContract.Channels.TYPE_PREVIEW) - .setDisplayName(computer.name) - .setInternalProviderId(computer.uuid) - .setAppLinkIntent(ServerHelper.createPcShortcutIntent(context, computer)); - - Long channelId = getChannelId(computer.uuid); - if (channelId != null) { - context.getContentResolver().update(TvContract.buildChannelUri(channelId), - builder.toContentValues(), null, null); - return; - } - - Uri channelUri; - - try { - channelUri = context.getContentResolver().insert( - TvContract.Channels.CONTENT_URI, builder.toContentValues()); - } catch (IllegalArgumentException e) { - // This can happen on HarmonyOS devices which report to - // support Leanback APIs, yet don't implement this URI - e.printStackTrace(); - return; - } - - if (channelUri != null) { - long id = ContentUris.parseId(channelUri); - updateChannelIcon(id); - } - } - } - - @TargetApi(Build.VERSION_CODES.O) - private void updateChannelIcon(long channelId) { - Bitmap logo = drawableToBitmap(context.getResources().getDrawable(R.drawable.ic_channel)); - try { - Uri localUri = TvContract.buildChannelLogoUri(channelId); - try (OutputStream outputStream = context.getContentResolver().openOutputStream(localUri)) { - logo.compress(Bitmap.CompressFormat.PNG, 100, outputStream); - outputStream.flush(); - } catch (SQLiteException | IOException e) { - LimeLog.warning("Failed to store the logo to the system content provider."); - e.printStackTrace(); - } - } finally { - logo.recycle(); - } - } - - private Bitmap drawableToBitmap(Drawable drawable) { - int width = context.getResources().getDimensionPixelSize(R.dimen.tv_channel_logo_width); - int height = context.getResources().getDimensionPixelSize(R.dimen.tv_channel_logo_width); - - Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(bitmap); - drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); - drawable.draw(canvas); - return bitmap; - } - - void addGameToChannel(ComputerDetails computer, NvApp app) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (!isAndroidTV()) { - return; - } - - - Long channelId = getChannelId(computer.uuid); - if (channelId == null) { - return; - } - - PreviewProgramBuilder builder = new PreviewProgramBuilder() - .setChannelId(channelId) - .setType(TYPE_GAME) - .setTitle(app.getAppName()) - .setPosterArtAspectRatio(ASPECT_RATIO_MOVIE_POSTER) - .setPosterArtUri(PosterContentProvider.createBoxArtUri(computer.uuid, ""+app.getAppId())) - .setIntent(ServerHelper.createAppShortcutIntent(context, computer, app)) - .setInternalProviderId(""+app.getAppId()) - // Weight should increase each time we run the game - .setWeight((int)((System.currentTimeMillis() - 1500000000000L) / 1000)); - - Long programId = getProgramId(channelId, ""+app.getAppId()); - if (programId != null) { - context.getContentResolver().update(TvContract.buildPreviewProgramUri(programId), - builder.toContentValues(), null, null); - return; - } - - try { - context.getContentResolver().insert(TvContract.PreviewPrograms.CONTENT_URI, - builder.toContentValues()); - } catch (IllegalArgumentException e) { - // This can happen on HarmonyOS devices which report to - // support Leanback APIs, yet don't implement this URI - e.printStackTrace(); - return; - } - - TvContract.requestChannelBrowsable(context, channelId); - } - } - - void deleteChannel(ComputerDetails computer) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (!isAndroidTV()) { - return; - } - - Long channelId = getChannelId(computer.uuid); - if (channelId == null) { - return; - } - - context.getContentResolver().delete(TvContract.buildChannelUri(channelId), null, null); - } - } - - void deleteProgram(ComputerDetails computer, NvApp app) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (!isAndroidTV()) { - return; - } - - Long channelId = getChannelId(computer.uuid); - if (channelId == null) { - return; - } - - - Long programId = getProgramId(channelId, ""+app.getAppId()); - if (programId == null) { - return; - } - - context.getContentResolver().delete(TvContract.buildPreviewProgramUri(programId), null, null); - } - } - - @TargetApi(Build.VERSION_CODES.O) - private Long getChannelId(String computerUuid) { - try (Cursor cursor = context.getContentResolver().query( - TvContract.Channels.CONTENT_URI, - new String[] {TvContract.Channels._ID, TvContract.Channels.COLUMN_INTERNAL_PROVIDER_ID}, - null, - null, - null)) { - if (cursor == null || cursor.getCount() == 0) { - return null; - } - while (cursor.moveToNext()) { - String internalProviderId = cursor.getString(INTERNAL_PROVIDER_ID_INDEX); - if (computerUuid.equals(internalProviderId)) { - return cursor.getLong(ID_INDEX); - } - } - - return null; - } - } - - @TargetApi(Build.VERSION_CODES.O) - private Long getProgramId(long channelId, String appId) { - try (Cursor cursor = context.getContentResolver().query( - TvContract.buildPreviewProgramsUriForChannel(channelId), - new String[] {TvContract.PreviewPrograms._ID, TvContract.PreviewPrograms.COLUMN_INTERNAL_PROVIDER_ID, TvContract.PreviewPrograms.COLUMN_BROWSABLE}, - null, - null, - null)) { - if (cursor == null || cursor.getCount() == 0) { - return null; - } - while (cursor.moveToNext()) { - String internalProviderId = cursor.getString(INTERNAL_PROVIDER_ID_INDEX); - if (appId.equals(internalProviderId)) { - long id = cursor.getLong(ID_INDEX); - int browsable = cursor.getInt(PROGRAM_BROWSABLE_INDEX); - if (browsable != 0) { - return id; - } else { - int countDeleted = context.getContentResolver().delete(TvContract.buildPreviewProgramUri(id), null, null); - if (countDeleted > 0) { - LimeLog.info("Preview program has been deleted"); - } else { - LimeLog.warning("Preview program has not been deleted"); - } - } - } - } - - return null; - } - } - - private static String toValueString(T value) { - return value == null ? null : value.toString(); - } - - private static String toUriString(Intent intent) { - return intent == null ? null : intent.toUri(Intent.URI_INTENT_SCHEME); - } - - @TargetApi(Build.VERSION_CODES.O) - private boolean isAndroidTV() { - return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK); - } - - @TargetApi(Build.VERSION_CODES.O) - private static class PreviewProgramBuilder { - - private ContentValues mValues = new ContentValues(); - - - public PreviewProgramBuilder setChannelId(Long channelId) { - mValues.put(TvContract.PreviewPrograms.COLUMN_CHANNEL_ID, channelId); - return this; - } - - public PreviewProgramBuilder setType(int type) { - mValues.put(TvContract.PreviewPrograms.COLUMN_TYPE, type); - return this; - } - - public PreviewProgramBuilder setTitle(String title) { - mValues.put(TvContract.PreviewPrograms.COLUMN_TITLE, title); - return this; - } - - public PreviewProgramBuilder setPosterArtAspectRatio(int aspectRatio) { - mValues.put(TvContract.PreviewPrograms.COLUMN_POSTER_ART_ASPECT_RATIO, aspectRatio); - return this; - } - - public PreviewProgramBuilder setIntent(Intent intent) { - mValues.put(TvContract.PreviewPrograms.COLUMN_INTENT_URI, toUriString(intent)); - return this; - } - - public PreviewProgramBuilder setIntentUri(Uri uri) { - mValues.put(TvContract.PreviewPrograms.COLUMN_INTENT_URI, toValueString(uri)); - return this; - } - - public PreviewProgramBuilder setInternalProviderId(String id) { - mValues.put(TvContract.PreviewPrograms.COLUMN_INTERNAL_PROVIDER_ID, id); - return this; - } - - public PreviewProgramBuilder setPosterArtUri(Uri uri) { - mValues.put(TvContract.PreviewPrograms.COLUMN_POSTER_ART_URI, toValueString(uri)); - return this; - } - - public PreviewProgramBuilder setWeight(int weight) { - mValues.put(TvContract.PreviewPrograms.COLUMN_WEIGHT, weight); - return this; - } - - public ContentValues toContentValues() { - return new ContentValues(mValues); - } - - } - - @TargetApi(Build.VERSION_CODES.O) - private static class ChannelBuilder { - - private ContentValues mValues = new ContentValues(); - - public ChannelBuilder setType(String type) { - mValues.put(TvContract.Channels.COLUMN_TYPE, type); - return this; - } - - public ChannelBuilder setDisplayName(String displayName) { - mValues.put(TvContract.Channels.COLUMN_DISPLAY_NAME, displayName); - return this; - } - - public ChannelBuilder setInternalProviderId(String internalProviderId) { - mValues.put(TvContract.Channels.COLUMN_INTERNAL_PROVIDER_ID, internalProviderId); - return this; - } - - public ChannelBuilder setAppLinkIntent(Intent intent) { - mValues.put(TvContract.Channels.COLUMN_APP_LINK_INTENT_URI, toUriString(intent)); - return this; - } - - public ContentValues toContentValues() { - return new ContentValues(mValues); - } - - } -} +package com.limelight.utils; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.database.sqlite.SQLiteException; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.media.tv.TvContract; +import android.net.Uri; +import android.os.Build; + +import com.limelight.LimeLog; +import com.limelight.PosterContentProvider; +import com.limelight.R; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.NvApp; + +import java.io.IOException; +import java.io.OutputStream; + +public class TvChannelHelper { + + private static final int ASPECT_RATIO_MOVIE_POSTER = 5; + private static final int TYPE_GAME = 12; + private static final int INTERNAL_PROVIDER_ID_INDEX = 1; + private static final int PROGRAM_BROWSABLE_INDEX = 2; + private static final int ID_INDEX = 0; + private Activity context; + + public TvChannelHelper(Activity context) { + this.context = context; + } + + void requestChannelOnHomeScreen(ComputerDetails computer) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (!isAndroidTV()) { + return; + } + + Long channelId = getChannelId(computer.uuid); + if (channelId == null) { + return; + } + + Intent intent = new Intent(TvContract.ACTION_REQUEST_CHANNEL_BROWSABLE); + intent.putExtra(TvContract.EXTRA_CHANNEL_ID, getChannelId(computer.uuid)); + try { + context.startActivityForResult(intent, 0); + } catch (Exception ignored) { + // ActivityNotFoundException is the only officially documented + // exception that can result from this call. However some buggy + // devices throw others. + // See https://github.com/moonlight-stream/moonlight-android/issues/1302 + } + } + } + + void createTvChannel(ComputerDetails computer) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (!isAndroidTV()) { + return; + } + + ChannelBuilder builder = new ChannelBuilder() + .setType(TvContract.Channels.TYPE_PREVIEW) + .setDisplayName(computer.name) + .setInternalProviderId(computer.uuid) + .setAppLinkIntent(ServerHelper.createPcShortcutIntent(context, computer)); + + Long channelId = getChannelId(computer.uuid); + if (channelId != null) { + context.getContentResolver().update(TvContract.buildChannelUri(channelId), + builder.toContentValues(), null, null); + return; + } + + Uri channelUri; + + try { + channelUri = context.getContentResolver().insert( + TvContract.Channels.CONTENT_URI, builder.toContentValues()); + } catch (IllegalArgumentException e) { + // This can happen on HarmonyOS devices which report to + // support Leanback APIs, yet don't implement this URI + e.printStackTrace(); + return; + } + + if (channelUri != null) { + long id = ContentUris.parseId(channelUri); + updateChannelIcon(id); + } + } + } + + @TargetApi(Build.VERSION_CODES.O) + private void updateChannelIcon(long channelId) { + Bitmap logo = drawableToBitmap(context.getResources().getDrawable(R.drawable.ic_channel)); + try { + Uri localUri = TvContract.buildChannelLogoUri(channelId); + try (OutputStream outputStream = context.getContentResolver().openOutputStream(localUri)) { + logo.compress(Bitmap.CompressFormat.PNG, 100, outputStream); + outputStream.flush(); + } catch (SQLiteException | IOException e) { + LimeLog.warning("Failed to store the logo to the system content provider."); + e.printStackTrace(); + } + } finally { + logo.recycle(); + } + } + + private Bitmap drawableToBitmap(Drawable drawable) { + int width = context.getResources().getDimensionPixelSize(R.dimen.tv_channel_logo_width); + int height = context.getResources().getDimensionPixelSize(R.dimen.tv_channel_logo_width); + + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + drawable.draw(canvas); + return bitmap; + } + + void addGameToChannel(ComputerDetails computer, NvApp app) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (!isAndroidTV()) { + return; + } + + + Long channelId = getChannelId(computer.uuid); + if (channelId == null) { + return; + } + + PreviewProgramBuilder builder = new PreviewProgramBuilder() + .setChannelId(channelId) + .setType(TYPE_GAME) + .setTitle(app.getAppName()) + .setPosterArtAspectRatio(ASPECT_RATIO_MOVIE_POSTER) + .setPosterArtUri(PosterContentProvider.createBoxArtUri(computer.uuid, ""+app.getAppId())) + .setIntent(ServerHelper.createAppShortcutIntent(context, computer, app)) + .setInternalProviderId(""+app.getAppId()) + // Weight should increase each time we run the game + .setWeight((int)((System.currentTimeMillis() - 1500000000000L) / 1000)); + + Long programId = getProgramId(channelId, ""+app.getAppId()); + if (programId != null) { + context.getContentResolver().update(TvContract.buildPreviewProgramUri(programId), + builder.toContentValues(), null, null); + return; + } + + try { + context.getContentResolver().insert(TvContract.PreviewPrograms.CONTENT_URI, + builder.toContentValues()); + } catch (IllegalArgumentException e) { + // This can happen on HarmonyOS devices which report to + // support Leanback APIs, yet don't implement this URI + e.printStackTrace(); + return; + } + + TvContract.requestChannelBrowsable(context, channelId); + } + } + + void deleteChannel(ComputerDetails computer) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (!isAndroidTV()) { + return; + } + + Long channelId = getChannelId(computer.uuid); + if (channelId == null) { + return; + } + + context.getContentResolver().delete(TvContract.buildChannelUri(channelId), null, null); + } + } + + void deleteProgram(ComputerDetails computer, NvApp app) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (!isAndroidTV()) { + return; + } + + Long channelId = getChannelId(computer.uuid); + if (channelId == null) { + return; + } + + + Long programId = getProgramId(channelId, ""+app.getAppId()); + if (programId == null) { + return; + } + + context.getContentResolver().delete(TvContract.buildPreviewProgramUri(programId), null, null); + } + } + + @TargetApi(Build.VERSION_CODES.O) + private Long getChannelId(String computerUuid) { + try (Cursor cursor = context.getContentResolver().query( + TvContract.Channels.CONTENT_URI, + new String[] {TvContract.Channels._ID, TvContract.Channels.COLUMN_INTERNAL_PROVIDER_ID}, + null, + null, + null)) { + if (cursor == null || cursor.getCount() == 0) { + return null; + } + while (cursor.moveToNext()) { + String internalProviderId = cursor.getString(INTERNAL_PROVIDER_ID_INDEX); + if (computerUuid.equals(internalProviderId)) { + return cursor.getLong(ID_INDEX); + } + } + + return null; + } + } + + @TargetApi(Build.VERSION_CODES.O) + private Long getProgramId(long channelId, String appId) { + try (Cursor cursor = context.getContentResolver().query( + TvContract.buildPreviewProgramsUriForChannel(channelId), + new String[] {TvContract.PreviewPrograms._ID, TvContract.PreviewPrograms.COLUMN_INTERNAL_PROVIDER_ID, TvContract.PreviewPrograms.COLUMN_BROWSABLE}, + null, + null, + null)) { + if (cursor == null || cursor.getCount() == 0) { + return null; + } + while (cursor.moveToNext()) { + String internalProviderId = cursor.getString(INTERNAL_PROVIDER_ID_INDEX); + if (appId.equals(internalProviderId)) { + long id = cursor.getLong(ID_INDEX); + int browsable = cursor.getInt(PROGRAM_BROWSABLE_INDEX); + if (browsable != 0) { + return id; + } else { + int countDeleted = context.getContentResolver().delete(TvContract.buildPreviewProgramUri(id), null, null); + if (countDeleted > 0) { + LimeLog.info("Preview program has been deleted"); + } else { + LimeLog.warning("Preview program has not been deleted"); + } + } + } + } + + return null; + } + } + + private static String toValueString(T value) { + return value == null ? null : value.toString(); + } + + private static String toUriString(Intent intent) { + return intent == null ? null : intent.toUri(Intent.URI_INTENT_SCHEME); + } + + @TargetApi(Build.VERSION_CODES.O) + private boolean isAndroidTV() { + return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK); + } + + @TargetApi(Build.VERSION_CODES.O) + private static class PreviewProgramBuilder { + + private ContentValues mValues = new ContentValues(); + + + public PreviewProgramBuilder setChannelId(Long channelId) { + mValues.put(TvContract.PreviewPrograms.COLUMN_CHANNEL_ID, channelId); + return this; + } + + public PreviewProgramBuilder setType(int type) { + mValues.put(TvContract.PreviewPrograms.COLUMN_TYPE, type); + return this; + } + + public PreviewProgramBuilder setTitle(String title) { + mValues.put(TvContract.PreviewPrograms.COLUMN_TITLE, title); + return this; + } + + public PreviewProgramBuilder setPosterArtAspectRatio(int aspectRatio) { + mValues.put(TvContract.PreviewPrograms.COLUMN_POSTER_ART_ASPECT_RATIO, aspectRatio); + return this; + } + + public PreviewProgramBuilder setIntent(Intent intent) { + mValues.put(TvContract.PreviewPrograms.COLUMN_INTENT_URI, toUriString(intent)); + return this; + } + + public PreviewProgramBuilder setIntentUri(Uri uri) { + mValues.put(TvContract.PreviewPrograms.COLUMN_INTENT_URI, toValueString(uri)); + return this; + } + + public PreviewProgramBuilder setInternalProviderId(String id) { + mValues.put(TvContract.PreviewPrograms.COLUMN_INTERNAL_PROVIDER_ID, id); + return this; + } + + public PreviewProgramBuilder setPosterArtUri(Uri uri) { + mValues.put(TvContract.PreviewPrograms.COLUMN_POSTER_ART_URI, toValueString(uri)); + return this; + } + + public PreviewProgramBuilder setWeight(int weight) { + mValues.put(TvContract.PreviewPrograms.COLUMN_WEIGHT, weight); + return this; + } + + public ContentValues toContentValues() { + return new ContentValues(mValues); + } + + } + + @TargetApi(Build.VERSION_CODES.O) + private static class ChannelBuilder { + + private ContentValues mValues = new ContentValues(); + + public ChannelBuilder setType(String type) { + mValues.put(TvContract.Channels.COLUMN_TYPE, type); + return this; + } + + public ChannelBuilder setDisplayName(String displayName) { + mValues.put(TvContract.Channels.COLUMN_DISPLAY_NAME, displayName); + return this; + } + + public ChannelBuilder setInternalProviderId(String internalProviderId) { + mValues.put(TvContract.Channels.COLUMN_INTERNAL_PROVIDER_ID, internalProviderId); + return this; + } + + public ChannelBuilder setAppLinkIntent(Intent intent) { + mValues.put(TvContract.Channels.COLUMN_APP_LINK_INTENT_URI, toUriString(intent)); + return this; + } + + public ContentValues toContentValues() { + return new ContentValues(mValues); + } + + } +} diff --git a/app/src/main/java/com/limelight/utils/UiHelper.java b/app/src/main/java/com/limelight/utils/UiHelper.java old mode 100644 new mode 100755 index f6da70aa25..8c796e5293 --- a/app/src/main/java/com/limelight/utils/UiHelper.java +++ b/app/src/main/java/com/limelight/utils/UiHelper.java @@ -1,261 +1,295 @@ -package com.limelight.utils; - -import android.app.Activity; -import android.app.AlertDialog; -import android.app.GameManager; -import android.app.GameState; -import android.app.LocaleManager; -import android.app.UiModeManager; -import android.content.Context; -import android.content.DialogInterface; -import android.content.SharedPreferences; -import android.content.res.Configuration; -import android.graphics.Insets; -import android.os.Build; -import android.os.LocaleList; -import android.view.View; -import android.view.WindowInsets; -import android.view.WindowManager; - -import com.limelight.Game; -import com.limelight.R; -import com.limelight.nvstream.http.ComputerDetails; -import com.limelight.preferences.PreferenceConfiguration; - -import java.util.Locale; - -public class UiHelper { - - private static final int TV_VERTICAL_PADDING_DP = 15; - private static final int TV_HORIZONTAL_PADDING_DP = 15; - - private static void setGameModeStatus(Context context, boolean streaming, boolean interruptible) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - GameManager gameManager = context.getSystemService(GameManager.class); - - if (streaming) { - gameManager.setGameState(new GameState(false, interruptible ? GameState.MODE_GAMEPLAY_INTERRUPTIBLE : GameState.MODE_GAMEPLAY_UNINTERRUPTIBLE)); - } - else { - gameManager.setGameState(new GameState(false, GameState.MODE_NONE)); - } - } - } - - public static void notifyStreamConnecting(Context context) { - setGameModeStatus(context, true, true); - } - - public static void notifyStreamConnected(Context context) { - setGameModeStatus(context, true, false); - } - - public static void notifyStreamEnteringPiP(Context context) { - setGameModeStatus(context, true, true); - } - - public static void notifyStreamExitingPiP(Context context) { - setGameModeStatus(context, true, false); - } - - public static void notifyStreamEnded(Context context) { - setGameModeStatus(context, false, false); - } - - public static void setLocale(Activity activity) - { - String locale = PreferenceConfiguration.readPreferences(activity).language; - if (!locale.equals(PreferenceConfiguration.DEFAULT_LANGUAGE)) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - // On Android 13, migrate this non-default language setting into the OS native API - LocaleManager localeManager = activity.getSystemService(LocaleManager.class); - localeManager.setApplicationLocales(LocaleList.forLanguageTags(locale)); - PreferenceConfiguration.completeLanguagePreferenceMigration(activity); - } - else { - Configuration config = new Configuration(activity.getResources().getConfiguration()); - - // Some locales include both language and country which must be separated - // before calling the Locale constructor. - if (locale.contains("-")) - { - config.locale = new Locale(locale.substring(0, locale.indexOf('-')), - locale.substring(locale.indexOf('-') + 1)); - } - else - { - config.locale = new Locale(locale); - } - - activity.getResources().updateConfiguration(config, activity.getResources().getDisplayMetrics()); - } - } - } - - public static void applyStatusBarPadding(View view) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - // This applies the padding that we omitted in notifyNewRootView() on Q - view.setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() { - @Override - public WindowInsets onApplyWindowInsets(View view, WindowInsets windowInsets) { - view.setPadding(view.getPaddingLeft(), - view.getPaddingTop(), - view.getPaddingRight(), - windowInsets.getTappableElementInsets().bottom); - return windowInsets; - } - }); - view.requestApplyInsets(); - } - } - - public static void notifyNewRootView(final Activity activity) - { - View rootView = activity.findViewById(android.R.id.content); - UiModeManager modeMgr = (UiModeManager) activity.getSystemService(Context.UI_MODE_SERVICE); - - // Set GameState.MODE_NONE initially for all activities - setGameModeStatus(activity, false, false); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - // Allow this non-streaming activity to layout under notches. - // - // We should NOT do this for the Game activity unless - // the user specifically opts in, because it can obscure - // parts of the streaming surface. - activity.getWindow().getAttributes().layoutInDisplayCutoutMode = - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; - } - - if (modeMgr.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) { - // Increase view padding on TVs - float scale = activity.getResources().getDisplayMetrics().density; - int verticalPaddingPixels = (int) (TV_VERTICAL_PADDING_DP*scale + 0.5f); - int horizontalPaddingPixels = (int) (TV_HORIZONTAL_PADDING_DP*scale + 0.5f); - - rootView.setPadding(horizontalPaddingPixels, verticalPaddingPixels, - horizontalPaddingPixels, verticalPaddingPixels); - } - else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - // Draw under the status bar on Android Q devices - - // Using getDecorView() here breaks the translucent status/navigation bar when gestures are disabled - activity.findViewById(android.R.id.content).setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() { - @Override - public WindowInsets onApplyWindowInsets(View view, WindowInsets windowInsets) { - // Use the tappable insets so we can draw under the status bar in gesture mode - Insets tappableInsets = windowInsets.getTappableElementInsets(); - view.setPadding(tappableInsets.left, - tappableInsets.top, - tappableInsets.right, - 0); - - // Show a translucent navigation bar if we can't tap there - if (tappableInsets.bottom != 0) { - activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION); - } - else { - activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION); - } - - return windowInsets; - } - }); - - activity.getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION); - } - } - - public static void showDecoderCrashDialog(Activity activity) { - final SharedPreferences prefs = activity.getSharedPreferences("DecoderTombstone", 0); - final int crashCount = prefs.getInt("CrashCount", 0); - int lastNotifiedCrashCount = prefs.getInt("LastNotifiedCrashCount", 0); - - // Remember the last crash count we notified at, so we don't - // display the crash dialog every time the app is started until - // they stream again - if (crashCount != 0 && crashCount != lastNotifiedCrashCount) { - if (crashCount % 3 == 0) { - // At 3 consecutive crashes, we'll forcefully reset their settings - PreferenceConfiguration.resetStreamingSettings(activity); - Dialog.displayDialog(activity, - activity.getResources().getString(R.string.title_decoding_reset), - activity.getResources().getString(R.string.message_decoding_reset), - new Runnable() { - @Override - public void run() { - // Mark notification as acknowledged on dismissal - prefs.edit().putInt("LastNotifiedCrashCount", crashCount).apply(); - } - }); - } - else { - Dialog.displayDialog(activity, - activity.getResources().getString(R.string.title_decoding_error), - activity.getResources().getString(R.string.message_decoding_error), - new Runnable() { - @Override - public void run() { - // Mark notification as acknowledged on dismissal - prefs.edit().putInt("LastNotifiedCrashCount", crashCount).apply(); - } - }); - } - } - } - - public static void displayQuitConfirmationDialog(Activity parent, final Runnable onYes, final Runnable onNo) { - DialogInterface.OnClickListener dialogClickListener = new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - switch (which){ - case DialogInterface.BUTTON_POSITIVE: - if (onYes != null) { - onYes.run(); - } - break; - - case DialogInterface.BUTTON_NEGATIVE: - if (onNo != null) { - onNo.run(); - } - break; - } - } - }; - - AlertDialog.Builder builder = new AlertDialog.Builder(parent); - builder.setMessage(parent.getResources().getString(R.string.applist_quit_confirmation)) - .setPositiveButton(parent.getResources().getString(R.string.yes), dialogClickListener) - .setNegativeButton(parent.getResources().getString(R.string.no), dialogClickListener) - .show(); - } - - public static void displayDeletePcConfirmationDialog(Activity parent, ComputerDetails computer, final Runnable onYes, final Runnable onNo) { - DialogInterface.OnClickListener dialogClickListener = new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - switch (which){ - case DialogInterface.BUTTON_POSITIVE: - if (onYes != null) { - onYes.run(); - } - break; - - case DialogInterface.BUTTON_NEGATIVE: - if (onNo != null) { - onNo.run(); - } - break; - } - } - }; - - AlertDialog.Builder builder = new AlertDialog.Builder(parent); - builder.setMessage(parent.getResources().getString(R.string.delete_pc_msg)) - .setTitle(computer.name) - .setPositiveButton(parent.getResources().getString(R.string.yes), dialogClickListener) - .setNegativeButton(parent.getResources().getString(R.string.no), dialogClickListener) - .show(); - } -} +package com.limelight.utils; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.GameManager; +import android.app.GameState; +import android.app.LocaleManager; +import android.app.UiModeManager; +import android.content.Context; +import android.content.DialogInterface; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.graphics.Insets; +import android.os.Build; +import android.os.LocaleList; +import android.text.Html; +import android.text.method.LinkMovementMethod; +import android.util.TypedValue; +import android.view.View; +import android.view.WindowInsets; +import android.view.WindowManager; +import android.widget.TextView; +import android.widget.Toast; + +import com.limelight.AppView; +import com.limelight.Game; +import com.limelight.LimeLog; +import com.limelight.R; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.preferences.PreferenceConfiguration; + +import java.util.Locale; + +public class UiHelper { + + private static final int TV_VERTICAL_PADDING_DP = 15; + private static final int TV_HORIZONTAL_PADDING_DP = 15; + + private static void setGameModeStatus(Context context, boolean streaming, boolean interruptible) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + GameManager gameManager = context.getSystemService(GameManager.class); + + if (gameManager == null) { + LimeLog.warning("GameManager is null, maybe your system does not support it?"); + return; + } + + if (streaming) { + gameManager.setGameState(new GameState(false, interruptible ? GameState.MODE_GAMEPLAY_INTERRUPTIBLE : GameState.MODE_GAMEPLAY_UNINTERRUPTIBLE)); + } + else { + gameManager.setGameState(new GameState(false, GameState.MODE_NONE)); + } + } + } + + public static void notifyStreamConnecting(Context context) { + setGameModeStatus(context, true, true); + } + + public static void notifyStreamConnected(Context context) { + setGameModeStatus(context, true, false); + } + + public static void notifyStreamEnteringPiP(Context context) { + setGameModeStatus(context, true, true); + } + + public static void notifyStreamExitingPiP(Context context) { + setGameModeStatus(context, true, false); + } + + public static void notifyStreamEnded(Context context) { + setGameModeStatus(context, false, false); + } + + public static void setLocale(Activity activity) + { + String locale = PreferenceConfiguration.readPreferences(activity).language; + Configuration config = new Configuration(activity.getResources().getConfiguration()); + if (locale.equals(PreferenceConfiguration.DEFAULT_LANGUAGE)) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // On Android 13, migrate this non-default language setting into the OS native API + LocaleManager localeManager = activity.getSystemService(LocaleManager.class); + LocaleList systemLocales = localeManager.getSystemLocales(); + if (!systemLocales.isEmpty()) { + config.locale = systemLocales.get(0); + } + } + } else { + // We're handling some nasty non-standard devices which cannot set locale using system config correctly + // Some locales include both language and country which must be separated + // before calling the Locale constructor. + if (locale.contains("-")) + { + config.locale = new Locale(locale.substring(0, locale.indexOf('-')), + locale.substring(locale.indexOf('-') + 1)); + } + else + { + config.locale = new Locale(locale); + } + } + + activity.getResources().updateConfiguration(config, activity.getResources().getDisplayMetrics()); + } + + public static void applyStatusBarPadding(View view) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // This applies the padding that we omitted in notifyNewRootView() on Q + view.setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() { + @Override + public WindowInsets onApplyWindowInsets(View view, WindowInsets windowInsets) { + view.setPadding(view.getPaddingLeft(), + view.getPaddingTop(), + view.getPaddingRight(), + windowInsets.getTappableElementInsets().bottom); + return windowInsets; + } + }); + view.requestApplyInsets(); + } + } + + public static void notifyNewRootView(final Activity activity) + { + View rootView = activity.findViewById(android.R.id.content); + UiModeManager modeMgr = (UiModeManager) activity.getSystemService(Context.UI_MODE_SERVICE); + + // Set GameState.MODE_NONE initially for all activities + setGameModeStatus(activity, false, false); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + // Allow this non-streaming activity to layout under notches. + // + // We should NOT do this for the Game activity unless + // the user specifically opts in, because it can obscure + // parts of the streaming surface. + activity.getWindow().getAttributes().layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; + } + + if (modeMgr.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) { + // Increase view padding on TVs + float scale = activity.getResources().getDisplayMetrics().density; + int verticalPaddingPixels = (int) (TV_VERTICAL_PADDING_DP*scale + 0.5f); + int horizontalPaddingPixels = (int) (TV_HORIZONTAL_PADDING_DP*scale + 0.5f); + + rootView.setPadding(horizontalPaddingPixels, verticalPaddingPixels, + horizontalPaddingPixels, verticalPaddingPixels); + } + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Draw under the status bar on Android Q devices + + // Using getDecorView() here breaks the translucent status/navigation bar when gestures are disabled + activity.findViewById(android.R.id.content).setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() { + @Override + public WindowInsets onApplyWindowInsets(View view, WindowInsets windowInsets) { + // Use the tappable insets so we can draw under the status bar in gesture mode + Insets tappableInsets = windowInsets.getTappableElementInsets(); + view.setPadding(tappableInsets.left, + tappableInsets.top, + tappableInsets.right, + 0); + + // Show a translucent navigation bar if we can't tap there + if (tappableInsets.bottom != 0) { + activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION); + } + else { + activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION); + } + + return windowInsets; + } + }); + + activity.getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION); + } + } + + public static void showDecoderCrashDialog(Activity activity) { + final SharedPreferences prefs = activity.getSharedPreferences("DecoderTombstone", 0); + final int crashCount = prefs.getInt("CrashCount", 0); + int lastNotifiedCrashCount = prefs.getInt("LastNotifiedCrashCount", 0); + + // Remember the last crash count we notified at, so we don't + // display the crash dialog every time the app is started until + // they stream again + if (crashCount != 0 && crashCount != lastNotifiedCrashCount) { + if (crashCount % 3 == 0) { + // At 3 consecutive crashes, we'll forcefully reset their settings + PreferenceConfiguration.resetStreamingSettings(activity); + Dialog.displayDialog(activity, + activity.getResources().getString(R.string.title_decoding_reset), + activity.getResources().getString(R.string.message_decoding_reset), + new Runnable() { + @Override + public void run() { + // Mark notification as acknowledged on dismissal + prefs.edit().putInt("LastNotifiedCrashCount", crashCount).apply(); + } + }); + } + else { + Dialog.displayDialog(activity, + activity.getResources().getString(R.string.title_decoding_error), + activity.getResources().getString(R.string.message_decoding_error), + new Runnable() { + @Override + public void run() { + // Mark notification as acknowledged on dismissal + prefs.edit().putInt("LastNotifiedCrashCount", crashCount).apply(); + } + }); + } + } + } + + public static void displayConfirmationDialog(Activity parent, String title, String message, String btnYesText, String btnNoText, final Runnable onYes, final Runnable onNo) { + DialogInterface.OnClickListener dialogClickListener = (dialog, which) -> { + switch (which){ + case DialogInterface.BUTTON_POSITIVE: + if (onYes != null) { + onYes.run(); + } + break; + + case DialogInterface.BUTTON_NEGATIVE: + if (onNo != null) { + onNo.run(); + } + break; + } + }; + + AlertDialog.Builder builder = new AlertDialog.Builder(parent); + builder.setMessage(Html.fromHtml(message)); + if (title != null) { + builder.setTitle(title); + } + if (btnYesText != null) { + builder.setPositiveButton(btnYesText, dialogClickListener); + } + if (btnNoText != null) { + builder.setNegativeButton(btnNoText, dialogClickListener); + } + AlertDialog dialog = builder.create(); + dialog.show(); + ((TextView)dialog.findViewById(android.R.id.message)).setMovementMethod(LinkMovementMethod.getInstance()); + } + + public static void displayVdisplayConfirmationDialog(Activity parent, ComputerDetails computer, final Runnable onYes, final Runnable onNo) { + String message = computer.vDisplaySupported ? + parent.getResources().getString(R.string.vdisplay_not_ready) : + parent.getResources().getString(R.string.vdisplay_not_supported); + UiHelper.displayConfirmationDialog( + parent, + null, + message, + parent.getResources().getString(R.string.proceed), + parent.getResources().getString(R.string.cancel), + onYes, + onNo + ); + } + + public static void displayQuitConfirmationDialog(Activity parent, final Runnable onYes, final Runnable onNo) { + displayConfirmationDialog( + parent, + null, + parent.getResources().getString(R.string.applist_quit_confirmation), + parent.getResources().getString(R.string.yes), + parent.getResources().getString(R.string.no), + onYes, + onNo + ); + } + + public static void displayDeletePcConfirmationDialog(Activity parent, ComputerDetails computer, final Runnable onYes, final Runnable onNo) { + displayConfirmationDialog( + parent, + computer.name, + parent.getResources().getString(R.string.delete_pc_msg), + parent.getResources().getString(R.string.yes), + parent.getResources().getString(R.string.no), + onYes, + onNo + ); + } + + public static float dpToPx(Context context, float dp) { + return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, context.getResources().getDisplayMetrics()); + } +} diff --git a/app/src/main/java/com/limelight/utils/Vector2d.java b/app/src/main/java/com/limelight/utils/Vector2d.java old mode 100644 new mode 100755 index a8178a2bf9..b2ea089311 --- a/app/src/main/java/com/limelight/utils/Vector2d.java +++ b/app/src/main/java/com/limelight/utils/Vector2d.java @@ -1,47 +1,47 @@ -package com.limelight.utils; - -public class Vector2d { - private float x; - private float y; - private double magnitude; - - public static final Vector2d ZERO = new Vector2d(); - - public Vector2d() { - initialize(0, 0); - } - - public void initialize(float x, float y) { - this.x = x; - this.y = y; - this.magnitude = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)); - } - - public double getMagnitude() { - return magnitude; - } - - public void getNormalized(Vector2d vector) { - vector.initialize((float)(x / magnitude), (float)(y / magnitude)); - } - - public void scalarMultiply(double factor) { - initialize((float)(x * factor), (float)(y * factor)); - } - - public void setX(float x) { - initialize(x, this.y); - } - - public void setY(float y) { - initialize(this.x, y); - } - - public float getX() { - return x; - } - - public float getY() { - return y; - } -} +package com.limelight.utils; + +public class Vector2d { + private float x; + private float y; + private double magnitude; + + public static final Vector2d ZERO = new Vector2d(); + + public Vector2d() { + initialize(0, 0); + } + + public void initialize(float x, float y) { + this.x = x; + this.y = y; + this.magnitude = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)); + } + + public double getMagnitude() { + return magnitude; + } + + public void getNormalized(Vector2d vector) { + vector.initialize((float)(x / magnitude), (float)(y / magnitude)); + } + + public void scalarMultiply(double factor) { + initialize((float)(x * factor), (float)(y * factor)); + } + + public void setX(float x) { + initialize(x, this.y); + } + + public void setY(float y) { + initialize(this.x, y); + } + + public float getX() { + return x; + } + + public float getY() { + return y; + } +} diff --git a/app/src/main/jni/moonlight-core/moonlight-common-c b/app/src/main/jni/moonlight-core/moonlight-common-c index 8af4562af6..40a4e68787 160000 --- a/app/src/main/jni/moonlight-core/moonlight-common-c +++ b/app/src/main/jni/moonlight-core/moonlight-common-c @@ -1 +1 @@ -Subproject commit 8af4562af672dd6b9ed28553ead172984fd9a683 +Subproject commit 40a4e6878714a3eb61f71a30a04effcf2dfadd32 diff --git a/app/src/main/jni/moonlight-core/simplejni.c b/app/src/main/jni/moonlight-core/simplejni.c index 39f4843d76..66ee8bd4ec 100644 --- a/app/src/main/jni/moonlight-core/simplejni.c +++ b/app/src/main/jni/moonlight-core/simplejni.c @@ -15,9 +15,15 @@ Java_com_limelight_nvstream_jni_MoonBridge_sendMouseMove(JNIEnv *env, jclass cla LiSendMouseMoveEvent(deltaX, deltaY); } +JNIEXPORT void JNICALL +Java_com_limelight_nvstream_jni_MoonBridge_sendExecServerCmd(JNIEnv *env, jclass clazz, + jint cmdId) { + LiSendExecServerCmd(cmdId); +} + JNIEXPORT void JNICALL Java_com_limelight_nvstream_jni_MoonBridge_sendMousePosition(JNIEnv *env, jclass clazz, - jshort x, jshort y, jshort referenceWidth, jshort referenceHeight) { + jshort x, jshort y, jshort referenceWidth, jshort referenceHeight) { LiSendMousePositionEvent(x, y, referenceWidth, referenceHeight); } diff --git a/app/src/main/res/anim/boxart_fadein.xml b/app/src/main/res/anim/boxart_fadein.xml old mode 100644 new mode 100755 index 334481bba2..f7bd1c76c9 --- a/app/src/main/res/anim/boxart_fadein.xml +++ b/app/src/main/res/anim/boxart_fadein.xml @@ -1,8 +1,8 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/res/anim/boxart_fadeout.xml b/app/src/main/res/anim/boxart_fadeout.xml old mode 100644 new mode 100755 index 579c5a3f35..ebe11aaf7c --- a/app/src/main/res/anim/boxart_fadeout.xml +++ b/app/src/main/res/anim/boxart_fadeout.xml @@ -1,8 +1,8 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-xhdpi/atv_banner.png b/app/src/main/res/drawable-xhdpi/atv_banner.png old mode 100644 new mode 100755 index 2e15225df9..ab24008d22 Binary files a/app/src/main/res/drawable-xhdpi/atv_banner.png and b/app/src/main/res/drawable-xhdpi/atv_banner.png differ diff --git a/app/src/main/res/drawable-xhdpi/no_app_image.png b/app/src/main/res/drawable-xhdpi/no_app_image.png old mode 100644 new mode 100755 diff --git a/app/src/main/res/drawable-xhdpi/ouya_icon.png b/app/src/main/res/drawable-xhdpi/ouya_icon.png old mode 100644 new mode 100755 index cda722c6f2..ab24008d22 Binary files a/app/src/main/res/drawable-xhdpi/ouya_icon.png and b/app/src/main/res/drawable-xhdpi/ouya_icon.png differ diff --git a/app/src/main/res/drawable/app_icon.png b/app/src/main/res/drawable/app_icon.png old mode 100644 new mode 100755 diff --git a/app/src/main/res/drawable/bg_ax_keyboard_button.xml b/app/src/main/res/drawable/bg_ax_keyboard_button.xml new file mode 100755 index 0000000000..27c93123ea --- /dev/null +++ b/app/src/main/res/drawable/bg_ax_keyboard_button.xml @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_ax_keyboard_button_confirm.xml b/app/src/main/res/drawable/bg_ax_keyboard_button_confirm.xml new file mode 100755 index 0000000000..7ed3876ec0 --- /dev/null +++ b/app/src/main/res/drawable/bg_ax_keyboard_button_confirm.xml @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_a.xml b/app/src/main/res/drawable/facebutton_a.xml new file mode 100755 index 0000000000..afb19e389d --- /dev/null +++ b/app/src/main/res/drawable/facebutton_a.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_a_press.xml b/app/src/main/res/drawable/facebutton_a_press.xml new file mode 100755 index 0000000000..60f66737bf --- /dev/null +++ b/app/src/main/res/drawable/facebutton_a_press.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_b.xml b/app/src/main/res/drawable/facebutton_b.xml new file mode 100755 index 0000000000..b2471c2c5e --- /dev/null +++ b/app/src/main/res/drawable/facebutton_b.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_b_press.xml b/app/src/main/res/drawable/facebutton_b_press.xml new file mode 100755 index 0000000000..fd2e478a58 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_b_press.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_dpad.xml b/app/src/main/res/drawable/facebutton_dpad.xml new file mode 100755 index 0000000000..2ba8c05c4e --- /dev/null +++ b/app/src/main/res/drawable/facebutton_dpad.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_dpad_up.xml b/app/src/main/res/drawable/facebutton_dpad_up.xml new file mode 100755 index 0000000000..ed99e8cede --- /dev/null +++ b/app/src/main/res/drawable/facebutton_dpad_up.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_dpad_up_right.xml b/app/src/main/res/drawable/facebutton_dpad_up_right.xml new file mode 100755 index 0000000000..e6bb8dc30d --- /dev/null +++ b/app/src/main/res/drawable/facebutton_dpad_up_right.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_l.xml b/app/src/main/res/drawable/facebutton_l.xml new file mode 100755 index 0000000000..6da1a95abb --- /dev/null +++ b/app/src/main/res/drawable/facebutton_l.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_l3.xml b/app/src/main/res/drawable/facebutton_l3.xml new file mode 100755 index 0000000000..150d8e11c9 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_l3.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_l3_press.xml b/app/src/main/res/drawable/facebutton_l3_press.xml new file mode 100755 index 0000000000..566603b827 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_l3_press.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_l_press.xml b/app/src/main/res/drawable/facebutton_l_press.xml new file mode 100755 index 0000000000..8002802fac --- /dev/null +++ b/app/src/main/res/drawable/facebutton_l_press.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_minus.xml b/app/src/main/res/drawable/facebutton_minus.xml new file mode 100755 index 0000000000..588e75cf3b --- /dev/null +++ b/app/src/main/res/drawable/facebutton_minus.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_minus_press.xml b/app/src/main/res/drawable/facebutton_minus_press.xml new file mode 100755 index 0000000000..fb33a6ff02 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_minus_press.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_plus.xml b/app/src/main/res/drawable/facebutton_plus.xml new file mode 100755 index 0000000000..e51faea753 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_plus.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_plus_press.xml b/app/src/main/res/drawable/facebutton_plus_press.xml new file mode 100755 index 0000000000..791e83489f --- /dev/null +++ b/app/src/main/res/drawable/facebutton_plus_press.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_r.xml b/app/src/main/res/drawable/facebutton_r.xml new file mode 100755 index 0000000000..c0db1c4976 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_r.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_r3.xml b/app/src/main/res/drawable/facebutton_r3.xml new file mode 100755 index 0000000000..a84e08d038 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_r3.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_r3_press.xml b/app/src/main/res/drawable/facebutton_r3_press.xml new file mode 100755 index 0000000000..68ee63b2aa --- /dev/null +++ b/app/src/main/res/drawable/facebutton_r3_press.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_r_press.xml b/app/src/main/res/drawable/facebutton_r_press.xml new file mode 100755 index 0000000000..c93bc6d80e --- /dev/null +++ b/app/src/main/res/drawable/facebutton_r_press.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_touchpad.xml b/app/src/main/res/drawable/facebutton_touchpad.xml new file mode 100755 index 0000000000..da199d6089 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_touchpad.xml @@ -0,0 +1,12 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_touchpad_press.xml b/app/src/main/res/drawable/facebutton_touchpad_press.xml new file mode 100755 index 0000000000..1f501b3f9d --- /dev/null +++ b/app/src/main/res/drawable/facebutton_touchpad_press.xml @@ -0,0 +1,12 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_x.xml b/app/src/main/res/drawable/facebutton_x.xml new file mode 100755 index 0000000000..67efd42934 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_x.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_x_press.xml b/app/src/main/res/drawable/facebutton_x_press.xml new file mode 100755 index 0000000000..d4d7b2be7b --- /dev/null +++ b/app/src/main/res/drawable/facebutton_x_press.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_y.xml b/app/src/main/res/drawable/facebutton_y.xml new file mode 100755 index 0000000000..5c590274c8 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_y.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_y_press.xml b/app/src/main/res/drawable/facebutton_y_press.xml new file mode 100755 index 0000000000..05b31ce7fa --- /dev/null +++ b/app/src/main/res/drawable/facebutton_y_press.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_zl.xml b/app/src/main/res/drawable/facebutton_zl.xml new file mode 100755 index 0000000000..836b748a72 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_zl.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_zl_press.xml b/app/src/main/res/drawable/facebutton_zl_press.xml new file mode 100755 index 0000000000..9c2e9e24fa --- /dev/null +++ b/app/src/main/res/drawable/facebutton_zl_press.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_zr.xml b/app/src/main/res/drawable/facebutton_zr.xml new file mode 100755 index 0000000000..18a4b70aed --- /dev/null +++ b/app/src/main/res/drawable/facebutton_zr.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/facebutton_zr_press.xml b/app/src/main/res/drawable/facebutton_zr_press.xml new file mode 100755 index 0000000000..e2e6effb03 --- /dev/null +++ b/app/src/main/res/drawable/facebutton_zr_press.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml old mode 100644 new mode 100755 index 2f118b3fd1..5a6140aa58 --- a/app/src/main/res/drawable/ic_add.xml +++ b/app/src/main/res/drawable/ic_add.xml @@ -1,9 +1,9 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_channel.xml b/app/src/main/res/drawable/ic_channel.xml old mode 100644 new mode 100755 index 4dada9a7f4..65d33b933a --- a/app/src/main/res/drawable/ic_channel.xml +++ b/app/src/main/res/drawable/ic_channel.xml @@ -1,10 +1,10 @@ - - - - - + + + + + diff --git a/app/src/main/res/drawable/ic_computer.xml b/app/src/main/res/drawable/ic_computer.xml old mode 100644 new mode 100755 index 2617515737..bfa8a02686 --- a/app/src/main/res/drawable/ic_computer.xml +++ b/app/src/main/res/drawable/ic_computer.xml @@ -1,9 +1,9 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_help.xml b/app/src/main/res/drawable/ic_help.xml old mode 100644 new mode 100755 index d0b4755fc1..d17bdf218f --- a/app/src/main/res/drawable/ic_help.xml +++ b/app/src/main/res/drawable/ic_help.xml @@ -1,9 +1,9 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_hud_bg.xml b/app/src/main/res/drawable/ic_hud_bg.xml new file mode 100755 index 0000000000..d2cb12f187 --- /dev/null +++ b/app/src/main/res/drawable/ic_hud_bg.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_keyboard_setting.xml b/app/src/main/res/drawable/ic_keyboard_setting.xml new file mode 100755 index 0000000000..2ddf0f1f7a --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_setting.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_lime_layer.xml b/app/src/main/res/drawable/ic_lime_layer.xml old mode 100644 new mode 100755 index 74ce061aba..42e6af44ec --- a/app/src/main/res/drawable/ic_lime_layer.xml +++ b/app/src/main/res/drawable/ic_lime_layer.xml @@ -1,19 +1,19 @@ - - - - - - - - - - + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_lock.xml b/app/src/main/res/drawable/ic_lock.xml old mode 100644 new mode 100755 index ad5e3a9860..6782f70e90 --- a/app/src/main/res/drawable/ic_lock.xml +++ b/app/src/main/res/drawable/ic_lock.xml @@ -1,9 +1,9 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_pc_offline.xml b/app/src/main/res/drawable/ic_pc_offline.xml old mode 100644 new mode 100755 index 983ddf4d7e..d15ee9c7a4 --- a/app/src/main/res/drawable/ic_pc_offline.xml +++ b/app/src/main/res/drawable/ic_pc_offline.xml @@ -1,9 +1,9 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_play.xml b/app/src/main/res/drawable/ic_play.xml old mode 100644 new mode 100755 index 83a91438e9..52db01aea3 --- a/app/src/main/res/drawable/ic_play.xml +++ b/app/src/main/res/drawable/ic_play.xml @@ -1,9 +1,9 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml old mode 100644 new mode 100755 index 0286f33133..f6a56f2b29 --- a/app/src/main/res/drawable/ic_settings.xml +++ b/app/src/main/res/drawable/ic_settings.xml @@ -1,9 +1,9 @@ - - - + + + diff --git a/app/src/main/res/drawable/key_popup_background.xml b/app/src/main/res/drawable/key_popup_background.xml new file mode 100644 index 0000000000..71eb1bb647 --- /dev/null +++ b/app/src/main/res/drawable/key_popup_background.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/list_view_unselected.xml b/app/src/main/res/drawable/list_view_unselected.xml old mode 100644 new mode 100755 index b691f3a27b..6decc513f0 --- a/app/src/main/res/drawable/list_view_unselected.xml +++ b/app/src/main/res/drawable/list_view_unselected.xml @@ -1,7 +1,7 @@ - - - - - + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-land/activity_pc_view.xml b/app/src/main/res/layout-land/activity_pc_view.xml old mode 100644 new mode 100755 index f745fe0a04..bc81ef441e --- a/app/src/main/res/layout-land/activity_pc_view.xml +++ b/app/src/main/res/layout-land/activity_pc_view.xml @@ -1,84 +1,84 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_add_computer_manually.xml b/app/src/main/res/layout/activity_add_computer_manually.xml old mode 100644 new mode 100755 index 80b921df86..ed50efcbb9 --- a/app/src/main/res/layout/activity_add_computer_manually.xml +++ b/app/src/main/res/layout/activity_add_computer_manually.xml @@ -1,51 +1,51 @@ - - - - - - - - - -