From 1c49b6fde2952564117677ba3253e22d48f82803 Mon Sep 17 00:00:00 2001 From: electrondefuser Date: Sun, 11 Feb 2024 15:22:31 +0530 Subject: [PATCH] First Commit --- .gitignore | 14 + .gitmodules | 6 + .idea/compiler.xml | 6 + .idea/deploymentTargetDropDown.xml | 10 + .idea/gradle.xml | 18 + .idea/kotlinc.xml | 6 + .idea/migrations.xml | 10 + .idea/misc.xml | 5 + .idea/vcs.xml | 8 + README.md | 228 +++++ app/.gitignore | 1 + app/build.gradle | 63 ++ app/gastest.o | Bin 0 -> 1356 bytes app/proguard-rules.pro | 21 + .../appknox/vnc/ExampleInstrumentedTest.java | 26 + app/src/main/AndroidManifest.xml | 82 ++ app/src/main/cpp/CMakeLists.txt | 40 + app/src/main/cpp/vnc.c | 460 ++++++++++ .../main/java/com/appknox/vnc/Constants.java | 22 + app/src/main/java/com/appknox/vnc/Defaults.kt | 111 +++ .../java/com/appknox/vnc/InputPointerView.kt | 125 +++ .../com/appknox/vnc/InputRequestActivity.java | 92 ++ .../java/com/appknox/vnc/InputService.java | 416 +++++++++ .../com/appknox/vnc/InputWrapperService.java | 63 ++ .../java/com/appknox/vnc/KeyboardMap.java | 133 +++ .../java/com/appknox/vnc/MainActivity.java | 709 ++++++++++++++++ .../vnc/MediaProjectionRequestActivity.java | 49 ++ .../vnc/NotificationRequestActivity.java | 81 ++ app/src/main/java/com/appknox/vnc/Utils.java | 22 + .../main/java/com/appknox/vnc/VNCService.java | 800 ++++++++++++++++++ .../vnc/WriteStorageRequestActivity.java | 85 ++ app/src/main/res/layout/activity_main.xml | 493 +++++++++++ app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 1150 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 871 bytes app/src/main/res/mipmap-xhdpi/banner.png | Bin 0 -> 13099 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 1606 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 2442 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 3450 bytes app/src/main/res/values-de/strings.xml | 59 ++ app/src/main/res/values-night/colors.xml | 8 + app/src/main/res/values-pt/strings.xml | 59 ++ app/src/main/res/values-television/colors.xml | 1 + app/src/main/res/values-television/styles.xml | 15 + app/src/main/res/values-zh-rCN/strings.xml | 59 ++ app/src/main/res/values/colors.xml | 8 + app/src/main/res/values/strings.xml | 60 ++ app/src/main/res/values/styles.xml | 27 + app/src/main/res/xml/input_service_config.xml | 8 + .../java/com/appknox/vnc/ExampleUnitTest.java | 17 + build.gradle | 25 + .../android/en-US/full_description.txt | 1 + .../android/en-US/images/featureGraphic.png | Bin 0 -> 23166 bytes .../metadata/android/en-US/images/icon.png | Bin 0 -> 5458 bytes .../en-US/images/phoneScreenshots/1.png | Bin 0 -> 116787 bytes .../android/en-US/short_description.txt | 1 + gradle.properties | 24 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54329 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 172 ++++ gradlew.bat | 84 ++ libjpeg-turbo | 1 + libvncserver | 1 + settings.gradle | 2 + 63 files changed, 4843 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 .idea/compiler.xml create mode 100644 .idea/deploymentTargetDropDown.xml create mode 100644 .idea/gradle.xml create mode 100644 .idea/kotlinc.xml create mode 100644 .idea/migrations.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/vcs.xml create mode 100644 README.md create mode 100644 app/.gitignore create mode 100644 app/build.gradle create mode 100644 app/gastest.o create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/com/appknox/vnc/ExampleInstrumentedTest.java create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/cpp/CMakeLists.txt create mode 100644 app/src/main/cpp/vnc.c create mode 100644 app/src/main/java/com/appknox/vnc/Constants.java create mode 100644 app/src/main/java/com/appknox/vnc/Defaults.kt create mode 100644 app/src/main/java/com/appknox/vnc/InputPointerView.kt create mode 100644 app/src/main/java/com/appknox/vnc/InputRequestActivity.java create mode 100644 app/src/main/java/com/appknox/vnc/InputService.java create mode 100644 app/src/main/java/com/appknox/vnc/InputWrapperService.java create mode 100644 app/src/main/java/com/appknox/vnc/KeyboardMap.java create mode 100644 app/src/main/java/com/appknox/vnc/MainActivity.java create mode 100644 app/src/main/java/com/appknox/vnc/MediaProjectionRequestActivity.java create mode 100644 app/src/main/java/com/appknox/vnc/NotificationRequestActivity.java create mode 100644 app/src/main/java/com/appknox/vnc/Utils.java create mode 100644 app/src/main/java/com/appknox/vnc/VNCService.java create mode 100644 app/src/main/java/com/appknox/vnc/WriteStorageRequestActivity.java create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/banner.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/values-de/strings.xml create mode 100644 app/src/main/res/values-night/colors.xml create mode 100644 app/src/main/res/values-pt/strings.xml create mode 120000 app/src/main/res/values-television/colors.xml create mode 100644 app/src/main/res/values-television/styles.xml create mode 100644 app/src/main/res/values-zh-rCN/strings.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/styles.xml create mode 100644 app/src/main/res/xml/input_service_config.xml create mode 100644 app/src/test/java/com/appknox/vnc/ExampleUnitTest.java create mode 100644 build.gradle create mode 100644 fastlane/metadata/android/en-US/full_description.txt create mode 100644 fastlane/metadata/android/en-US/images/featureGraphic.png create mode 100644 fastlane/metadata/android/en-US/images/icon.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/1.png create mode 100644 fastlane/metadata/android/en-US/short_description.txt create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 160000 libjpeg-turbo create mode 160000 libvncserver create mode 100644 settings.gradle diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..603b140 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..d08a43e --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "libjpeg-turbo"] + path = libjpeg-turbo + url = https://github.com/libjpeg-turbo/libjpeg-turbo.git +[submodule "libvncserver"] + path = libvncserver + url = https://github.com/LibVNC/libvncserver.git diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b589d56 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml new file mode 100644 index 0000000..0c0c338 --- /dev/null +++ b/.idea/deploymentTargetDropDown.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..32522c1 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..fdf8d99 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..824785d --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..9e16f9f --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7f36442 --- /dev/null +++ b/README.md @@ -0,0 +1,228 @@ +# droidVNC-NG + +[![Join the chat at https://gitter.im/droidVNC-NG/community](https://badges.gitter.im/droidVNC-NG/community.svg)](https://gitter.im/droidVNC-NG/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) + +This is an Android VNC server using contemporary Android 5+ APIs. It therefore does not require +root access. In reverence to the venerable [droid-VNC-server](https://github.com/oNaiPs/droidVncServer) +is is called droidVNC-NG. + +If you have a general question, it's best to [ask in the community chat](https://gitter.im/droidVNC-NG/community). If your concern is about a bug or feature request instead, please use [the issue tracker](https://github.com/bk138/droidVNC-NG/issues). + +[Get it on F-Droid](https://f-droid.org/packages/net.christianbeier.droidvnc_ng/) +[Get it on Google Play](https://play.google.com/store/apps/details?id=net.christianbeier.droidvnc_ng) + +## Features + +* Network export of device frame buffer with optional server-side scaling. +* Injection of remote pointer events. +* Handling of client-to-server text copy & paste. Note that server-to-client copy & paste does not + work in a generic way due to [Android security restrictions](https://developer.android.com/about/versions/10/privacy/changes#clipboard-data). +* Handling of special keys to trigger 'Recent Apps' overview, Home button and Back button. +* Android permission handling. +* Screen rotation handling. +* File transfer via the local network, assuming TightVNC viewer for Windows version 1.3.x is used. +* Password protection for secure-in-terms-of-VNC connection. +* Ability to specify the port used. +* Start of background service on device boot. +* Reverse VNC. +* Ability to connect to a UltraVNC-style Mode-2 repeater. +* Functionality to provide default configuration via a JSON file. +* Zeroconf/Bonjour publishing for VNC server auto-discovery. +* Per-client mouse pointers on the controlled device. + + +## How to use + +1. Install the app from either marketplace. +2. Get it all the permissions required. +3. Set a good password and consider turning the `Start on Boot` off. +4. Connect to your local Wi-Fi. For accepting a connection your device should be connected to some Local Area Network that you can control, normally it is a router. Connections via data networks (i.e. your mobile provider) are not supported. +5. Click `Start` and connect to your device. + +### Keyboard Shortcuts From a VNC Viewer + +* **Ctrl-Shift-Esc** triggers 'Recent Apps' overview +* **Home/Pos1** acts as Home button +* **Escape** acts as Back button + +### For accepting connections from outside + +1. You should allow [Port Forwarding](https://en.wikipedia.org/wiki/Port_forwarding) in your router's Firewall settings. Login to your router's settings (usually open 192.168.1.1 in your browser, some routers have password written on them). +2. Find Port Forwarding, usually it's somewhere in **Network - Firewall - Port Forwards**. +3. Create a new rule, this is an example from OpenWRT firmware. + + Name: **VNC forwarding** + + Protocol: **TCP** + + Source zone: **wan** may be "internet", "modem", something that suggests the external source. + + External port: **5900** by default or whatever you specified in the app. + + Destination zone: **lan** something that suggests local network. + + Internal IP address: your device's local IP address, leaving **any** is less secure. The device's address may change over time! You can look it up in your routers' connected clients info. + + Internal port: same as external port. + +4. Apply the settings, sometimes it requires rebooting a router. +5. Figure out your public adress i.e. . +6. Use this address and port from above to connect to your device. + +### How to Pre-seed Preferences + +DroidVNC-NG can read a JSON file with default settings that apply if settings were not changed +by the user. A file named `defaults.json` needs to created under +`/Android/data/com.appknox.knoxvnc/files/` where +depending on your device, `` is something like `/storage/emulated/0` if +the device shows two external storages or simply `/sdcard` if the device has one external storage. + +An example `defaults.json` with completely new defaults (not all entries need to be provided) is: + +```json +{ + "port": 5901, + "portReverse": 5555, + "portRepeater": 5556, + "scaling": 0.7, + "viewOnly": false, + "showPointers": true, + "fileTransfer": true, + "password": "supersecure", + "accessKey": "evenmoresecure", + "startOnBoot": true, + "startOnBootDelay": 0 +} +``` + +### Remote Control via the Intent Interface + +droidVNC-NG features a remote control interface by means of Intents. This allows starting the VNC +server from other apps or on certain events. It is designed to be working with automation apps +like [MacroDroid](https://www.macrodroid.com/), [Automate](https://llamalab.com/automate/) or +[Tasker](https://tasker.joaoapps.com/) as well as to be called from code. + +You basically send an explicit Intent to `com.appknox.knoxvnc.MainService` with one of +the following Actions and associated Extras set: + +* `com.appknox.knoxvnc.ACTION_START`: Starts the server. + * `com.appknox.knoxvnc.EXTRA_ACCESS_KEY`: Required String Extra containing the remote control interface's access key. You can get/set this from the Admin Panel. + * `com.appknox.knoxvnc.EXTRA_REQUEST_ID`: Optional String Extra containing a unique id for this request. Used to identify the answer from the service. + * `com.appknox.knoxvnc.EXTRA_PORT`: Optional Integer Extra setting the listening port. Set to `-1` to disable listening. + * `com.appknox.knoxvnc.EXTRA_PASSWORD`: Optional String Extra containing VNC password. + * `com.appknox.knoxvnc.EXTRA_SCALING`: Optional Float Extra between 0.0 and 1.0 describing the server-side framebuffer scaling. + * `com.appknox.knoxvnc.EXTRA_VIEW_ONLY`: Optional Boolean Extra toggling view-only mode. + * `com.appknox.knoxvnc.EXTRA_SHOW_POINTERS`: Optional Boolean Extra toggling per-client mouse pointers. + * `com.appknox.knoxvnc.EXTRA_FILE_TRANSFER`: Optional Boolean Extra toggling the file transfer feature. + +* `com.appknox.knoxvnc.ACTION_CONNECT_REVERSE`: Make an outbound connection to a listening viewer. + * `com.appknox.knoxvnc.EXTRA_ACCESS_KEY`: Required String Extra containing the remote control interface's access key. You can get/set this from the Admin Panel. + * `com.appknox.knoxvnc.EXTRA_REQUEST_ID`: Optional String Extra containing a unique id for this request. Used to identify the answer from the service. + * `com.appknox.knoxvnc.EXTRA_HOST`: Required String Extra setting the host to connect to. + * `com.appknox.knoxvnc.EXTRA_PORT`: Optional Integer Extra setting the remote port. + +* `com.appknox.knoxvnc.ACTION_CONNECT_REPEATER` Make an outbound connection to a repeater. + * `com.appknox.knoxvnc.EXTRA_ACCESS_KEY`: Required String Extra containing the remote control interface's access key. You can get/set this from the Admin Panel. + * `com.appknox.knoxvnc.EXTRA_REQUEST_ID`: Optional String Extra containing a unique id for this request. Used to identify the answer from the service. + * `com.appknox.knoxvnc.EXTRA_HOST`: Required String Extra setting the host to connect to. + * `com.appknox.knoxvnc.EXTRA_PORT`: Optional Integer Extra setting the remote port. + * `com.appknox.knoxvnc.EXTRA_REPEATER_ID`: Required String Extra setting the ID on the repeater. + +* `com.appknox.knoxvnc.ACTION_STOP`: Stops the server. + * `com.appknox.knoxvnc.EXTRA_ACCESS_KEY`: Required String Extra containing the remote control interface's access key. You can get/set this from the Admin Panel. + * `com.appknox.knoxvnc.EXTRA_REQUEST_ID`: Optional String Extra containing a unique id for this request. Used to identify the answer from the service. + +The service answers with a Broadcast Intent with its Action mirroring your request: + +* Action: one of the above Actions you requested + * `com.appknox.knoxvnc.EXTRA_REQUEST_ID`: The request id this answer is for. + * `com.appknox.knoxvnc.EXTRA_REQUEST_SUCCESS`: Boolean Extra describing the outcome of the request. + +There is one special case where the service sends a Broadcast Intent with action +`com.appknox.knoxvnc.ACTION_STOP` without any extras: that is when it is stopped by the +system. + +#### Examples + +##### Start a password-protected view-only server on port 5901 + +Using `adb shell am` syntax: + +```shell +adb shell am start-foreground-service \ + -n com.appknox.knoxvnc.knoxvnc/.MainService \ + -a com.appknox.knoxvnc.knoxvnc.ACTION_START \ + --es com.appknox.knoxvnc.knoxvnc.EXTRA_ACCESS_KEY de32550a6efb43f8a5d145e6c07b2cde \ + --es com.appknox.knoxvnc.knoxvnc.EXTRA_REQUEST_ID abc123 \ + --ei com.appknox.knoxvnc.knoxvnc.EXTRA_PORT 5901 \ + --es com.appknox.knoxvnc.knoxvnc.EXTRA_PASSWORD supersecure \ + --ez com.appknox.knoxvnc.knoxvnc.EXTRA_VIEW_ONLY true +``` + +##### Start a server with defaults from Tasker + +- Tasker action-category in menu is System -> Send Intent +- In there: + - Action `com.appknox.knoxvnc.ACTION_START` + - Extra `com.appknox.knoxvnc.EXTRA_ACCESS_KEY:` + - Package `com.appknox.knoxvnc` + - Class `com.appknox.knoxvnc.MainService` + - Target: Service + +##### Make an outbound connection to a listening viewer from the running server + +For example from Java code: + +See [MainActivity.java](app/src/main/java/net/christianbeier/knoxvnc/MainActivity.java). + +##### Stop the server again + +Using `adb shell am` syntax again: + +```shell +adb shell am start-foreground-service \ + -n com.appknox.knoxvnc.knoxvnc/.MainService \ + -a com.appknox.knoxvnc.knoxvnc.ACTION_STOP \ + --es com.appknox.knoxvnc.knoxvnc.EXTRA_ACCESS_KEY de32550a6efb43f8a5d145e6c07b2cde \ + --es com.appknox.knoxvnc.knoxvnc.EXTRA_REQUEST_ID def456 +``` + +## Building + +* After cloning the repo, make sure you have the required git submodules set up via `git submodule update --init`. +* Then simply build via Android Studio or `gradlew`. + + +## Contributing + +Contributions to the project are very welcome and encouraged! They can come in many forms. +You can: + + * Submit a feature request or bug report as an [issue](https://github.com/bk138/droidVNC-NG/issues). + * Provide info for [issues that require feedback](https://github.com/bk138/droidVNC-NG/labels/answer-needed). + * Add features or fix bugs via [pull requests](https://github.com/bk138/droidVNC-NG/pulls). + Please note [there's a list of issues](https://github.com/bk138/droidVNC-NG/labels/help%20wanted) + where contributions are especially welcome. Also, please adhere to the [contribution guidelines](CONTRIBUTING.md). + + +## Notes + +* Requires at least Android 7. + +* [Since Android 10](https://developer.android.com/about/versions/10/privacy/changes#screen-contents), +the permission to access the screen contents has to be given on each start and is not saved. You can, +however, work around this by installing [adb](https://developer.android.com/studio/command-line/adb) +(or simply Android Studio) on a PC, connecting the device running droidVNC-NG to that PC and running +`adb shell cmd appops set com.appknox.knoxvnc PROJECT_MEDIA allow` once. + +* You can also use adb to manually give input permission prior to app start via `adb shell settings put secure enabled_accessibility_services com.appknox.knoxvnc/.InputService:$(adb shell settings get secure enabled_accessibility_services)`. + +* If you are getting a black screen in a connected VNC viewer despite having given all permissions, it +might be that your device does not support Android's MediaProjection API correctly. To find out, you can +try screen recording with another app, [ScreenRecorder](https://gitlab.com/vijai/screenrecorder). If it +fails as well, your device most likely does not support screen recording via MediaProjection. This is +known to be the case for [Android-x86](https://www.android-x86.org). diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..6a7fedb --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,63 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlin_version" +} + +android { + compileSdkVersion 34 + + defaultConfig { + applicationId "com.appknox.vnc" + minSdkVersion 24 + targetSdkVersion 33 + versionCode 28 + versionName "2.1.7" + + externalNativeBuild { + cmake { + // specify explicit target list to exclude examples, tests, utils. etc from used libraries + targets "turbojpeg-static", "vncserver", "vnc" + } + } + + ndk { + debugSymbolLevel = 'FULL' + } + + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + externalNativeBuild { + cmake { + version '3.22.1' + path 'src/main/cpp/CMakeLists.txt' + } + } + namespace 'com.appknox.vnc' + // needed for Gradle 8.x w/ Kotlin 1.8.x + kotlinOptions { + jvmTarget = '1.8' + } +} + +dependencies { + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'androidx.preference:preference:1.2.1' + implementation "androidx.core:core:1.12.0" + implementation 'com.google.android.material:material:1.10.0' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1" +} +repositories { + mavenCentral() +} \ No newline at end of file diff --git a/app/gastest.o b/app/gastest.o new file mode 100644 index 0000000000000000000000000000000000000000..1f9b10ea0628453e45c88fb30aa97e2e10be9be0 GIT binary patch literal 1356 zcmb_b&2G~`5T1>bG%W>MencfWm_nsgqFyI4iCZ`jsURfE1%$+{UE7<~#qnCU8^Woa zIPd^G2p3*}2jIpF@Bm1_2@cFS-ZniT^}tB8^UXKAGwa#$(Ziibj4{x{pa~})Kx7n= znp2o2EK>CQmtRo%_@lpyq>#c3fvq5{^)~uYUzqDR*nDNdSY)fLgS)H6(du#52KxL( zCew_M!&u6kZ>!gFq(qSMSFxImW1ov8;g&y{@NMZG4td*YsrI0qaFLFv-p05mcocdE zi5&7Q90hzRvRr1l_53WUp&tjS3Int0I-7Q@=iV_rk;%YRv6(t|?Vjrd>!u%xcxaCC zo+0ur$Fi-i+1$_bB-`R#OjB4xrG}Br)M4sN%U2_wD;4?sB8+(yjYk_vst%-|n~vYL zJ96OJzAx;aBRAU}R}5UwY1v)BV~ZegoR&N2t^<1uY?&=xTV|J-(YW4NYh11YR2IN! zY}N18m#L9AP%G2E7Aq~(S1s=lR3XI`ANTi#^JM58D*NBP2RLbd@yNB}W-CuBRV=G- z^bc#`XY8MfO+QOhMmatweqF=D8ZZyUU@e?YwAiX8vRL>^X^S|{vF49T9;|?-Ygc$) zD#u{ua*~79fA-W8d7g&eAx;!nsf_f#6bIro6~+TKyCuSb1}n?cTzF{TjL6;=mW?IP zEH82A^tYQH`GYMP&79Ufy@(uP-4XgnXNO?=;{jYn9`hw=3b~K#trC;b`H0t;_+8ZA z456Wm{5Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + assertEquals("com.appknox.vnc", appContext.getPackageName()); + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..adf4a7e --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..c4d0f0f --- /dev/null +++ b/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.4.1) +project (knoxvnc C) + +set(BUILD_SHARED_LIBS OFF CACHE BOOL "Build shared Libs" FORCE) + +if (CMAKE_ANDROID_ARCH_ABI STREQUAL "arm64-v8a") + set(CMAKE_ASM_FLAGS "${CMAKE_ASM_FLAGS} --target=aarch64-linux-android${ANDROID_VERSION}") +elseif (CMAKE_ANDROID_ARCH_ABI MATCHES "^armeabi.*") # armeabi-v7a || armeabi-v6 || armeabi + set(CMAKE_ASM_FLAGS "${CMAKE_ASM_FLAGS} --target=arm-linux-androideabi${ANDROID_VERSION}") +endif () + +# build libJPEG +message("------libjpeg-turbo-----") +set(libjpeg_src_DIR ${CMAKE_SOURCE_DIR}/../../../../libjpeg-turbo) +set(libjpeg_build_DIR ${CMAKE_BINARY_DIR}/libjpeg) + +add_subdirectory(${libjpeg_src_DIR} ${libjpeg_build_DIR}) +set(JPEG_LIBRARY ${libjpeg_build_DIR}/libturbojpeg.a CACHE FILEPATH "") +set(JPEG_INCLUDE_DIR ${libjpeg_src_DIR} CACHE PATH "") +include_directories( + ${libjpeg_src_DIR} + ${libjpeg_build_DIR} +) + +# build LibVNCServer +message("------LibVNCServer-----") +set(libvnc_src_DIR ${CMAKE_SOURCE_DIR}/../../../../libvncserver) +set(libvnc_build_DIR ${CMAKE_BINARY_DIR}/libvnc) +add_subdirectory(${libvnc_src_DIR} ${libvnc_build_DIR}) +include_directories( + ${libvnc_src_DIR}/include + ${libvnc_build_DIR}/include +) + +# build libvnc +add_library(vnc SHARED vnc.c) +target_link_libraries(vnc + log + vncserver) + diff --git a/app/src/main/cpp/vnc.c b/app/src/main/cpp/vnc.c new file mode 100644 index 0000000..edef708 --- /dev/null +++ b/app/src/main/cpp/vnc.c @@ -0,0 +1,460 @@ +#include +#include +#include +#include +#include "rfb/rfb.h" + +#define TAG "knoxvnc (native)" + +rfbScreenInfoPtr theScreen; +jclass theInputService; +jclass theMainService; +JavaVM *theVM; + +/* + * Modeled after rfbDefaultLog: + * - with Android log functions + * - without time stamping as the Android logging does this already + */ +static void logcat_info(const char *format, ...) +{ + va_list args; + + va_start(args, format); + __android_log_vprint(ANDROID_LOG_INFO, TAG, format, args); + va_end(args); +} + +static void logcat_err(const char *format, ...) +{ + va_list args; + + va_start(args, format); + __android_log_vprint(ANDROID_LOG_ERROR, TAG, format, args); + va_end(args); +} + + +/** + * @return Current time in floating point seconds. + */ +static double getTime() +{ + struct timeval tv; + if (gettimeofday(&tv, NULL) < 0) + return 0.0; + else + return (double) tv.tv_sec + ((double) tv.tv_usec / 1000000.); +} + + +static void onPointerEvent(int buttonMask,int x,int y,rfbClientPtr cl) +{ + JNIEnv *env = NULL; + if ((*theVM)->AttachCurrentThread(theVM, &env, NULL) != 0) { + __android_log_print(ANDROID_LOG_ERROR, TAG, "onPointerEvent: could not attach thread, there will be no input"); + return; + } + + /* needed to allow multiple dragging actions at once */ + cl->screen->pointerClient = NULL; + + jmethodID mid = (*env)->GetStaticMethodID(env, theInputService, "onPointerEvent", "(IIIJ)V"); + (*env)->CallStaticVoidMethod(env, theInputService, mid, buttonMask, x, y, (jlong)cl); + + if ((*env)->ExceptionCheck(env)) + (*env)->ExceptionDescribe(env); + + (*theVM)->DetachCurrentThread(theVM); +} + +static void onKeyEvent(rfbBool down, rfbKeySym key, rfbClientPtr cl) +{ + JNIEnv *env = NULL; + if ((*theVM)->AttachCurrentThread(theVM, &env, NULL) != 0) { + __android_log_print(ANDROID_LOG_ERROR, TAG, "onKeyEvent: could not attach thread, there will be no input"); + return; + } + + jmethodID mid = (*env)->GetStaticMethodID(env, theInputService, "onKeyEvent", "(IJJ)V"); + (*env)->CallStaticVoidMethod(env, theInputService, mid, down, (jlong)key, (jlong)cl); + + if ((*env)->ExceptionCheck(env)) + (*env)->ExceptionDescribe(env); + + (*theVM)->DetachCurrentThread(theVM); +} + +static void onCutText(char *text, __unused int len, rfbClientPtr cl) +{ + JNIEnv *env = NULL; + if ((*theVM)->AttachCurrentThread(theVM, &env, NULL) != 0) { + __android_log_print(ANDROID_LOG_ERROR, TAG, "onCutText: could not attach thread, there will be no input"); + return; + } + + //Charset charset = Charset.forName("ISO-8859-1") + jclass clsCharset = (*env)->FindClass(env,"java/nio/charset/Charset"); + jmethodID midCharsetForName = (*env)->GetStaticMethodID(env, clsCharset, "forName", "(Ljava/lang/String;)Ljava/nio/charset/Charset;"); + jobject charset = (*env)->CallStaticObjectMethod(env, clsCharset, midCharsetForName, (*env)->NewStringUTF(env, "ISO-8859-1")); + + //CharBuffer charBuffer = charset.decode(byteBuffer) + jobject byteBuffer = (*env)->NewDirectByteBuffer(env, (jbyte *)text, strlen(text)); + jmethodID midCharsetDecode = (*env)->GetMethodID(env, clsCharset, "decode", "(Ljava/nio/ByteBuffer;)Ljava/nio/CharBuffer;"); + jobject charBuffer = (*env)->CallObjectMethod(env, charset, midCharsetDecode, byteBuffer); + (*env)->DeleteLocalRef(env, byteBuffer); + + //String jText = charBuffer.toString(); + jclass clsCharBuffer = (*env)->FindClass(env, "java/nio/CharBuffer"); + jmethodID midCharBufferToString = (*env)->GetMethodID(env, clsCharBuffer, "toString", "()Ljava/lang/String;"); + jstring jText = (*env)->CallObjectMethod(env, charBuffer, midCharBufferToString); + (*env)->DeleteLocalRef(env, charBuffer); + + jmethodID mid = (*env)->GetStaticMethodID(env, theInputService, "onCutText", "(Ljava/lang/String;J)V"); + (*env)->CallStaticVoidMethod(env, theInputService, mid, jText, (jlong)cl); + + (*env)->DeleteLocalRef(env, jText); + if ((*env)->ExceptionCheck(env)) + (*env)->ExceptionDescribe(env); + + (*theVM)->DetachCurrentThread(theVM); +} + +void onClientDisconnected(rfbClientPtr cl) +{ + JNIEnv *env = NULL; + // check if already attached. happens on reverse connections + (*theVM)->GetEnv(theVM, (void **) &env, JNI_VERSION_1_6); + int wasAlreadyAttached = env != NULL; + // AttachCurrentThread() on an already attached thread is a no-op. https://developer.android.com/training/articles/perf-jni#threads + if ((*theVM)->AttachCurrentThread(theVM, &env, NULL) != 0) { + __android_log_print(ANDROID_LOG_ERROR, TAG, "onClientDisconnected: could not attach thread, not calling VNCService.onClientDisconnected()"); + return; + } + + jmethodID mid = (*env)->GetStaticMethodID(env, theMainService, "onClientDisconnected", "(J)V"); + (*env)->CallStaticVoidMethod(env, theMainService, mid, (jlong)cl); + + if ((*env)->ExceptionCheck(env)) + (*env)->ExceptionDescribe(env); + + // only detach if not attached before + if (!wasAlreadyAttached) + (*theVM)->DetachCurrentThread(theVM); +} + +#pragma clang diagnostic push +#pragma ide diagnostic ignored "ConstantFunctionResult" +static enum rfbNewClientAction onClientConnected(rfbClientPtr cl) +{ + // connect clientGoneHook + cl->clientGoneHook = onClientDisconnected; + + /* + * call the managed version of this function + */ + JNIEnv *env = NULL; + // check if already attached. happens on reverse connections + (*theVM)->GetEnv(theVM, (void **) &env, JNI_VERSION_1_6); + int wasAlreadyAttached = env != NULL; + // AttachCurrentThread() on an already attached thread is a no-op. https://developer.android.com/training/articles/perf-jni#threads + if ((*theVM)->AttachCurrentThread(theVM, &env, NULL) != 0) { + __android_log_print(ANDROID_LOG_ERROR, TAG, "onClientConnected: could not attach thread, not calling VNCService.onClientConnected()"); + return RFB_CLIENT_ACCEPT; + } + + jmethodID mid = (*env)->GetStaticMethodID(env, theMainService, "onClientConnected", "(J)V"); + (*env)->CallStaticVoidMethod(env, theMainService, mid, (jlong)cl); + + if ((*env)->ExceptionCheck(env)) + (*env)->ExceptionDescribe(env); + + // only detach if not attached before + if(!wasAlreadyAttached) + (*theVM)->DetachCurrentThread(theVM); + return RFB_CLIENT_ACCEPT; +} +#pragma clang diagnostic pop + +rfbClientPtr +repeaterConnection(rfbScreenInfoPtr rfbScreen, + char *repeaterHost, + int repeaterPort, + const char* repeaterIdentifier) +{ + rfbSocket sock; + rfbClientPtr cl; + char id[250]; + __android_log_print(ANDROID_LOG_INFO, TAG, "Connecting to a repeater Host: %s:%d.", repeaterHost, repeaterPort); + + if ((sock = rfbConnect(rfbScreen, repeaterHost, repeaterPort)) < 0) + return NULL; + + memset(id, 0, sizeof(id)); + if(snprintf(id, sizeof(id), "ID:%s", repeaterIdentifier) >= (int)sizeof(id)) { + /* truncated! */ + __android_log_print(ANDROID_LOG_ERROR, TAG, "Error, given ID is too long.\n"); + return NULL; + } + __android_log_print(ANDROID_LOG_INFO, TAG, "Sending a repeater ID: %s.\n", id); + if (send(sock, id, sizeof(id),0) != sizeof(id)) { + rfbLog("writing id failed\n"); + return NULL; + } + cl = rfbNewClient(rfbScreen, sock); + if (!cl) { + __android_log_print(ANDROID_LOG_ERROR, TAG, "New client failed\n"); + return NULL; + } + + cl->reverseConnection = 0; + if (!cl->onHold) + rfbStartOnHoldClient(cl); + return cl; +} + +/* + * The VM calls JNI_OnLoad when the native library is loaded (for example, through System.loadLibrary). + * JNI_OnLoad must return the JNI version needed by the native library. + * We use this to wire up LibVNCServer logging to logcat. + */ +JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void __unused * reserved) { + + __android_log_print(ANDROID_LOG_INFO, TAG, "loading, using LibVNCServer %s\n", LIBVNCSERVER_VERSION); + + theVM = vm; + + /* + * https://developer.android.com/training/articles/perf-jni#faq_FindClass + * and + * https://stackoverflow.com/a/17449108/361413 + */ + JNIEnv *env = NULL; + (*theVM)->GetEnv(theVM, (void**) &env, JNI_VERSION_1_6); // this will always succeed in JNI_OnLoad() + theInputService = (*env)->NewGlobalRef(env, (*env)->FindClass(env, "com/appknox/vnc/InputService")); + theMainService = (*env)->NewGlobalRef(env, (*env)->FindClass(env, "com/appknox/vnc/VNCService")); + + rfbLog = logcat_info; + rfbErr = logcat_err; + rfbMaxClientWait = 5000; + + return JNI_VERSION_1_6; +} + + +JNIEXPORT jboolean JNICALL Java_com_appknox_vnc_VNCService_vncStopServer(__unused JNIEnv *env, __unused jobject thiz) { + + if(!theScreen) + return JNI_FALSE; + + rfbShutdownServer(theScreen, TRUE); + free(theScreen->frameBuffer); + theScreen->frameBuffer = NULL; + free((char*)theScreen->desktopName); // always malloc'ed by us + theScreen->desktopName = NULL; + if(theScreen->authPasswdData) { // if this is set, it was malloc'ed by us and has one password in there + char **passwordList = theScreen->authPasswdData; + free(passwordList[0]); // free the password created by strdup() + free(theScreen->authPasswdData); // and free the malloc'ed list, theScreen->authPasswdData is NULLed by rfbGetScreen() + } + rfbScreenCleanup(theScreen); + theScreen = NULL; + + __android_log_print(ANDROID_LOG_INFO, TAG, "vncStopServer: successfully stopped"); + + return JNI_TRUE; +} + + +JNIEXPORT jboolean JNICALL Java_com_appknox_vnc_VNCService_vncStartServer(JNIEnv *env, jobject thiz, jint width, jint height, jint port, jstring desktopname, jstring password) { + + int argc = 0; + + if(theScreen) + return JNI_FALSE; + + rfbRegisterTightVNCFileTransferExtension(); + + theScreen=rfbGetScreen(&argc, NULL, width, height, 8, 3, 4); + if(!theScreen) { + __android_log_print(ANDROID_LOG_ERROR, TAG, "vncStartServer: failed allocating rfb screen"); + return JNI_FALSE; + } + + theScreen->frameBuffer=(char*)calloc(width * height * 4, 1); + if(!theScreen->frameBuffer) { + __android_log_print(ANDROID_LOG_ERROR, TAG, "vncStartServer: failed allocating framebuffer"); + Java_com_appknox_vnc_VNCService_vncStopServer(env, thiz); + return JNI_FALSE; + } + theScreen->ptrAddEvent = onPointerEvent; + theScreen->kbdAddEvent = onKeyEvent; + theScreen->setXCutText = onCutText; + theScreen->setXCutTextUTF8 = onCutText; + theScreen->newClientHook = onClientConnected; + + theScreen->port = port; + theScreen->ipv6port = port; + + // don't show X cursor + theScreen->cursor = NULL; + // needed to allow multiple dragging actions at once + theScreen->deferPtrUpdateTime = 0; + + if(desktopname) { // string arg to GetStringUTFChars() must not be NULL + const char *cDesktopName = (*env)->GetStringUTFChars(env, desktopname, NULL); + if(!cDesktopName) { + __android_log_print(ANDROID_LOG_ERROR, TAG, "vncStartServer: failed getting desktop name from JNI"); + theScreen->desktopName = strdup("Android"); // vncStopServer() must have something to correctly free() + Java_com_appknox_vnc_VNCService_vncStopServer(env, thiz); + return JNI_FALSE; + } + theScreen->desktopName = strdup(cDesktopName); + (*env)->ReleaseStringUTFChars(env, desktopname, cDesktopName); + } else + theScreen->desktopName = strdup("Android"); + + if(password && (*env)->GetStringLength(env, password)) { // string arg to GetStringUTFChars() must not be NULL and also do not set an empty password + char **passwordList = malloc(sizeof(char **) * 2); + if(!passwordList) { + __android_log_print(ANDROID_LOG_ERROR, TAG, "vncStartServer: failed allocating password list"); + Java_com_appknox_vnc_VNCService_vncStopServer(env, thiz); + return JNI_FALSE; + } + const char *cPassword = (*env)->GetStringUTFChars(env, password, NULL); + if(!cPassword) { + __android_log_print(ANDROID_LOG_ERROR, TAG, "vncStartServer: failed getting password from JNI"); + Java_com_appknox_vnc_VNCService_vncStopServer(env, thiz); + return JNI_FALSE; + } + passwordList[0] = strdup(cPassword); + passwordList[1] = NULL; + theScreen->authPasswdData = (void *) passwordList; + theScreen->passwordCheck = rfbCheckPasswordByList; + (*env)->ReleaseStringUTFChars(env, password, cPassword); + } + + + rfbInitServer(theScreen); + + if (port != -1) { + if (theScreen->listenSock == RFB_INVALID_SOCKET || theScreen->listen6Sock == RFB_INVALID_SOCKET) { + __android_log_print(ANDROID_LOG_ERROR, TAG, "vncStartServer: failed starting (%s)", strerror(errno)); + Java_com_appknox_vnc_VNCService_vncStopServer(env, thiz); + return JNI_FALSE; + } + } + + rfbRunEventLoop(theScreen, -1, TRUE); + + __android_log_print(ANDROID_LOG_INFO, TAG, "vncStartServer: successfully started"); + + return JNI_TRUE; +} + +// The VNCService run this on a worker thread, in the worst case blocking for rfbMaxClientWait +JNIEXPORT jlong JNICALL Java_com_appknox_vnc_VNCService_vncConnectReverse(JNIEnv *env, __unused jobject thiz, jstring host, jint port) +{ + if(!theScreen || !theScreen->frameBuffer) + return 0; + + if(host) { // string arg to GetStringUTFChars() must not be NULL + char *cHost = (char*)(*env)->GetStringUTFChars(env, host, NULL); + if(!cHost) { + __android_log_print(ANDROID_LOG_ERROR, TAG, "vncConnectReverse: failed getting desktop name from JNI"); + return 0; + } + rfbClientPtr cl = rfbReverseConnection(theScreen, cHost, port); + (*env)->ReleaseStringUTFChars(env, host, cHost); + return (jlong) cl; + } + return 0; +} + +// The VNCService run this on a worker thread, in the worst case blocking for rfbMaxClientWait +JNIEXPORT jlong JNICALL Java_com_appknox_vnc_VNCService_vncConnectRepeater(JNIEnv *env, __unused jobject thiz, jstring host, jint port, jstring repeaterIdentifier) +{ + if(!theScreen || !theScreen->frameBuffer) + return 0; + + if(host && repeaterIdentifier) { // string arg to GetStringUTFChars() must not be NULL + char *cHost = (char*)(*env)->GetStringUTFChars(env, host, NULL); + if(!cHost) { + __android_log_print(ANDROID_LOG_ERROR, TAG, "vncConnectRepeater: failed getting desktop name from JNI"); + return 0; + } + char *cRepeaterIdentifier = (char*)(*env)->GetStringUTFChars(env, repeaterIdentifier, NULL); + if(!cRepeaterIdentifier) { + __android_log_print(ANDROID_LOG_ERROR, TAG, "vncConnectRepeater: failed getting repeater ID from JNI"); + return 0; + } + rfbClientPtr cl = repeaterConnection(theScreen, cHost, port, cRepeaterIdentifier); + (*env)->ReleaseStringUTFChars(env, host, cHost); + (*env)->ReleaseStringUTFChars(env, repeaterIdentifier, cRepeaterIdentifier); + return (jlong) cl; + } + return 0; +} + + +JNIEXPORT jboolean JNICALL Java_com_appknox_vnc_VNCService_vncNewFramebuffer(__unused JNIEnv *env, __unused jobject thiz, jint width, jint height) +{ + rfbClientIteratorPtr iterator; + rfbClientPtr cl; + + char *oldfb, *newfb; + + if(!theScreen || !theScreen->frameBuffer) + return JNI_FALSE; + + oldfb = theScreen->frameBuffer; + newfb = calloc(width * height * 4, 1); + if(!newfb) { + __android_log_print(ANDROID_LOG_ERROR, TAG, "vncNewFramebuffer: failed allocating new framebuffer"); + return JNI_FALSE; + } + + rfbNewFramebuffer(theScreen, (char*)newfb, width, height, 8, 3, 4); + + free(oldfb); + __android_log_print(ANDROID_LOG_INFO, TAG, "vncNewFramebuffer: allocated new framebuffer, %dx%d", width, height); + + return JNI_TRUE; +} + +JNIEXPORT jboolean JNICALL Java_com_appknox_vnc_VNCService_vncUpdateFramebuffer(JNIEnv *env, jobject __unused thiz, jobject buf) +{ + void *cBuf = (*env)->GetDirectBufferAddress(env, buf); + jlong bufSize = (*env)->GetDirectBufferCapacity(env, buf); + + if(!theScreen || !theScreen->frameBuffer || !cBuf || bufSize < 0) + return JNI_FALSE; + + double t0 = getTime(); + memcpy(theScreen->frameBuffer, cBuf, bufSize); + __android_log_print(ANDROID_LOG_DEBUG, TAG, "vncUpdateFramebuffer: copy took %.3f ms", (getTime()-t0)*1000); + + rfbMarkRectAsModified(theScreen, 0, 0, theScreen->width, theScreen->height); + + return JNI_TRUE; +} + +JNIEXPORT jint JNICALL Java_com_appknox_vnc_VNCService_vncGetFramebufferWidth(__unused JNIEnv *env, jobject __unused thiz) +{ + if(!theScreen || !theScreen->frameBuffer) + return -1; + + return theScreen->width; +} + +JNIEXPORT jint JNICALL Java_com_appknox_vnc_VNCService_vncGetFramebufferHeight(__unused JNIEnv *env, jobject __unused thiz) +{ + if(!theScreen || !theScreen->frameBuffer) + return -1; + + return theScreen->height; +} + +JNIEXPORT jboolean JNICALL Java_com_appknox_vnc_VNCService_vncIsActive(JNIEnv *env, jobject thiz) { + return theScreen && rfbIsActive(theScreen); +} \ No newline at end of file diff --git a/app/src/main/java/com/appknox/vnc/Constants.java b/app/src/main/java/com/appknox/vnc/Constants.java new file mode 100644 index 0000000..d88bfdc --- /dev/null +++ b/app/src/main/java/com/appknox/vnc/Constants.java @@ -0,0 +1,22 @@ +package com.appknox.vnc; + +public class Constants { + /* + user settings + */ + public static final String PREFS_KEY_SETTINGS_PORT = "settings_port"; + public static final String PREFS_KEY_SETTINGS_PASSWORD = "settings_password" ; + public static final String PREFS_KEY_SETTINGS_START_ON_BOOT = "settings_start_on_boot" ; + public static final String PREFS_KEY_SETTINGS_START_ON_BOOT_DELAY = "settings_start_on_boot_delay" ; + public static final String PREFS_KEY_SETTINGS_SCALING = "settings_scaling" ; + public static final String PREFS_KEY_SETTINGS_VIEW_ONLY = "settings_view_only" ; + public static final String PREFS_KEY_SETTINGS_SHOW_POINTERS = "settings_show_pointers" ; + public static final String PREFS_KEY_SETTINGS_ACCESS_KEY = "settings_access_key"; + public static final String PREFS_KEY_SETTINGS_FILE_TRANSFER = "settings_file_transfer"; + + /* + persisted runtime values shared between components + */ + public static final String PREFS_KEY_SERVER_LAST_SCALING = "server_last_scaling" ; + public static final String PREFS_KEY_INPUT_LAST_ENABLED = "input_last_enabled" ; +} diff --git a/app/src/main/java/com/appknox/vnc/Defaults.kt b/app/src/main/java/com/appknox/vnc/Defaults.kt new file mode 100644 index 0000000..1e6bcab --- /dev/null +++ b/app/src/main/java/com/appknox/vnc/Defaults.kt @@ -0,0 +1,111 @@ +package com.appknox.vnc + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import androidx.preference.PreferenceManager +import kotlinx.serialization.* +import kotlinx.serialization.json.* +import java.io.File +import java.util.UUID + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +class Defaults { + companion object { + private const val TAG = "Defaults" + private const val PREFS_KEY_DEFAULTS_ACCESS_KEY = "defaults_access_key" + } + + @EncodeDefault + var port = 5900 + private set + + @EncodeDefault + var portReverse = 5500 + private set + + @EncodeDefault + var portRepeater = 5500 + private set + + @EncodeDefault + var scaling = 1.0f + private set + + @EncodeDefault + var viewOnly = false + private set + + @EncodeDefault + var showPointers = false + private set + + @EncodeDefault + var fileTransfer = true + private set + + @EncodeDefault + var password = "" + private set + + @EncodeDefault + var accessKey = "" + private set + + @EncodeDefault + var startOnBoot = true + private set + + @EncodeDefault + var startOnBootDelay = 0 + private set + + /* + NB if adding fields here, don't forget to add their copying in the constructor as well! + */ + + constructor(context: Context) { + /* + persist randomly generated defaults + */ + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val defaultAccessKey = prefs.getString(PREFS_KEY_DEFAULTS_ACCESS_KEY, null) + if (defaultAccessKey == null) { + val ed: SharedPreferences.Editor = prefs.edit() + ed.putString( + PREFS_KEY_DEFAULTS_ACCESS_KEY, + UUID.randomUUID().toString().replace("-".toRegex(), "") + ) + ed.apply() + } + this.accessKey = prefs.getString(PREFS_KEY_DEFAULTS_ACCESS_KEY, null)!! + + /* + read provided defaults + */ + val jsonFile = File(context.getExternalFilesDir(null), "defaults.json") + try { + val jsonString = jsonFile.readText() + val readDefault = Json.decodeFromString(jsonString) + this.port = readDefault.port + this.portReverse = readDefault.portReverse + this.portRepeater = readDefault.portRepeater + this.fileTransfer = readDefault.fileTransfer + this.scaling = readDefault.scaling + this.viewOnly = readDefault.viewOnly + this.showPointers = readDefault.showPointers + this.password = readDefault.password + // only set new access key if there is one given; i.e. don't overwrite generated default + // with empty string + if (readDefault.accessKey != "") + this.accessKey = readDefault.accessKey + this.startOnBoot = readDefault.startOnBoot + this.startOnBootDelay = readDefault.startOnBootDelay + // add here! + } catch (e: Exception) { + Log.w(TAG, "${e.message}") + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/appknox/vnc/InputPointerView.kt b/app/src/main/java/com/appknox/vnc/InputPointerView.kt new file mode 100644 index 0000000..6c4ff37 --- /dev/null +++ b/app/src/main/java/com/appknox/vnc/InputPointerView.kt @@ -0,0 +1,125 @@ +package com.appknox.vnc + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PixelFormat +import android.hardware.display.DisplayManager +import android.os.Build +import android.util.DisplayMetrics +import android.view.Display +import android.view.Gravity +import android.view.View +import android.view.WindowManager +import java.lang.IllegalArgumentException + +@SuppressLint("ViewConstructor") +class InputPointerView( + context: Context, + private val displayId: Int, + val red: Float, + val green: Float, + val blue: Float +) : View(context) { + +private val path: Path = Path() +private val windowManager: WindowManager + +private val paintFill: Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = Color.argb((0.9f * 255).toInt(), (red * 255).toInt(), (green * 255).toInt(), (blue * 255).toInt()) + } + +private val paintStroke: Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + color = Color.BLACK + strokeWidth = 0.8f * density + } + +private val density: Float + +private val layoutParams = WindowManager.LayoutParams( + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE + or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS + or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN, + PixelFormat.TRANSLUCENT + ) + + init { + val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + + // get density for later drawing in size adapted to display + val metrics = DisplayMetrics() + displayManager.getDisplay(displayId).getRealMetrics(metrics) + density = metrics.density + + windowManager = if (displayId != Display.DEFAULT_DISPLAY) { + if (Build.VERSION.SDK_INT < 32) { + // technically, the API is there on API level 30, but WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY + // only works on another display starting with API level 32, on 31 we get a BadTokenException + throw IllegalArgumentException("On API level < 32, can only be called with the default display id") + } + // other display's window manager + val windowContext = context.createDisplayContext(displayManager.getDisplay(displayId)) + .createWindowContext(WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY, null) + windowContext.getSystemService(Context.WINDOW_SERVICE) as WindowManager + } else { + // default display's window manager + context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + } + } + + // Set the size of the view + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + // Set the dimensions of the view + setMeasuredDimension((24f * density).toInt(), (24f * density).toInt()) + } + + // Draw the pointer + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + path.reset() + path.moveTo(1f * density, 1f * density) + path.lineTo(12f * density, 8f * density) + path.lineTo(5f * density, 15f * density) + path.close() + + canvas.drawPath(path, paintFill) + canvas.drawPath(path, paintStroke) + } + + /** + * Add input pointer view to display specified in constructor. + */ + fun addView() { + // attach to display + layoutParams.gravity = Gravity.TOP or Gravity.START + windowManager.addView(this, layoutParams) + } + + /** + * Remove input pointer view from display specified in constructor. + */ + fun removeView() { + windowManager.removeView(this) + } + + /** + * Position input pointer view on display specified in constructor. + */ + fun positionView(x: Int, y: Int) { + layoutParams.x = x + layoutParams.y = y + windowManager.updateViewLayout(this, layoutParams) + } + + } + diff --git a/app/src/main/java/com/appknox/vnc/InputRequestActivity.java b/app/src/main/java/com/appknox/vnc/InputRequestActivity.java new file mode 100644 index 0000000..c549dbd --- /dev/null +++ b/app/src/main/java/com/appknox/vnc/InputRequestActivity.java @@ -0,0 +1,92 @@ +package com.appknox.vnc; + +import android.app.AlertDialog; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.os.Bundle; +import android.provider.Settings; +import android.util.Log; + +import androidx.appcompat.app.AppCompatActivity; + +public class InputRequestActivity extends AppCompatActivity { + + private static final String TAG = "InputRequestActivity"; + private static final int REQUEST_INPUT = 43; + + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // if VIEW_ONLY is set, bail out early without bothering the user + if(getIntent().getBooleanExtra(VNCService.EXTRA_VIEW_ONLY, new Defaults(this).getViewOnly())) { + postResultAndFinish(false); + return; + } + + if(!InputService.isConnected()) { + new AlertDialog.Builder(this) + .setCancelable(false) + .setTitle(R.string.input_a11y_title) + .setMessage(R.string.input_a11y_msg) + .setPositiveButton(R.string.yes, (dialog, which) -> { + Intent intent = new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS); + + // highlight entry on some devices, see https://stackoverflow.com/a/63214655/361413 + final String EXTRA_FRAGMENT_ARG_KEY = ":settings:fragment_args_key"; + final String EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args"; + Bundle bundle = new Bundle(); + String showArgs = getPackageName() + "/" + InputService.class.getName(); + bundle.putString(EXTRA_FRAGMENT_ARG_KEY, showArgs); + intent.putExtra(EXTRA_FRAGMENT_ARG_KEY, showArgs); + intent.putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, bundle); + + if (intent.resolveActivity(getPackageManager()) != null && !intent.resolveActivity(getPackageManager()).toString().contains("Stub")) + startActivityForResult(intent, REQUEST_INPUT); + else + new AlertDialog.Builder(InputRequestActivity.this) + .setMessage(R.string.input_a11y_act_not_found_msg) + .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> { + Intent generalSettingsIntent = new Intent(Settings.ACTION_SETTINGS); + try { + startActivityForResult(generalSettingsIntent, REQUEST_INPUT); + } catch(ActivityNotFoundException ignored) { + // This should not happen, but there were crashes reported from flaky devices + // so in this case do nothing instead of crashing. + } + }) + .show(); + }) + .setNegativeButton(getString(R.string.no), (dialog, which) -> postResultAndFinish(false)) + .show(); + } else { + postResultAndFinish(true); + } + + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQUEST_INPUT) { + Log.d(TAG, "onActivityResult"); + postResultAndFinish(InputService.isConnected()); + } + } + + private void postResultAndFinish(boolean isA11yEnabled) { + + if (isA11yEnabled) + Log.i(TAG, "a11y enabled"); + else + Log.i(TAG, "a11y disabled"); + + Intent intent = new Intent(this, VNCService.class); + intent.setAction(VNCService.ACTION_HANDLE_INPUT_RESULT); + intent.putExtra(VNCService.EXTRA_INPUT_RESULT, isA11yEnabled); + startService(intent); + finish(); + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/appknox/vnc/InputService.java b/app/src/main/java/com/appknox/vnc/InputService.java new file mode 100644 index 0000000..bf8185f --- /dev/null +++ b/app/src/main/java/com/appknox/vnc/InputService.java @@ -0,0 +1,416 @@ +package com.appknox.vnc; + +import android.accessibilityservice.AccessibilityService; +import android.accessibilityservice.GestureDescription; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.os.Build; +import android.os.Handler; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.Display; +import android.view.WindowManager; +import android.view.accessibility.AccessibilityEvent; +import android.view.ViewConfiguration; +import android.graphics.Path; + +import androidx.preference.PreferenceManager; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class InputService extends AccessibilityService { + static InputWrapperService inputWrapperService; + + /** + * This tracks gesture completion per client. + */ + private static class GestureCallback extends AccessibilityService.GestureResultCallback { + private boolean mCompleted = true; // initially true so we can actually dispatch something + + @Override + public synchronized void onCompleted(GestureDescription gestureDescription) { + mCompleted = true; + } + + @Override + public synchronized void onCancelled(GestureDescription gestureDescription) { + mCompleted = true; + } + } + + /** + * Per-client input context. + */ + static class InputContext { + // pointer-related + boolean isButtonOneDown; + Path path = new Path(); + long lastGestureStartTime; + GestureCallback gestureCallback = new GestureCallback(); + InputPointerView pointerView; + // keyboard-related + boolean isKeyCtrlDown; + boolean isKeyAltDown; + boolean isKeyShiftDown; + boolean isKeyDelDown; + boolean isKeyEscDown; + + private int displayId; + + int getDisplayId() {return displayId;} + + /** + * Sets a new display id, recreates pointer view if necessary. + */ + void setDisplayId(int displayId) { + // set display id + this.displayId = displayId; + // and if there is a pointer, recreate it with the new display id + if(pointerView != null) { + pointerView.removeView(); + pointerView = new InputPointerView( + instance, + displayId, + pointerView.getRed(), + pointerView.getGreen(), + pointerView.getBlue() + ); + pointerView.addView(); + } + } + } + + private static final String TAG = "InputService"; + + private static InputService instance; + /** + * Scaling factor that's applied to incoming pointer events by dividing coordinates by + * the given factor. + */ + static float scaling; + static boolean isEnabled; + + private Handler mMainHandler; + + private final Map mInputContexts = new ConcurrentHashMap<>(); + + + @Override + public void onAccessibilityEvent( AccessibilityEvent event ) { } + + @Override + public void onInterrupt() { } + + @Override + public void onServiceConnected() + { + super.onServiceConnected(); + inputWrapperService = new InputWrapperService(); + instance = this; + isEnabled = PreferenceManager.getDefaultSharedPreferences(this).getBoolean(Constants.PREFS_KEY_INPUT_LAST_ENABLED, !new Defaults(this).getViewOnly()); + scaling = PreferenceManager.getDefaultSharedPreferences(this).getFloat(Constants.PREFS_KEY_SERVER_LAST_SCALING, new Defaults(this).getScaling()); + mMainHandler = new Handler(instance.getMainLooper()); + Log.i(TAG, "onServiceConnected"); + } + + @Override + public void onDestroy() { + super.onDestroy(); + instance = null; + inputWrapperService = null; + Log.i(TAG, "onDestroy"); + } + + public static boolean isConnected() + { + return instance != null; + } + + public static void addClient(long client, boolean withPointer) { + // NB runs on a worker thread! + try { + int displayId = Display.DEFAULT_DISPLAY; + InputContext inputContext = new InputContext(); + inputContext.setDisplayId(displayId); + if(withPointer) { + inputContext.pointerView = new InputPointerView( + instance, + displayId, + 0.4f * ((instance.mInputContexts.size() + 1) % 3), + 0.2f * ((instance.mInputContexts.size() + 1) % 5), + 1.0f * ((instance.mInputContexts.size() + 1) % 2) + ); + // run this on UI thread (use main handler as view is not yet added) + instance.mMainHandler.post(() -> inputContext.pointerView.addView()); + } + instance.mInputContexts.put(client, inputContext); + } catch (Exception e) { + Log.e(TAG, "addClient: " + e); + } + } + + public static void removeClient(long client) { + // NB runs on a worker thread! + try { + InputContext inputContext = instance.mInputContexts.get(client); + if(inputContext != null && inputContext.pointerView != null) { + // run this on UI thread + inputContext.pointerView.post(inputContext.pointerView::removeView); + } + instance.mInputContexts.remove(client); + } catch (Exception e) { + Log.e(TAG, "removeClient: " + e); + } + } + + @SuppressWarnings("unused") + public static void onPointerEvent(int buttonMask, int x, int y, long client) { + + if(!isEnabled) { + return; + } + + try { + InputContext inputContext = instance.mInputContexts.get(client); + + if(inputContext == null) { + throw new IllegalStateException("Client " + client + " was not added or is already removed"); + } + + x /= scaling; + y /= scaling; + + /* + draw pointer + */ + InputPointerView pointerView = inputContext.pointerView; + if (pointerView != null) { + // showing pointers is enabled + int finalX = x; + int finalY = y; + pointerView.post(() -> pointerView.positionView(finalX, finalY)); + } + + /* + left mouse button + */ + + // down, was up + if ((buttonMask & (1 << 0)) != 0 && !inputContext.isButtonOneDown) { + inputContext.isButtonOneDown = true; + instance.startGesture(inputContext, x, y); + } + + // down, was down + if ((buttonMask & (1 << 0)) != 0 && inputContext.isButtonOneDown) { + instance.continueGesture(inputContext, x, y); + } + + // up, was down + if ((buttonMask & (1 << 0)) == 0 && inputContext.isButtonOneDown) { + inputContext.isButtonOneDown = false; + instance.endGesture(inputContext, x, y); + } + + + // right mouse button + if ((buttonMask & (1 << 2)) != 0) { + instance.longPress(inputContext, x, y); + } + + // scroll up + if ((buttonMask & (1 << 3)) != 0) { + + DisplayMetrics displayMetrics = new DisplayMetrics(); + WindowManager wm = (WindowManager) instance.getApplicationContext().getSystemService(Context.WINDOW_SERVICE); + wm.getDefaultDisplay().getRealMetrics(displayMetrics); + + instance.scroll(inputContext, x, y, -displayMetrics.heightPixels / 2); + } + + // scroll down + if ((buttonMask & (1 << 4)) != 0) { + + DisplayMetrics displayMetrics = new DisplayMetrics(); + WindowManager wm = (WindowManager) instance.getApplicationContext().getSystemService(Context.WINDOW_SERVICE); + wm.getDefaultDisplay().getRealMetrics(displayMetrics); + + instance.scroll(inputContext, x, y, displayMetrics.heightPixels / 2); + } + } catch (Exception e) { + // instance probably null + Log.e(TAG, "onPointerEvent: failed: " + Log.getStackTraceString(e)); + } + } + + public static void onKeyEvent(int down, long keysym, long client) { + + if(!isEnabled) { + return; + } + + Log.d(TAG, "onKeyEvent: keysym " + keysym + " down " + down + " by client " + client); + + /* + Special key handling. + */ + try { + InputContext inputContext = instance.mInputContexts.get(client); + + /* processKey(down, keysym); */ + /* processSystemEvent(down, (int) keysym, inputContext); */ + + inputWrapperService.processKey(down, keysym); + inputWrapperService.processSystemEvent(down, (int) keysym, inputContext); + + if(inputContext == null) { + throw new IllegalStateException("Client " + client + " was not added or is already removed"); + } + + if(keysym == 0xFFE3) + inputContext.isKeyCtrlDown = down != 0; + + if(keysym == 0xFFE9 || keysym == 0xFF7E) // MacOS clients send Alt as 0xFF7E + inputContext.isKeyAltDown = down != 0; + + if(keysym == 0xFFE1) + inputContext.isKeyShiftDown = down != 0; + + if(keysym == 0xFFFF) + inputContext.isKeyDelDown = down != 0; + + if(keysym == 0xFF1B) + inputContext.isKeyEscDown = down != 0; + + if(inputContext.isKeyCtrlDown && inputContext.isKeyAltDown && inputContext.isKeyDelDown) { + Log.i(TAG, "onKeyEvent: got Ctrl-Alt-Del"); + instance.mMainHandler.post(VNCService::togglePortraitInLandscapeWorkaround); + } + + if(inputContext.isKeyCtrlDown && inputContext.isKeyShiftDown && inputContext.isKeyEscDown) { + Log.i(TAG, "onKeyEvent: got Ctrl-Shift-Esc"); + instance.performGlobalAction(AccessibilityService.GLOBAL_ACTION_RECENTS); + } + + if (keysym == 0xFF50 && down != 0) { + Log.i(TAG, "onKeyEvent: got Home/Pos1"); + instance.performGlobalAction(AccessibilityService.GLOBAL_ACTION_HOME); + } + + if(keysym == 0xFF1B && down != 0) { + Log.i(TAG, "onKeyEvent: got Esc"); + instance.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK); + } + + } catch (Exception e) { + Log.e(TAG, "onKeyEvent: failed: " + e); + } + + } + + public static void onCutText(String text, long client) { + + if(!isEnabled) { + return; + } + + Log.d(TAG, "onCutText: text '" + text + "' by client " + client); + + try { + instance.mMainHandler.post(() -> { + try { + ((ClipboardManager) instance.getSystemService(Context.CLIPBOARD_SERVICE)).setPrimaryClip(ClipData.newPlainText(text, text)); + } catch (Exception e) { + // some other error on main thread + Log.e(TAG, "onCutText: failed: " + e); + } + } + ); + } catch (Exception e) { + // instance probably null + Log.e(TAG, "onCutText: failed: " + e); + } + } + + private void startGesture(InputContext inputContext, int x, int y) { + inputContext.path.reset(); + inputContext.path.moveTo( x, y ); + inputContext.lastGestureStartTime = System.currentTimeMillis(); + } + + private void continueGesture(InputContext inputContext, int x, int y) { + inputContext.path.lineTo( x, y ); + } + + private void endGesture(InputContext inputContext, int x, int y) { + inputContext.path.lineTo( x, y ); + long duration = System.currentTimeMillis() - inputContext.lastGestureStartTime; + // gesture ended very very shortly after start (< 1ms). make it 1ms to get dispatched to the system + if (duration == 0) duration = 1; + GestureDescription.StrokeDescription stroke = new GestureDescription.StrokeDescription( inputContext.path, 0, duration); + GestureDescription.Builder builder = new GestureDescription.Builder(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + builder.setDisplayId(inputContext.getDisplayId()); + } + builder.addStroke(stroke); + // Docs says: Any gestures currently in progress, whether from the user, this service, or another service, will be cancelled. + // But at least on API level 32, setting different display ids with the builder allows for parallel input. + dispatchGesture(builder.build(), null, null); + } + + + private void longPress(InputContext inputContext, int x, int y ) + { + dispatchGesture( createClick(inputContext, x, y, ViewConfiguration.getTapTimeout() + ViewConfiguration.getLongPressTimeout()), null, null ); + } + + private void scroll(InputContext inputContext, int x, int y, int scrollAmount ) + { + /* + Ignore if another gesture is still ongoing. Especially true for scroll events: + These mouse button 4,5 events come per each virtual scroll wheel click, an incoming + event would cancel the preceding one, only actually scrolling when the user stopped + scrolling. + */ + if(!inputContext.gestureCallback.mCompleted) + return; + + inputContext.gestureCallback.mCompleted = false; + dispatchGesture(createSwipe(inputContext, x, y, x, y - scrollAmount, ViewConfiguration.getScrollDefaultDelay()), inputContext.gestureCallback, null); + } + + private static GestureDescription createClick(InputContext inputContext, int x, int y, int duration ) + { + Path clickPath = new Path(); + clickPath.moveTo( x, y ); + GestureDescription.StrokeDescription clickStroke = new GestureDescription.StrokeDescription( clickPath, 0, duration ); + GestureDescription.Builder clickBuilder = new GestureDescription.Builder(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + clickBuilder.setDisplayId(inputContext.getDisplayId()); + } + clickBuilder.addStroke( clickStroke ); + return clickBuilder.build(); + } + + private static GestureDescription createSwipe(InputContext inputContext, int x1, int y1, int x2, int y2, int duration ) + { + Path swipePath = new Path(); + + x1 = Math.max(x1, 0); + y1 = Math.max(y1, 0); + x2 = Math.max(x2, 0); + y2 = Math.max(y2, 0); + + swipePath.moveTo( x1, y1 ); + swipePath.lineTo( x2, y2 ); + GestureDescription.StrokeDescription swipeStroke = new GestureDescription.StrokeDescription( swipePath, 0, duration ); + GestureDescription.Builder swipeBuilder = new GestureDescription.Builder(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + swipeBuilder.setDisplayId(inputContext.getDisplayId()); + } + swipeBuilder.addStroke( swipeStroke ); + return swipeBuilder.build(); + } +} diff --git a/app/src/main/java/com/appknox/vnc/InputWrapperService.java b/app/src/main/java/com/appknox/vnc/InputWrapperService.java new file mode 100644 index 0000000..daef335 --- /dev/null +++ b/app/src/main/java/com/appknox/vnc/InputWrapperService.java @@ -0,0 +1,63 @@ +package com.appknox.vnc; + +import android.util.Log; +import android.view.KeyEvent; + +import java.io.IOException; + +public class InputWrapperService { + + private final KeyboardMap keyboardMap; + public InputWrapperService() { + keyboardMap = new KeyboardMap(); + } + + private String retrieveKeyStroke(int keyId) { + boolean status = keyboardMap.getKeyMaps().containsKey(keyId); + if (status) + return keyboardMap.getKeyMaps().get(keyId); + + return ""; + } + + private int retrieveEvent(String key) { + boolean status = keyboardMap.getKeyEvents().containsKey(key); + if (status) { + return keyboardMap.getKeyEvents().get(key); + } + + return 0; + } + + public void processKey(int status, long keycode) { + String code = this.retrieveKeyStroke((int) keycode); + + if (status == 1) + sendKey(code); + } + + public void processSystemEvent(int status, int keycode, InputService.InputContext context) { + int keyCode = this.retrieveEvent(String.valueOf(keycode)); + + if (status == 1) + sendKeyEvent(keyCode, context); + + } + + private static void sendKey(String keycode) { + try { + Runtime.getRuntime().exec(new String[] { "su", "-c","input text " + "\"" + keycode + "\"" }); + Log.d("shell_input_key_send", String.valueOf(keycode)); + } catch (IOException ignored) {} + } + + private static void sendKeyEvent(int keycode, InputService.InputContext inputContext) { + try { + if (inputContext.isKeyShiftDown) + Runtime.getRuntime().exec(new String[] { "su", "-c","input keycombination 59 " + keycode }); + else + Runtime.getRuntime().exec(new String[] { "su", "-c","input keyevent " + keycode }); + Log.d("KeyMapEv", String.valueOf(keycode)); + } catch (IOException ignored) {} + } +} diff --git a/app/src/main/java/com/appknox/vnc/KeyboardMap.java b/app/src/main/java/com/appknox/vnc/KeyboardMap.java new file mode 100644 index 0000000..9fdb38e --- /dev/null +++ b/app/src/main/java/com/appknox/vnc/KeyboardMap.java @@ -0,0 +1,133 @@ +package com.appknox.vnc; + +import android.view.KeyEvent; +import java.util.HashMap; + +public class KeyboardMap +{ + private HashMap keyMaps; + private HashMap keyEvents; + public KeyboardMap() { + keyMaps = new HashMap() {}; + keyEvents = new HashMap() {}; + + keyEvents.put("65288", KeyEvent.KEYCODE_DEL); + keyEvents.put("65293", KeyEvent.KEYCODE_ENTER); + + keyEvents.put("65361", KeyEvent.KEYCODE_DPAD_LEFT); + keyEvents.put("65363", KeyEvent.KEYCODE_DPAD_RIGHT); + keyEvents.put("65362", KeyEvent.KEYCODE_DPAD_UP); + keyEvents.put("65364", KeyEvent.KEYCODE_DPAD_DOWN); + + keyMaps.put(32, " "); + keyMaps.put(33, "!"); + keyMaps.put(34, "\""); + keyMaps.put(35, "#"); + + keyMaps.put(36, "$"); + keyMaps.put(37, "%"); + keyMaps.put(38, "&"); + keyMaps.put(39, "'"); + + keyMaps.put(40, "("); + keyMaps.put(41, ")"); + keyMaps.put(42, "*"); + keyMaps.put(43, "+"); + + keyMaps.put(44, ","); + keyMaps.put(45, "-"); + keyMaps.put(46, "."); + keyMaps.put(47, "/"); + + + keyMaps.put(48, "0"); + keyMaps.put(49, "1"); + keyMaps.put(50, "2"); + keyMaps.put(51, "3"); + + keyMaps.put(52, "4"); + keyMaps.put(53, "5"); + keyMaps.put(54, "6"); + keyMaps.put(55, "7"); + + keyMaps.put(56, "8"); + keyMaps.put(57, "9"); + + keyMaps.put(58, ":"); + keyMaps.put(59, ";"); + keyMaps.put(60, "<"); + keyMaps.put(61, "="); + + keyMaps.put(62, ">"); + keyMaps.put(63, "?"); + keyMaps.put(64, "@"); + + keyMaps.put(65, "A"); + keyMaps.put(66, "B"); + keyMaps.put(67, "C"); + keyMaps.put(68, "D"); + keyMaps.put(69, "E"); + keyMaps.put(70, "F"); + keyMaps.put(71, "G"); + keyMaps.put(72, "H"); + keyMaps.put(73, "I"); + keyMaps.put(74, "J"); + keyMaps.put(75, "K"); + keyMaps.put(76, "L"); + keyMaps.put(77, "M"); + keyMaps.put(78, "N"); + keyMaps.put(79, "O"); + keyMaps.put(80, "P"); + keyMaps.put(81, "Q"); + keyMaps.put(82, "R"); + keyMaps.put(83, "S"); + keyMaps.put(84, "T"); + keyMaps.put(85, "U"); + keyMaps.put(86, "V"); + keyMaps.put(87, "W"); + keyMaps.put(88, "X"); + keyMaps.put(89, "Y"); + keyMaps.put(90, "Z"); + + keyMaps.put(97, "a"); + keyMaps.put(98, "b"); + keyMaps.put(99, "c"); + keyMaps.put(100, "d"); + keyMaps.put(101, "e"); + keyMaps.put(102, "f"); + keyMaps.put(103, "g"); + keyMaps.put(104, "h"); + keyMaps.put(105, "i"); + keyMaps.put(106, "j"); + keyMaps.put(107, "k"); + keyMaps.put(108, "l"); + keyMaps.put(109, "m"); + keyMaps.put(110, "n"); + keyMaps.put(111, "o"); + keyMaps.put(112, "p"); + keyMaps.put(113, "q"); + keyMaps.put(114, "r"); + keyMaps.put(115, "s"); + keyMaps.put(116, "t"); + keyMaps.put(117, "u"); + keyMaps.put(118, "v"); + keyMaps.put(119, "w"); + keyMaps.put(120, "x"); + keyMaps.put(121, "y"); + keyMaps.put(122, "z"); + + keyMaps.put(96, "`"); + keyMaps.put(123, "{"); + keyMaps.put(124, "|"); + keyMaps.put(125, "}"); + keyMaps.put(126, "~"); + } + + public HashMap getKeyMaps() { + return keyMaps; + } + + public HashMap getKeyEvents() { + return keyEvents; + } +} diff --git a/app/src/main/java/com/appknox/vnc/MainActivity.java b/app/src/main/java/com/appknox/vnc/MainActivity.java new file mode 100644 index 0000000..91859ac --- /dev/null +++ b/app/src/main/java/com/appknox/vnc/MainActivity.java @@ -0,0 +1,709 @@ +package com.appknox.vnc; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.content.res.Resources; +import android.os.Build; +import android.os.Bundle; + +import androidx.core.content.ContextCompat; +import androidx.preference.PreferenceManager; + +import android.text.Editable; +import android.text.InputType; +import android.text.TextWatcher; +import android.text.method.PasswordTransformationMethod; +import android.text.method.SingleLineTransformationMethod; +import android.util.Log; +import android.util.TypedValue; +import android.view.View; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; + +import com.google.android.material.slider.Slider; +import com.google.android.material.switchmaterial.SwitchMaterial; + +import java.util.ArrayList; +import java.util.UUID; + +public class MainActivity extends AppCompatActivity { + + private static final String TAG = "MainActivity"; + private static final String PREFS_KEY_REVERSE_VNC_LAST_HOST = "reverse_vnc_last_host" ; + private static final String PREFS_KEY_REPEATER_VNC_LAST_HOST = "repeater_vnc_last_host" ; + private static final String PREFS_KEY_REPEATER_VNC_LAST_ID = "repeater_vnc_last_id" ; + + private Button mButtonToggle; + private TextView mAddress; + private boolean mIsMainServiceRunning; + private BroadcastReceiver mMainServiceBroadcastReceiver; + private AlertDialog mOutgoingConnectionWaitDialog; + private String mLastMainServiceRequestId; + private String mLastReverseHost; + private int mLastReversePort; + private String mLastRepeaterHost; + private int mLastRepeaterPort; + private String mLastRepeaterId; + private Defaults mDefaults; + + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + mDefaults = new Defaults(this); + + mButtonToggle = findViewById(R.id.toggle); + mButtonToggle.setOnClickListener(view -> { + + Intent intent = new Intent(MainActivity.this, VNCService.class); + intent.putExtra(VNCService.EXTRA_PORT, prefs.getInt(Constants.PREFS_KEY_SETTINGS_PORT, mDefaults.getPort())); + intent.putExtra(VNCService.EXTRA_PASSWORD, prefs.getString(Constants.PREFS_KEY_SETTINGS_PASSWORD, mDefaults.getPassword())); + intent.putExtra(VNCService.EXTRA_FILE_TRANSFER, prefs.getBoolean(Constants.PREFS_KEY_SETTINGS_FILE_TRANSFER, mDefaults.getFileTransfer())); + intent.putExtra(VNCService.EXTRA_VIEW_ONLY, prefs.getBoolean(Constants.PREFS_KEY_SETTINGS_VIEW_ONLY, mDefaults.getViewOnly())); + intent.putExtra(VNCService.EXTRA_SHOW_POINTERS, prefs.getBoolean(Constants.PREFS_KEY_SETTINGS_SHOW_POINTERS, mDefaults.getShowPointers())); + intent.putExtra(VNCService.EXTRA_SCALING, prefs.getFloat(Constants.PREFS_KEY_SETTINGS_SCALING, mDefaults.getScaling())); + if(mIsMainServiceRunning) { + intent.setAction(VNCService.ACTION_STOP); + } + else { + intent.setAction(VNCService.ACTION_START); + } + mButtonToggle.setEnabled(false); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(intent); + } else { + startService(intent); + } + + }); + + mAddress = findViewById(R.id.address); + + Button reverseVNC = findViewById(R.id.reverse_vnc); + reverseVNC.setOnClickListener(view -> { + + final EditText inputText = new EditText(this); + inputText.setInputType(InputType.TYPE_CLASS_TEXT); + inputText.setHint(getString(R.string.main_activity_reverse_vnc_hint)); + String lastHost = prefs.getString(PREFS_KEY_REVERSE_VNC_LAST_HOST, null); + if(lastHost != null) { + inputText.setText(lastHost); + // select all to make new input quicker + inputText.setSelectAllOnFocus(true); + } + inputText.requestFocus(); + inputText.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + + LinearLayout inputLayout = new LinearLayout(this); + inputLayout.setPadding( + (int)TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 20, Resources.getSystem().getDisplayMetrics()), + (int)TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10, Resources.getSystem().getDisplayMetrics()), + (int)TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 20, Resources.getSystem().getDisplayMetrics()), + 0 + ); + inputLayout.addView(inputText); + + AlertDialog dialog = new AlertDialog.Builder(this) + .setTitle(R.string.main_activity_reverse_vnc_button) + .setView(inputLayout) + .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> { + // parse host and port parts + String[] parts = inputText.getText().toString().split("\\:"); + String host = parts[0]; + int port = mDefaults.getPortReverse(); + if (parts.length > 1) { + try { + port = Integer.parseInt(parts[1]); + } catch(NumberFormatException unused) { + // stays at default reverse port + } + } + Log.d(TAG, "reverse vnc " + host + ":" + port); + mLastMainServiceRequestId = UUID.randomUUID().toString(); + mLastReverseHost = host; + mLastReversePort = port; + Intent request = new Intent(MainActivity.this, VNCService.class); + request.setAction(VNCService.ACTION_CONNECT_REVERSE); + request.putExtra(VNCService.EXTRA_HOST, host); + request.putExtra(VNCService.EXTRA_PORT, port); + request.putExtra(VNCService.EXTRA_REQUEST_ID, mLastMainServiceRequestId); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(request); + } else { + startService(request); + } + + // show a progress dialog + ProgressBar progressBar = new ProgressBar(this); + progressBar.setPadding(0,0,0, (int) (30 * getResources().getDisplayMetrics().density)); + mOutgoingConnectionWaitDialog = new AlertDialog.Builder(this) + .setCancelable(false) + .setTitle(R.string.main_activity_reverse_vnc_button) + .setMessage(getString(R.string.main_activity_connecting_to, host + ":" + port)) + .setView(progressBar) + .show(); + }) + .setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> dialogInterface.cancel()) + .create(); + dialog.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE); + dialog.show(); + }); + + Button repeaterVNC = findViewById(R.id.repeater_vnc); + repeaterVNC.setOnClickListener(view -> { + + final EditText hostInputText = new EditText(this); + hostInputText.setInputType(InputType.TYPE_CLASS_TEXT); + hostInputText.setHint(getString(R.string.main_activity_repeater_vnc_hint)); + String lastHost = prefs.getString(PREFS_KEY_REPEATER_VNC_LAST_HOST, ""); + hostInputText.setText(lastHost); //host:port + hostInputText.requestFocus(); + hostInputText.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + + final EditText idInputText = new EditText(this); + idInputText.setInputType(InputType.TYPE_CLASS_NUMBER); + idInputText.setHint(getString(R.string.main_activity_repeater_vnc_hint_id)); + String lastID = prefs.getString(PREFS_KEY_REPEATER_VNC_LAST_ID, ""); + idInputText.setText(lastID); //host:port + idInputText.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + + LinearLayout inputLayout = new LinearLayout(this); + inputLayout.setPadding( + (int)TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 20, Resources.getSystem().getDisplayMetrics()), + (int)TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10, Resources.getSystem().getDisplayMetrics()), + (int)TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 20, Resources.getSystem().getDisplayMetrics()), + 0 + ); + inputLayout.setOrientation(LinearLayout.VERTICAL); + inputLayout.addView(hostInputText); + inputLayout.addView(idInputText); + + AlertDialog dialog = new AlertDialog.Builder(this) + .setTitle(R.string.main_activity_repeater_vnc_button) + .setView(inputLayout) + .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> { + // parse host and port parts + String[] parts = hostInputText.getText().toString().split("\\:"); + String host = parts[0]; + int port = mDefaults.getPortRepeater(); + if (parts.length > 1) { + try { + port = Integer.parseInt(parts[1]); + } catch(NumberFormatException unused) { + // stays at default repeater port + } + } + // parse ID + String repeaterId = idInputText.getText().toString(); + // sanity-check + if (host.isEmpty() || repeaterId.isEmpty()) { + Toast.makeText(MainActivity.this, getString(R.string.main_activity_repeater_vnc_input_missing), Toast.LENGTH_LONG).show(); + return; + } + // done + Log.d(TAG, "repeater vnc " + host + ":" + port + ":" + repeaterId); + mLastMainServiceRequestId = UUID.randomUUID().toString(); + mLastRepeaterHost = host; + mLastRepeaterPort = port; + mLastRepeaterId = repeaterId; + Intent request = new Intent(MainActivity.this, VNCService.class); + request.setAction(VNCService.ACTION_CONNECT_REPEATER); + request.putExtra(VNCService.EXTRA_HOST, host); + request.putExtra(VNCService.EXTRA_PORT, port); + request.putExtra(VNCService.EXTRA_REPEATER_ID, repeaterId); + request.putExtra(VNCService.EXTRA_REQUEST_ID, mLastMainServiceRequestId); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(request); + } else { + startService(request); + } + // show a progress dialog + ProgressBar progressBar = new ProgressBar(this); + progressBar.setPadding(0,0,0, (int) (30 * getResources().getDisplayMetrics().density)); + mOutgoingConnectionWaitDialog = new AlertDialog.Builder(this) + .setCancelable(false) + .setTitle(R.string.main_activity_repeater_vnc_button) + .setMessage(getString(R.string.main_activity_connecting_to, host + ":" + port + " - " + repeaterId)) + .setView(progressBar) + .show(); + }) + .setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> dialogInterface.cancel()) + .create(); + dialog.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE); + dialog.show(); + }); + + + final EditText port = findViewById(R.id.settings_port); + if(prefs.getInt(Constants.PREFS_KEY_SETTINGS_PORT, mDefaults.getPort()) < 0) { + port.setHint(R.string.main_activity_settings_port_not_listening); + } else { + port.setText(String.valueOf(prefs.getInt(Constants.PREFS_KEY_SETTINGS_PORT, mDefaults.getPort()))); + } + port.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) { + } + + @Override + public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) { + try { + SharedPreferences.Editor ed = prefs.edit(); + ed.putInt(Constants.PREFS_KEY_SETTINGS_PORT, Integer.parseInt(charSequence.toString())); + ed.apply(); + } catch(NumberFormatException e) { + // nop + } + } + + @Override + public void afterTextChanged(Editable editable) { + if(port.getText().length() == 0) { + // hint that not listening + port.setHint(R.string.main_activity_settings_port_not_listening); + // and set default + SharedPreferences.Editor ed = prefs.edit(); + ed.putInt(Constants.PREFS_KEY_SETTINGS_PORT, -1); + ed.apply(); + } + } + }); + port.setOnFocusChangeListener((v, hasFocus) -> { + // move cursor to end of text + port.setSelection(port.getText().length()); + }); + + final EditText password = findViewById(R.id.settings_password); + password.setText(prefs.getString(Constants.PREFS_KEY_SETTINGS_PASSWORD, mDefaults.getPassword())); + password.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) { + + } + + @Override + public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) { + // only save new value if it differs from the default and was not saved before + if(!(prefs.getString(Constants.PREFS_KEY_SETTINGS_PASSWORD, null) == null && charSequence.toString().equals(mDefaults.getPassword()))) { + SharedPreferences.Editor ed = prefs.edit(); + ed.putString(Constants.PREFS_KEY_SETTINGS_PASSWORD, charSequence.toString()); + ed.apply(); + } + } + + @Override + public void afterTextChanged(Editable editable) { + + } + }); + // show/hide password on focus change. NB that this triggers onTextChanged above, so we have + // to take special precautions there. + password.setOnFocusChangeListener((v, hasFocus) -> { + if (hasFocus) { + password.setTransformationMethod(new SingleLineTransformationMethod()); + } else { + password.setTransformationMethod(new PasswordTransformationMethod()); + } + // move cursor to end of text + password.setSelection(password.getText().length()); + }); + + final EditText accessKey = findViewById(R.id.settings_access_key); + accessKey.setText(prefs.getString(Constants.PREFS_KEY_SETTINGS_ACCESS_KEY, mDefaults.getAccessKey())); + accessKey.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) { + } + + @Override + public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) { + // only save new value if it differs from the default and was not saved before + if(!(prefs.getString(Constants.PREFS_KEY_SETTINGS_ACCESS_KEY, null) == null && charSequence.toString().equals(mDefaults.getAccessKey()))) { + SharedPreferences.Editor ed = prefs.edit(); + ed.putString(Constants.PREFS_KEY_SETTINGS_ACCESS_KEY, charSequence.toString()); + ed.apply(); + } + } + + @Override + public void afterTextChanged(Editable editable) { + } + }); + // show/hide access key on focus change. NB that this triggers onTextChanged above, so we have + // to take special precautions there. + accessKey.setOnFocusChangeListener((v, hasFocus) -> { + if (hasFocus) { + accessKey.setTransformationMethod(new SingleLineTransformationMethod()); + } else { + accessKey.setTransformationMethod(new PasswordTransformationMethod()); + // if value just saved was empty, reset preference and UI back to default + String savedAccessKey = prefs.getString(Constants.PREFS_KEY_SETTINGS_ACCESS_KEY, null); + if(savedAccessKey != null && savedAccessKey.isEmpty()) { + SharedPreferences.Editor ed = prefs.edit(); + ed.putString(Constants.PREFS_KEY_SETTINGS_ACCESS_KEY, mDefaults.getAccessKey()); + ed.apply(); + accessKey.setText(mDefaults.getAccessKey()); + } + } + // move cursor to end of text + accessKey.setSelection(accessKey.getText().length()); + }); + + final EditText startOnBootDelay = findViewById(R.id.settings_start_on_boot_delay); + startOnBootDelay.setText(String.valueOf(prefs.getInt(Constants.PREFS_KEY_SETTINGS_START_ON_BOOT_DELAY, mDefaults.getStartOnBootDelay()))); + startOnBootDelay.setEnabled(prefs.getBoolean(Constants.PREFS_KEY_SETTINGS_START_ON_BOOT, mDefaults.getStartOnBoot())); + startOnBootDelay.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) { + } + + @Override + public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) { + try { + SharedPreferences.Editor ed = prefs.edit(); + ed.putInt(Constants.PREFS_KEY_SETTINGS_START_ON_BOOT_DELAY, Integer.parseInt(charSequence.toString())); + ed.apply(); + } catch(NumberFormatException e) { + // nop + } + } + + @Override + public void afterTextChanged(Editable editable) { + // if value just saved was empty, reset preference and UI back to default + String savedAccessKey = prefs.getString(Constants.PREFS_KEY_SETTINGS_ACCESS_KEY, null); + if(savedAccessKey != null && savedAccessKey.isEmpty()) { + SharedPreferences.Editor ed = prefs.edit(); + ed.putString(Constants.PREFS_KEY_SETTINGS_ACCESS_KEY, mDefaults.getAccessKey()); + ed.apply(); + accessKey.setText(mDefaults.getAccessKey()); + } + + if(startOnBootDelay.getText().length() == 0) { + // reset to default + startOnBootDelay.setHint(String.valueOf(mDefaults.getStartOnBootDelay())); + // and remove preference + SharedPreferences.Editor ed = prefs.edit(); + ed.remove(Constants.PREFS_KEY_SETTINGS_START_ON_BOOT_DELAY); + ed.apply(); + } + } + }); + // move cursor to end of text + startOnBootDelay.setOnFocusChangeListener((v, hasFocus) -> startOnBootDelay.setSelection(startOnBootDelay.getText().length())); + + final SwitchMaterial startOnBoot = findViewById(R.id.settings_start_on_boot); + startOnBoot.setChecked(prefs.getBoolean(Constants.PREFS_KEY_SETTINGS_START_ON_BOOT, mDefaults.getStartOnBoot())); + startOnBoot.setOnCheckedChangeListener((compoundButton, b) -> { + SharedPreferences.Editor ed = prefs.edit(); + ed.putBoolean(Constants.PREFS_KEY_SETTINGS_START_ON_BOOT, b); + ed.apply(); + startOnBootDelay.setEnabled(b); + }); + + if(Build.VERSION.SDK_INT >= 33) { + // no use asking for permission on Android 13+, always denied. + // users can always read/write Documents and Downloads tough. + findViewById(R.id.settings_row_file_transfer).setVisibility(View.GONE); + } else { + final SwitchMaterial fileTransfer = findViewById(R.id.settings_file_transfer); + fileTransfer.setChecked(prefs.getBoolean(Constants.PREFS_KEY_SETTINGS_FILE_TRANSFER, mDefaults.getFileTransfer())); + fileTransfer.setOnCheckedChangeListener((compoundButton, b) -> { + SharedPreferences.Editor ed = prefs.edit(); + ed.putBoolean(Constants.PREFS_KEY_SETTINGS_FILE_TRANSFER, b); + ed.apply(); + }); + } + + Slider scaling = findViewById(R.id.settings_scaling); + scaling.setValue(prefs.getFloat(Constants.PREFS_KEY_SETTINGS_SCALING, mDefaults.getScaling())*100); + scaling.setLabelFormatter(value -> Math.round(value) + " %"); + scaling.addOnChangeListener((slider, value, fromUser) -> { + SharedPreferences.Editor ed = prefs.edit(); + ed.putFloat(Constants.PREFS_KEY_SETTINGS_SCALING, value/100); + ed.apply(); + }); + + final SwitchMaterial showPointers = findViewById(R.id.settings_show_pointers); + showPointers.setChecked(prefs.getBoolean(Constants.PREFS_KEY_SETTINGS_SHOW_POINTERS, mDefaults.getShowPointers())); + showPointers.setOnCheckedChangeListener((compoundButton, b) -> { + SharedPreferences.Editor ed = prefs.edit(); + ed.putBoolean(Constants.PREFS_KEY_SETTINGS_SHOW_POINTERS, b); + ed.apply(); + }); + showPointers.setEnabled(!prefs.getBoolean(Constants.PREFS_KEY_SETTINGS_VIEW_ONLY, mDefaults.getViewOnly())); + + final SwitchMaterial viewOnly = findViewById(R.id.settings_view_only); + viewOnly.setChecked(prefs.getBoolean(Constants.PREFS_KEY_SETTINGS_VIEW_ONLY, mDefaults.getViewOnly())); + viewOnly.setOnCheckedChangeListener((compoundButton, b) -> { + SharedPreferences.Editor ed = prefs.edit(); + ed.putBoolean(Constants.PREFS_KEY_SETTINGS_VIEW_ONLY, b); + ed.apply(); + // pointers depend on this one + showPointers.setEnabled(!b); + }); + + TextView about = findViewById(R.id.about); + // about.setText(getString(R.string.main_activity_about, BuildConfig.VERSION_NAME)); + + mMainServiceBroadcastReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (VNCService.ACTION_START.equals(intent.getAction())) { + if(intent.getBooleanExtra(VNCService.EXTRA_REQUEST_SUCCESS, false)) { + // was a successful START requested by anyone (but sent by VNCService, as the receiver is not exported!) + Log.d(TAG, "got VNCService started success event"); + onServerStarted(); + } else { + // was a failed START requested by anyone (but sent by VNCService, as the receiver is not exported!) + Log.d(TAG, "got VNCService started fail event"); + // if it was, by us, re-enable the button! + mButtonToggle.setEnabled(true); + // let focus stay on button + mButtonToggle.requestFocus(); + } + } + + if (VNCService.ACTION_STOP.equals(intent.getAction()) + && (intent.getBooleanExtra(VNCService.EXTRA_REQUEST_SUCCESS, true))) { + // was a successful STOP requested by anyone (but sent by VNCService, as the receiver is not exported!) + // or a STOP without any extras + Log.d(TAG, "got VNCService stopped event"); + onServerStopped(); + } + + if (VNCService.ACTION_CONNECT_REVERSE.equals(intent.getAction()) + && mLastMainServiceRequestId != null + && mLastMainServiceRequestId.equals(intent.getStringExtra(VNCService.EXTRA_REQUEST_ID))) { + // was a CONNECT_REVERSE requested by us + if (intent.getBooleanExtra(VNCService.EXTRA_REQUEST_SUCCESS, false)) { + Toast.makeText(MainActivity.this, + getString(R.string.main_activity_reverse_vnc_success, + mLastReverseHost, + mLastReversePort), + Toast.LENGTH_LONG) + .show(); + SharedPreferences.Editor ed = prefs.edit(); + ed.putString(PREFS_KEY_REVERSE_VNC_LAST_HOST, + mLastReverseHost + ":" + mLastReversePort); + ed.apply(); + } else + Toast.makeText(MainActivity.this, + getString(R.string.main_activity_reverse_vnc_fail, + mLastReverseHost, + mLastReversePort), + Toast.LENGTH_LONG) + .show(); + + // reset this + mLastMainServiceRequestId = null; + try { + mOutgoingConnectionWaitDialog.dismiss(); + } catch(NullPointerException ignored) { + } + } + + if (VNCService.ACTION_CONNECT_REPEATER.equals(intent.getAction()) + && mLastMainServiceRequestId != null + && mLastMainServiceRequestId.equals(intent.getStringExtra(VNCService.EXTRA_REQUEST_ID))) { + // was a CONNECT_REPEATER requested by us + if (intent.getBooleanExtra(VNCService.EXTRA_REQUEST_SUCCESS, false)) { + Toast.makeText(MainActivity.this, + getString(R.string.main_activity_repeater_vnc_success, + mLastRepeaterHost, + mLastRepeaterPort, + mLastRepeaterId), + Toast.LENGTH_LONG) + .show(); + SharedPreferences.Editor ed = prefs.edit(); + ed.putString(PREFS_KEY_REPEATER_VNC_LAST_HOST, + mLastRepeaterHost + ":" + mLastRepeaterPort); + ed.putString(PREFS_KEY_REPEATER_VNC_LAST_ID, + mLastRepeaterId); + ed.apply(); + } + else + Toast.makeText(MainActivity.this, + getString(R.string.main_activity_repeater_vnc_fail, + mLastRepeaterHost, + mLastRepeaterPort, + mLastRepeaterId), + Toast.LENGTH_LONG) + .show(); + + // reset this + mLastMainServiceRequestId = null; + try { + mOutgoingConnectionWaitDialog.dismiss(); + } catch(NullPointerException ignored) { + } + } + + } + }; + IntentFilter filter = new IntentFilter(); + filter.addAction(VNCService.ACTION_START); + filter.addAction(VNCService.ACTION_STOP); + filter.addAction(VNCService.ACTION_CONNECT_REVERSE); + filter.addAction(VNCService.ACTION_CONNECT_REPEATER); + // register the receiver as NOT_EXPORTED so it only receives broadcasts sent by VNCService, + // not a malicious fake broadcaster like + // `adb shell am broadcast -a net.christianbeier.com.appknox.knoxvnc.ACTION_STOP --ez net.christianbeier.com.appknox.knoxvnc.EXTRA_REQUEST_SUCCESS true` + // for instance + ContextCompat.registerReceiver(this, mMainServiceBroadcastReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED); + + // setup UI initial state + if (VNCService.isServerActive()) { + Log.d(TAG, "Found server to be started"); + onServerStarted(); + } else { + Log.d(TAG, "Found server to be stopped"); + onServerStopped(); + } + } + + @SuppressLint("SetTextI18n") + @Override + protected void onResume() { + super.onResume(); + + /* + Update Input permission display. + */ + TextView inputStatus = findViewById(R.id.permission_status_input); + if(InputService.isConnected()) { + inputStatus.setText(R.string.main_activity_granted); + inputStatus.setTextColor(getColor(R.color.granted)); + } else { + inputStatus.setText(R.string.main_activity_denied); + inputStatus.setTextColor(getColor(R.color.denied)); + } + + + /* + Update File Access permission display. Only show on < Android 13. + */ + if(Build.VERSION.SDK_INT < 33) { + TextView fileAccessStatus = findViewById(R.id.permission_status_file_access); + if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) { + fileAccessStatus.setText(R.string.main_activity_granted); + fileAccessStatus.setTextColor(getColor(R.color.granted)); + } else { + fileAccessStatus.setText(R.string.main_activity_denied); + fileAccessStatus.setTextColor(getColor(R.color.denied)); + } + } else { + findViewById(R.id.permission_row_file_access).setVisibility(View.GONE); + } + + /* + Update Screen Capturing permission display. + */ + TextView screenCapturingStatus = findViewById(R.id.permission_status_screen_capturing); + if(VNCService.isMediaProjectionEnabled() == 1) { + screenCapturingStatus.setText(R.string.main_activity_granted); + screenCapturingStatus.setTextColor(getColor(R.color.granted)); + } + if(VNCService.isMediaProjectionEnabled() == 0) { + screenCapturingStatus.setText(R.string.main_activity_denied); + screenCapturingStatus.setTextColor(getColor(R.color.denied)); + } + if(VNCService.isMediaProjectionEnabled() == -1) { + screenCapturingStatus.setText(R.string.main_activity_unknown); + screenCapturingStatus.setTextColor(getColor(android.R.color.darker_gray)); + } + + } + + @Override + protected void onDestroy() { + super.onDestroy(); + Log.d(TAG, "onDestroy"); + unregisterReceiver(mMainServiceBroadcastReceiver); + } + + private void onServerStarted() { + mButtonToggle.post(() -> { + mButtonToggle.setText(R.string.stop); + mButtonToggle.setEnabled(true); + // let focus stay on button + mButtonToggle.requestFocus(); + }); + + if(VNCService.getPort() >= 0) { + // uhh there must be a nice functional way for this + ArrayList hosts = VNCService.getIPv4s(); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < hosts.size(); ++i) { + sb.append(hosts.get(i) + ":" + VNCService.getPort()); + if (i != hosts.size() - 1) + sb.append(" ").append(getString(R.string.or)).append(" "); + } + mAddress.post(() -> mAddress.setText(getString(R.string.main_activity_address) + " " + sb)); + } else { + mAddress.post(() -> mAddress.setText(R.string.main_activity_not_listening)); + } + + // show outbound connection interface + findViewById(R.id.outbound_text).setVisibility(View.VISIBLE); + findViewById(R.id.outbound_buttons).setVisibility(View.VISIBLE); + + // indicate that changing these settings does not have an effect when the server is running + findViewById(R.id.settings_port).setEnabled(false); + findViewById(R.id.settings_password).setEnabled(false); + findViewById(R.id.settings_access_key).setEnabled(false); + findViewById(R.id.settings_scaling).setEnabled(false); + findViewById(R.id.settings_view_only).setEnabled(false); + findViewById(R.id.settings_file_transfer).setEnabled(false); + findViewById(R.id.settings_show_pointers).setEnabled(false); + + mIsMainServiceRunning = true; + } + + private void onServerStopped() { + mButtonToggle.post(() -> { + mButtonToggle.setText(R.string.start); + mButtonToggle.setEnabled(true); + // let focus stay on button + mButtonToggle.requestFocus(); + }); + mAddress.post(() -> mAddress.setText("")); + + // hide outbound connection interface + findViewById(R.id.outbound_text).setVisibility(View.GONE); + findViewById(R.id.outbound_buttons).setVisibility(View.GONE); + + // indicate that changing these settings does have an effect when the server is stopped + findViewById(R.id.settings_port).setEnabled(true); + findViewById(R.id.settings_password).setEnabled(true); + findViewById(R.id.settings_access_key).setEnabled(true); + findViewById(R.id.settings_scaling).setEnabled(true); + findViewById(R.id.settings_view_only).setEnabled(true); + findViewById(R.id.settings_file_transfer).setEnabled(true); + if(!((SwitchMaterial)findViewById(R.id.settings_view_only)).isChecked()) { + // pointers depend on view-only being disabled + findViewById(R.id.settings_show_pointers).setEnabled(true); + } + + mIsMainServiceRunning = false; + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/appknox/vnc/MediaProjectionRequestActivity.java b/app/src/main/java/com/appknox/vnc/MediaProjectionRequestActivity.java new file mode 100644 index 0000000..306f0ee --- /dev/null +++ b/app/src/main/java/com/appknox/vnc/MediaProjectionRequestActivity.java @@ -0,0 +1,49 @@ +package com.appknox.vnc; + +import androidx.appcompat.app.AppCompatActivity; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.media.projection.MediaProjectionManager; +import android.os.Bundle; +import android.util.Log; + +public class MediaProjectionRequestActivity extends AppCompatActivity { + + private static final String TAG = "MPRequestActivity"; + private static final int REQUEST_MEDIA_PROJECTION = 42; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + MediaProjectionManager mMediaProjectionManager = (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE); + + Log.i(TAG, "Requesting confirmation"); + // This initiates a prompt dialog for the user to confirm screen projection. + startActivityForResult( + mMediaProjectionManager.createScreenCaptureIntent(), + REQUEST_MEDIA_PROJECTION); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == REQUEST_MEDIA_PROJECTION) { + if (resultCode != Activity.RESULT_OK) + Log.i(TAG, "User cancelled"); + else + Log.i(TAG, "User acknowledged"); + + Intent intent = new Intent(this, VNCService.class); + intent.setAction(VNCService.ACTION_HANDLE_MEDIA_PROJECTION_RESULT); + intent.putExtra(VNCService.EXTRA_MEDIA_PROJECTION_RESULT_CODE, resultCode); + intent.putExtra(VNCService.EXTRA_MEDIA_PROJECTION_RESULT_DATA, data); + startService(intent); + finish(); + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/appknox/vnc/NotificationRequestActivity.java b/app/src/main/java/com/appknox/vnc/NotificationRequestActivity.java new file mode 100644 index 0000000..300c706 --- /dev/null +++ b/app/src/main/java/com/appknox/vnc/NotificationRequestActivity.java @@ -0,0 +1,81 @@ +package com.appknox.vnc; + +import android.Manifest; +import android.app.AlertDialog; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Bundle; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.preference.PreferenceManager; + +public class NotificationRequestActivity extends AppCompatActivity { + + private static final String TAG = "NotificationRequestActivity"; + private static final int REQUEST_POST_NOTIFICATION = 45; + private static final String PREFS_KEY_POST_NOTIFICATION_PERMISSION_ASKED_BEFORE = "post_notification_permission_asked_before"; + + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if(Build.VERSION.SDK_INT < 33) { + // no permission needed on API level < 33 + postResultAndFinish(true); + return; + } + + if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + Log.i(TAG, "Has no permission! Ask!"); + + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + + if (!prefs.getBoolean(PREFS_KEY_POST_NOTIFICATION_PERMISSION_ASKED_BEFORE, false)) { + new AlertDialog.Builder(this) + .setCancelable(false) + .setTitle(R.string.notification_title) + .setMessage(R.string.notification_msg) + .setPositiveButton(R.string.yes, (dialog, which) -> requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, REQUEST_POST_NOTIFICATION)) + .setNegativeButton(getString(R.string.no), (dialog, which) -> postResultAndFinish(false)) + .show(); + SharedPreferences.Editor ed = prefs.edit(); + ed.putBoolean(PREFS_KEY_POST_NOTIFICATION_PERMISSION_ASKED_BEFORE, true); + ed.apply(); + } else { + postResultAndFinish(false); + } + } else { + Log.i(TAG, "Permission already given!"); + postResultAndFinish(true); + } + } + + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + + if (requestCode == REQUEST_POST_NOTIFICATION) { + postResultAndFinish(grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED); + } + } + + private void postResultAndFinish(boolean isPermissionGiven) { + + if (isPermissionGiven) + Log.i(TAG, "permission granted"); + else + Log.i(TAG, "permission denied"); + + Intent intent = new Intent(this, VNCService.class); + intent.setAction(VNCService.ACTION_HANDLE_NOTIFICATION_RESULT); + startService(intent); + finish(); + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/appknox/vnc/Utils.java b/app/src/main/java/com/appknox/vnc/Utils.java new file mode 100644 index 0000000..cfff693 --- /dev/null +++ b/app/src/main/java/com/appknox/vnc/Utils.java @@ -0,0 +1,22 @@ +package com.appknox.vnc; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; + +public class Utils { + + public static String getProp(String prop) { + String result = ""; + try { + Process process = new ProcessBuilder().command("/system/bin/getprop", prop).start(); + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + result = reader.readLine(); + reader.close(); + } catch (IOException e) { + e.printStackTrace(); + } + return result; + } +} + diff --git a/app/src/main/java/com/appknox/vnc/VNCService.java b/app/src/main/java/com/appknox/vnc/VNCService.java new file mode 100644 index 0000000..53710eb --- /dev/null +++ b/app/src/main/java/com/appknox/vnc/VNCService.java @@ -0,0 +1,800 @@ +package com.appknox.vnc; + +import android.annotation.SuppressLint; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.PixelFormat; +import android.hardware.display.DisplayManager; +import android.hardware.display.VirtualDisplay; +import android.media.Image; +import android.media.ImageReader; +import android.media.projection.MediaProjection; +import android.media.projection.MediaProjectionManager; +import android.net.nsd.NsdManager; +import android.net.nsd.NsdServiceInfo; +import android.os.Build; +import android.os.IBinder; +import android.os.PowerManager; +import androidx.preference.PreferenceManager; +import android.provider.Settings; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.Display; + +import androidx.annotation.NonNull; +import androidx.core.app.NotificationCompat; + +import com.appknox.vnc.Defaults; + +import java.net.InterfaceAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Enumeration; + +public class VNCService extends Service { + + private static final String TAG = "VNCService"; + private static final int NOTIFICATION_ID = 11; + public final static String ACTION_START = "com.appknox.vnc.ACTION_START"; + public final static String ACTION_STOP = "com.appknox.vnc.ACTION_STOP"; + public static final String ACTION_CONNECT_REVERSE = "com.appknox.vnc.ACTION_CONNECT_REVERSE"; + public static final String ACTION_CONNECT_REPEATER = "com.appknox.vnc.ACTION_CONNECT_REPEATER"; + public static final String EXTRA_REQUEST_ID = "com.appknox.vnc.EXTRA_REQUEST_ID"; + public static final String EXTRA_REQUEST_SUCCESS = "com.appknox.vnc.EXTRA_REQUEST_SUCCESS"; + public static final String EXTRA_HOST = "com.appknox.vnc.EXTRA_HOST"; + public static final String EXTRA_PORT = "com.appknox.vnc.EXTRA_PORT"; + public static final String EXTRA_REPEATER_ID = "com.appknox.vnc.EXTRA_REPEATER_ID"; + public static final String EXTRA_PASSWORD = "com.appknox.vnc.EXTRA_PASSWORD"; + public static final String EXTRA_VIEW_ONLY = "com.appknox.vnc.EXTRA_VIEW_ONLY"; + public static final String EXTRA_SHOW_POINTERS = "com.appknox.vnc.EXTRA_SHOW_POINTERS"; + public static final String EXTRA_SCALING = "com.appknox.vnc.EXTRA_SCALING"; + /** + * Only used on Android 12 and earlier. + */ + public static final String EXTRA_FILE_TRANSFER = "com.appknox.vnc.EXTRA_FILE_TRANSFER"; + + final static String ACTION_HANDLE_MEDIA_PROJECTION_RESULT = "action_handle_media_projection_result"; + final static String EXTRA_MEDIA_PROJECTION_RESULT_DATA = "result_data_media_projection"; + final static String EXTRA_MEDIA_PROJECTION_RESULT_CODE = "result_code_media_projection"; + + final static String ACTION_HANDLE_INPUT_RESULT = "action_handle_a11y_result"; + final static String EXTRA_INPUT_RESULT = "result_a11y"; + + final static String ACTION_HANDLE_WRITE_STORAGE_RESULT = "action_handle_write_storage_result"; + final static String EXTRA_WRITE_STORAGE_RESULT = "result_write_storage"; + + final static String ACTION_HANDLE_NOTIFICATION_RESULT = "action_handle_notification_result"; + + private static final String PREFS_KEY_SERVER_LAST_PORT = "server_last_port" ; + private static final String PREFS_KEY_SERVER_LAST_PASSWORD = "server_last_password" ; + private static final String PREFS_KEY_SERVER_LAST_FILE_TRANSFER = "server_last_file_transfer" ; + private static final String PREFS_KEY_SERVER_LAST_SHOW_POINTERS = "server_last_show_pointers" ; + private static final String PREFS_KEY_SERVER_LAST_START_REQUEST_ID = "server_last_start_request_id" ; + + private int mResultCode; + private Intent mResultData; + private ImageReader mImageReader; + private VirtualDisplay mVirtualDisplay; + private MediaProjection mMediaProjection; + private MediaProjectionManager mMediaProjectionManager; + + private boolean mHasPortraitInLandscapeWorkaroundApplied; + private boolean mHasPortraitInLandscapeWorkaroundSet; + + private PowerManager.WakeLock mWakeLock; + + private int mNumberOfClients; + private boolean mIsStopping; + // service is stopping on OUR initiative, NOT by stopService() + private boolean mIsStoppingByUs; + + private Defaults mDefaults; + + private static VNCService instance; + + private final NsdManager.RegistrationListener mNSDRegistrationListener = new NsdManager.RegistrationListener() { + @Override + public void onRegistrationFailed(NsdServiceInfo nsdServiceInfo, int i) { + Log.e(TAG, "NSD register failed for " + nsdServiceInfo + " with code " + i); + } + + @Override + public void onUnregistrationFailed(NsdServiceInfo nsdServiceInfo, int i) { + Log.e(TAG, "NSD unregister failed for " + nsdServiceInfo + " with code " + i); + } + + @Override + public void onServiceRegistered(NsdServiceInfo nsdServiceInfo) { + Log.d(TAG, "NSD register for " + nsdServiceInfo); + } + + @Override + public void onServiceUnregistered(NsdServiceInfo nsdServiceInfo) { + Log.d(TAG, "NSD unregister for " + nsdServiceInfo); + } + }; + + static { + // order is important here + System.loadLibrary("vnc"); + } + + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + private native boolean vncStartServer(int width, int height, int port, String desktopName, String password); + private native boolean vncStopServer(); + private native boolean vncIsActive(); + private native long vncConnectReverse(String host, int port); + private native long vncConnectRepeater(String host, int port, String repeaterIdentifier); + private native boolean vncNewFramebuffer(int width, int height); + private native boolean vncUpdateFramebuffer(ByteBuffer buf); + private native int vncGetFramebufferWidth(); + private native int vncGetFramebufferHeight(); + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onCreate() { + Log.d(TAG, "onCreate"); + + instance = this; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + /* + Create notification channel + */ + NotificationChannel serviceChannel = new NotificationChannel( + getPackageName(), + "Foreground Service Channel", + NotificationManager.IMPORTANCE_DEFAULT + ); + NotificationManager manager = getSystemService(NotificationManager.class); + manager.createNotificationChannel(serviceChannel); + + /* + startForeground() w/ notification + */ + startForeground(NOTIFICATION_ID, getNotification(null, true)); + } + + /* + Get the MediaProjectionManager + */ + mMediaProjectionManager = (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE); + + /* + Get a wake lock + */ + //noinspection deprecation + mWakeLock = ((PowerManager) instance.getSystemService(Context.POWER_SERVICE)).newWakeLock((PowerManager.SCREEN_DIM_WAKE_LOCK| PowerManager.ACQUIRE_CAUSES_WAKEUP | PowerManager.ON_AFTER_RELEASE), TAG + ":clientsConnected"); + + /* + Load defaults + */ + mDefaults = new Defaults(this); + } + + + @Override + public void onConfigurationChanged(@NonNull Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + DisplayMetrics displayMetrics = getDisplayMetrics(Display.DEFAULT_DISPLAY); + Log.d(TAG, "onConfigurationChanged: width: " + displayMetrics.widthPixels + " height: " + displayMetrics.heightPixels); + + startScreenCapture(); + } + + + @Override + public void onDestroy() { + Log.d(TAG, "onDestroy"); + + mIsStopping = true; + + if(!mIsStoppingByUs && vncIsActive()) { + // stopService() from OS or other component + Log.d(TAG, "onDestroy: sending ACTION_STOP"); + sendBroadcast(new Intent(ACTION_STOP)); + } + + try { + ((NsdManager) getSystemService(Context.NSD_SERVICE)).unregisterService(mNSDRegistrationListener); + } catch (Exception ignored) { + // was not registered + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + // API levels < 26 don't have the mandatory foreground notification and need manual notification dismissal + getSystemService(NotificationManager.class).cancelAll(); + } + + stopScreenCapture(); + vncStopServer(); + instance = null; + } + + + + @Override + public int onStartCommand(Intent intent, int flags, int startId) + { + if(ACTION_HANDLE_MEDIA_PROJECTION_RESULT.equals(intent.getAction())) { + Log.d(TAG, "onStartCommand: handle media projection result"); + // Step 4 (optional): coming back from capturing permission check, now starting capturing machinery + mResultCode = intent.getIntExtra(EXTRA_MEDIA_PROJECTION_RESULT_CODE, 0); + mResultData = intent.getParcelableExtra(EXTRA_MEDIA_PROJECTION_RESULT_DATA); + DisplayMetrics displayMetrics = getDisplayMetrics(Display.DEFAULT_DISPLAY); + int port = PreferenceManager.getDefaultSharedPreferences(this).getInt(PREFS_KEY_SERVER_LAST_PORT, mDefaults.getPort()); + // get device name + String name; + try { + // This is what we had until targetSDK 33. + name = Settings.Secure.getString(getContentResolver(), "bluetooth_name"); + } catch (SecurityException ignored) { + // throws on devices with API level 33, so use fallback + if (Build.VERSION.SDK_INT > 25) { + name = Settings.Global.getString(getContentResolver(), Settings.Global.DEVICE_NAME); + } else { + name = getString(R.string.app_name); + } + } + + boolean status = vncStartServer(displayMetrics.widthPixels, + displayMetrics.heightPixels, + port, + name, + PreferenceManager.getDefaultSharedPreferences(this).getString(PREFS_KEY_SERVER_LAST_PASSWORD, mDefaults.getPassword())); + Intent answer = new Intent(ACTION_START); + answer.putExtra(EXTRA_REQUEST_ID, PreferenceManager.getDefaultSharedPreferences(this).getString(PREFS_KEY_SERVER_LAST_START_REQUEST_ID, null)); + answer.putExtra(EXTRA_REQUEST_SUCCESS, status); + sendBroadcast(answer); + + if(status) { + startScreenCapture(); + registerNSD(name, port); + updateNotification(); + // if we got here, we want to restart if we were killed + return START_REDELIVER_INTENT; + } else { + stopSelfByUs(); + return START_NOT_STICKY; + } + } + + if(ACTION_HANDLE_WRITE_STORAGE_RESULT.equals(intent.getAction()) || ACTION_HANDLE_NOTIFICATION_RESULT.equals(intent.getAction())) { + if(ACTION_HANDLE_WRITE_STORAGE_RESULT.equals(intent.getAction())) { + Log.d(TAG, "onStartCommand: handle write storage result"); + // Step 3 on Android < 13: coming back from write storage permission check, start capturing + // or ask for capturing permission first (then going in step 4) + } + if(ACTION_HANDLE_NOTIFICATION_RESULT.equals(intent.getAction())) { + Log.d(TAG, "onStartCommand: handle notification result"); + // Step 3 on Android >= 13: coming back from notification permission check, start capturing + // or ask for capturing permission first (then going in step 4) + } + + if (mResultCode != 0 && mResultData != null) { + DisplayMetrics displayMetrics = getDisplayMetrics(Display.DEFAULT_DISPLAY); + int port = PreferenceManager.getDefaultSharedPreferences(this).getInt(PREFS_KEY_SERVER_LAST_PORT, mDefaults.getPort()); + String name = Settings.Secure.getString(getContentResolver(), "bluetooth_name"); + boolean status = vncStartServer(displayMetrics.widthPixels, + displayMetrics.heightPixels, + port, + name, + PreferenceManager.getDefaultSharedPreferences(this).getString(PREFS_KEY_SERVER_LAST_PASSWORD, mDefaults.getPassword())); + + Intent answer = new Intent(ACTION_START); + answer.putExtra(EXTRA_REQUEST_ID, PreferenceManager.getDefaultSharedPreferences(this).getString(PREFS_KEY_SERVER_LAST_START_REQUEST_ID, null)); + answer.putExtra(EXTRA_REQUEST_SUCCESS, status); + sendBroadcast(answer); + + if(status) { + startScreenCapture(); + registerNSD(name, port); + updateNotification(); + // if we got here, we want to restart if we were killed + return START_REDELIVER_INTENT; + } else { + stopSelfByUs(); + return START_NOT_STICKY; + } + } else { + Log.i(TAG, "Requesting confirmation"); + // This initiates a prompt dialog for the user to confirm screen projection. + Intent mediaProjectionRequestIntent = new Intent(this, MediaProjectionRequestActivity.class); + mediaProjectionRequestIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(mediaProjectionRequestIntent); + // if screen capturing was not started, we don't want a restart if we were killed + // especially, we don't want the permission asking to replay. + return START_NOT_STICKY; + } + } + + if(ACTION_HANDLE_INPUT_RESULT.equals(intent.getAction())) { + Log.d(TAG, "onStartCommand: handle input result"); + // Step 2: coming back from input permission check, now setup InputService and ask for write storage permission or notification permission + InputService.isEnabled = intent.getBooleanExtra(EXTRA_INPUT_RESULT, false); + if(Build.VERSION.SDK_INT < 33) { + Intent writeStorageRequestIntent = new Intent(this, WriteStorageRequestActivity.class); + writeStorageRequestIntent.putExtra( + EXTRA_FILE_TRANSFER, + PreferenceManager.getDefaultSharedPreferences(this).getBoolean(PREFS_KEY_SERVER_LAST_FILE_TRANSFER, mDefaults.getFileTransfer())); + writeStorageRequestIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(writeStorageRequestIntent); + } else { + Intent notificationRequestIntent = new Intent(this, NotificationRequestActivity.class); + notificationRequestIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(notificationRequestIntent); + } + // if screen capturing was not started, we don't want a restart if we were killed + // especially, we don't want the permission asking to replay. + return START_NOT_STICKY; + } + + if(ACTION_START.equals(intent.getAction())) { + Log.d(TAG, "onStartCommand: start"); + + if(vncIsActive()) { + Intent answer = new Intent(ACTION_START); + answer.putExtra(EXTRA_REQUEST_ID, intent.getStringExtra(EXTRA_REQUEST_ID)); + answer.putExtra(EXTRA_REQUEST_SUCCESS, false); + sendBroadcast(answer); + return START_NOT_STICKY; + } + + // Step 0: persist given arguments to be able to recover from possible crash later + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + SharedPreferences.Editor ed = prefs.edit(); + ed.putInt(PREFS_KEY_SERVER_LAST_PORT, intent.getIntExtra(EXTRA_PORT, prefs.getInt(Constants.PREFS_KEY_SETTINGS_PORT, mDefaults.getPort()))); + ed.putString(PREFS_KEY_SERVER_LAST_PASSWORD, intent.getStringExtra(EXTRA_PASSWORD) != null ? intent.getStringExtra(EXTRA_PASSWORD) : prefs.getString(Constants.PREFS_KEY_SETTINGS_PASSWORD, mDefaults.getPassword())); + ed.putBoolean(PREFS_KEY_SERVER_LAST_FILE_TRANSFER, intent.getBooleanExtra(EXTRA_FILE_TRANSFER, prefs.getBoolean(Constants.PREFS_KEY_SETTINGS_FILE_TRANSFER, mDefaults.getFileTransfer()))); + ed.putBoolean(Constants.PREFS_KEY_INPUT_LAST_ENABLED, !intent.getBooleanExtra(EXTRA_VIEW_ONLY, prefs.getBoolean(Constants.PREFS_KEY_SETTINGS_VIEW_ONLY, mDefaults.getViewOnly()))); + ed.putFloat(Constants.PREFS_KEY_SERVER_LAST_SCALING, intent.getFloatExtra(EXTRA_SCALING, prefs.getFloat(Constants.PREFS_KEY_SETTINGS_SCALING, mDefaults.getScaling()))); + ed.putString(PREFS_KEY_SERVER_LAST_START_REQUEST_ID, intent.getStringExtra(EXTRA_REQUEST_ID)); + // showing pointers depends on view-only being false + ed.putBoolean(PREFS_KEY_SERVER_LAST_SHOW_POINTERS, + !intent.getBooleanExtra(EXTRA_VIEW_ONLY, prefs.getBoolean(Constants.PREFS_KEY_SETTINGS_VIEW_ONLY, mDefaults.getViewOnly())) + && intent.getBooleanExtra(EXTRA_SHOW_POINTERS, prefs.getBoolean(Constants.PREFS_KEY_SETTINGS_SHOW_POINTERS, mDefaults.getShowPointers()))); + ed.apply(); + // also set new value for InputService + InputService.scaling = intent.getFloatExtra(EXTRA_SCALING, mDefaults.getScaling()); + + // Step 1: check input permission + Intent inputRequestIntent = new Intent(this, InputRequestActivity.class); + inputRequestIntent.putExtra(EXTRA_VIEW_ONLY, intent.getBooleanExtra(EXTRA_VIEW_ONLY, mDefaults.getViewOnly())); + inputRequestIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(inputRequestIntent); + // if screen capturing was not started, we don't want a restart if we were killed + // especially, we don't want the permission asking to replay. + return START_NOT_STICKY; + } + + if(ACTION_STOP.equals(intent.getAction())) { + Log.d(TAG, "onStartCommand: stop"); + stopSelfByUs(); + Intent answer = new Intent(ACTION_STOP); + answer.putExtra(EXTRA_REQUEST_ID, intent.getStringExtra(EXTRA_REQUEST_ID)); + answer.putExtra(EXTRA_REQUEST_SUCCESS, vncIsActive()); + sendBroadcast(answer); + return START_NOT_STICKY; + } + + if(ACTION_CONNECT_REVERSE.equals(intent.getAction())) { + Log.d(TAG, "onStartCommand: connect reverse"); + if(vncIsActive()) { + // run on worker thread + new Thread(() -> { + long client = 0; + try { + client = instance.vncConnectReverse(intent.getStringExtra(EXTRA_HOST), intent.getIntExtra(EXTRA_PORT, mDefaults.getPortReverse())); + } catch (NullPointerException ignored) { + } + Intent answer = new Intent(ACTION_CONNECT_REVERSE); + answer.putExtra(EXTRA_REQUEST_ID, intent.getStringExtra(EXTRA_REQUEST_ID)); + answer.putExtra(EXTRA_REQUEST_SUCCESS, client != 0); + sendBroadcast(answer); + }).start(); + } else { + stopSelfByUs(); + } + + return START_NOT_STICKY; + } + + if(ACTION_CONNECT_REPEATER.equals(intent.getAction())) { + Log.d(TAG, "onStartCommand: connect repeater"); + + if(vncIsActive()) { + // run on worker thread + new Thread(() -> { + long client = 0; + try { + client = instance.vncConnectRepeater( + intent.getStringExtra(EXTRA_HOST), + intent.getIntExtra(EXTRA_PORT, mDefaults.getPortRepeater()), + intent.getStringExtra(EXTRA_REPEATER_ID)); + } catch (NullPointerException ignored) { + } + Intent answer = new Intent(ACTION_CONNECT_REPEATER); + answer.putExtra(EXTRA_REQUEST_ID, intent.getStringExtra(EXTRA_REQUEST_ID)); + answer.putExtra(EXTRA_REQUEST_SUCCESS, client != 0); + sendBroadcast(answer); + }).start(); + } else { + stopSelfByUs(); + } + + return START_NOT_STICKY; + } + + // no known action was given, stop the _service_ again if the _server_ is not active + if(!vncIsActive()) { + stopSelfByUs(); + } + + return START_NOT_STICKY; + } + + @SuppressLint("WakelockTimeout") + @SuppressWarnings("unused") + static void onClientConnected(long client) { + Log.d(TAG, "onClientConnected: client " + client); + + try { + instance.mWakeLock.acquire(); + instance.mNumberOfClients++; + instance.updateNotification(); + InputService.addClient(client, PreferenceManager.getDefaultSharedPreferences(instance).getBoolean(PREFS_KEY_SERVER_LAST_SHOW_POINTERS, new Defaults(instance).getShowPointers())); + } catch (Exception e) { + // instance probably null + Log.e(TAG, "onClientConnected: error: " + e); + } + } + + @SuppressWarnings("unused") + static void onClientDisconnected(long client) { + Log.d(TAG, "onClientDisconnected: client " + client); + + try { + instance.mWakeLock.release(); + instance.mNumberOfClients--; + if(!instance.mIsStopping) { + // don't show notifications when clients are disconnected on orderly server shutdown + instance.updateNotification(); + } + InputService.removeClient(client); + } catch (Exception e) { + // instance probably null + Log.e(TAG, "onClientDisconnected: error: " + e); + } + } + + @SuppressLint("WrongConstant") + private void startScreenCapture() { + + if(mMediaProjection == null) + try { + mMediaProjection = mMediaProjectionManager.getMediaProjection(mResultCode, mResultData); + } catch (SecurityException e) { + Log.w(TAG, "startScreenCapture: got SecurityException, re-requesting confirmation"); + // This initiates a prompt dialog for the user to confirm screen projection. + Intent mediaProjectionRequestIntent = new Intent(this, MediaProjectionRequestActivity.class); + mediaProjectionRequestIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(mediaProjectionRequestIntent); + return; + } + + if (mMediaProjection == null) { + Log.e(TAG, "startScreenCapture: did not get a media projection, probably user denied"); + return; + } + + if (mImageReader != null) + mImageReader.close(); + + final DisplayMetrics metrics = getDisplayMetrics(Display.DEFAULT_DISPLAY); + + // apply selected scaling + float scaling = PreferenceManager.getDefaultSharedPreferences(this).getFloat(Constants.PREFS_KEY_SERVER_LAST_SCALING, new Defaults(this).getScaling()); + int scaledWidth = (int) (metrics.widthPixels * scaling); + int scaledHeight = (int) (metrics.heightPixels * scaling); + + // only set this by detecting quirky hardware if the user has not set manually + if(!mHasPortraitInLandscapeWorkaroundSet && Build.FINGERPRINT.contains("rk3288") && metrics.widthPixels > 800) { + Log.w(TAG, "detected >10in rk3288 applying workaround for portrait-in-landscape quirk"); + mHasPortraitInLandscapeWorkaroundApplied = true; + } + + // use workaround if flag set and in actual portrait mode + if(mHasPortraitInLandscapeWorkaroundApplied && scaledWidth < scaledHeight) { + + final float portraitInsideLandscapeScaleFactor = (float)scaledWidth/scaledHeight; + + // width and height are swapped here + final int quirkyLandscapeWidth = (int)((float)scaledHeight/portraitInsideLandscapeScaleFactor); + final int quirkyLandscapeHeight = (int)((float)scaledWidth/portraitInsideLandscapeScaleFactor); + + mImageReader = ImageReader.newInstance(quirkyLandscapeWidth, quirkyLandscapeHeight, PixelFormat.RGBA_8888, 2); + mImageReader.setOnImageAvailableListener(imageReader -> { + try (Image image = imageReader.acquireLatestImage()) { + + if (image == null) + return; + + final Image.Plane[] planes = image.getPlanes(); + final ByteBuffer buffer = planes[0].getBuffer(); + int pixelStride = planes[0].getPixelStride(); + int rowStride = planes[0].getRowStride(); + int rowPadding = rowStride - pixelStride * quirkyLandscapeWidth; + int w = quirkyLandscapeWidth + rowPadding / pixelStride; + + // create destination Bitmap + Bitmap dest = Bitmap.createBitmap(w, quirkyLandscapeHeight, Bitmap.Config.ARGB_8888); + + // copy landscape buffer to dest bitmap + buffer.rewind(); + dest.copyPixelsFromBuffer(buffer); + + // get the portrait portion that's in the center of the landscape bitmap + Bitmap croppedDest = Bitmap.createBitmap(dest, quirkyLandscapeWidth / 2 - scaledWidth / 2, 0, scaledWidth, scaledHeight); + + ByteBuffer croppedBuffer = ByteBuffer.allocateDirect(scaledWidth * scaledHeight * 4); + croppedDest.copyPixelsToBuffer(croppedBuffer); + + // if needed, setup a new VNC framebuffer that matches the new buffer's dimensions + if (scaledWidth != vncGetFramebufferWidth() || scaledHeight != vncGetFramebufferHeight()) + vncNewFramebuffer(scaledWidth, scaledHeight); + + vncUpdateFramebuffer(croppedBuffer); + } catch (Exception ignored) { + } + }, null); + + try { + if(mVirtualDisplay == null) { + mVirtualDisplay = mMediaProjection.createVirtualDisplay(getString(R.string.app_name), + quirkyLandscapeWidth, quirkyLandscapeHeight, metrics.densityDpi, + DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, + mImageReader.getSurface(), null, null); + } else { + mVirtualDisplay.resize(quirkyLandscapeWidth, quirkyLandscapeHeight, metrics.densityDpi); + mVirtualDisplay.setSurface(mImageReader.getSurface()); + } + } catch (SecurityException e) { + Log.w(TAG, "startScreenCapture: got SecurityException, re-requesting confirmation"); + // This initiates a prompt dialog for the user to confirm screen projection. + Intent mediaProjectionRequestIntent = new Intent(this, MediaProjectionRequestActivity.class); + mediaProjectionRequestIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(mediaProjectionRequestIntent); + } + + return; + } + + /* + This is the default behaviour. + */ + mImageReader = ImageReader.newInstance(scaledWidth, scaledHeight, PixelFormat.RGBA_8888, 2); + mImageReader.setOnImageAvailableListener(imageReader -> { + try (Image image = imageReader.acquireLatestImage()) { + + if (image == null) + return; + + final Image.Plane[] planes = image.getPlanes(); + final ByteBuffer buffer = planes[0].getBuffer(); + int pixelStride = planes[0].getPixelStride(); + int rowStride = planes[0].getRowStride(); + int rowPadding = rowStride - pixelStride * scaledWidth; + int w = scaledWidth + rowPadding / pixelStride; + + // if needed, setup a new VNC framebuffer that matches the image plane's parameters + if (w != vncGetFramebufferWidth() || scaledHeight != vncGetFramebufferHeight()) + vncNewFramebuffer(w, scaledHeight); + + buffer.rewind(); + + vncUpdateFramebuffer(buffer); + } catch (Exception ignored) { + } + }, null); + + try { + if(mVirtualDisplay == null) { + mVirtualDisplay = mMediaProjection.createVirtualDisplay(getString(R.string.app_name), + scaledWidth, scaledHeight, metrics.densityDpi, + DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, + mImageReader.getSurface(), null, null); + } else { + mVirtualDisplay.resize(scaledWidth, scaledHeight, metrics.densityDpi); + mVirtualDisplay.setSurface(mImageReader.getSurface()); + } + } catch (SecurityException e) { + Log.w(TAG, "startScreenCapture: got SecurityException, re-requesting confirmation"); + // This initiates a prompt dialog for the user to confirm screen projection. + Intent mediaProjectionRequestIntent = new Intent(this, MediaProjectionRequestActivity.class); + mediaProjectionRequestIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(mediaProjectionRequestIntent); + } + + } + + private void stopScreenCapture() { + try { + mVirtualDisplay.release(); + mVirtualDisplay = null; + } catch (Exception e) { + //unused + } + + if (mMediaProjection != null) { + mMediaProjection.stop(); + mMediaProjection = null; + } + } + + /** + * Wrapper around stopSelf() that indicates that the stop was on our initiative. + */ + private void stopSelfByUs() { + mIsStoppingByUs = true; + stopSelf(); + } + + /** + * Get whether Media Projection was granted by the user. + * @return -1 if unknown, 0 if denied, 1 if granted + */ + static int isMediaProjectionEnabled() { + if(instance == null) + return -1; + if(instance.mResultCode == 0 || instance.mResultData == null) + return 0; + + return 1; + } + + static boolean isServerActive() { + try { + return instance.vncIsActive(); + } catch (Exception ignored) { + return false; + } + } + + static void togglePortraitInLandscapeWorkaround() { + try { + // set + instance.mHasPortraitInLandscapeWorkaroundSet = true; + instance.mHasPortraitInLandscapeWorkaroundApplied = !instance.mHasPortraitInLandscapeWorkaroundApplied; + // apply + instance.startScreenCapture(); + } + catch (NullPointerException e) { + //unused + } + + } + + /** + * Get non-loopback IPv4 addresses. + * @return A list of strings, each containing one IPv4 address. + */ + static ArrayList getIPv4s() { + + ArrayList hosts = new ArrayList<>(); + + // if running on Chrome OS, this prop is set and contains the device's IPv4 address, + // see https://chromeos.dev/en/games/optimizing-games-networking + String prop = Utils.getProp("arc.net.ipv4.host_address"); + if(!prop.isEmpty()) { + hosts.add(prop); + return hosts; + } + + // not running on Chrome OS + try { + // thanks go to https://stackoverflow.com/a/20103869/361413 + Enumeration nis = NetworkInterface.getNetworkInterfaces(); + NetworkInterface ni; + while (nis.hasMoreElements()) { + ni = nis.nextElement(); + if (!ni.isLoopback()/*not loopback*/ && ni.isUp()/*it works now*/) { + for (InterfaceAddress ia : ni.getInterfaceAddresses()) { + //filter for ipv4/ipv6 + if (ia.getAddress().getAddress().length == 4) { + //4 for ipv4, 16 for ipv6 + hosts.add(ia.getAddress().toString().replaceAll("/", "")); + } + } + } + } + } catch (SocketException e) { + //unused + } + + return hosts; + } + + static int getPort() { + try { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(instance); + return prefs.getInt(PREFS_KEY_SERVER_LAST_PORT, new Defaults(instance).getPort()); + } catch (Exception e) { + return -2; + } + } + + /** @noinspection SameParameterValue*/ + private DisplayMetrics getDisplayMetrics(int displayId) { + DisplayMetrics displayMetrics = new DisplayMetrics(); + DisplayManager dm = (DisplayManager) getSystemService(DISPLAY_SERVICE); + dm.getDisplay(displayId).getRealMetrics(displayMetrics); + return displayMetrics; + } + + private void registerNSD(String name, int port) { + // unregister old one + try { + ((NsdManager) getSystemService(Context.NSD_SERVICE)).unregisterService(mNSDRegistrationListener); + } catch (Exception ignored) { + // was not registered + } + + if(port < 0) { + // no service offered + return; + } + + if(name == null || name.isEmpty()) { + name = "Android"; + } + + // register new one + NsdServiceInfo serviceInfo = new NsdServiceInfo(); + serviceInfo.setServiceName(name); + serviceInfo.setServiceType("_rfb._tcp."); + serviceInfo.setPort(port); + + ((NsdManager)getSystemService(Context.NSD_SERVICE)).registerService( + serviceInfo, + NsdManager.PROTOCOL_DNS_SD, + mNSDRegistrationListener + ); + } + + private Notification getNotification(String text, boolean isSilent){ + Intent notificationIntent = new Intent(this, MainActivity.class); + + PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, + notificationIntent, PendingIntent.FLAG_IMMUTABLE); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, getPackageName()) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle("Devicefarm") + .setContentText("Running Device Services in Foreground") + .setSilent(isSilent) + .setOngoing(true); + if (Build.VERSION.SDK_INT >= 31) { + builder.setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE); + } + return builder.build(); + } + + private void updateNotification() { + } + +} diff --git a/app/src/main/java/com/appknox/vnc/WriteStorageRequestActivity.java b/app/src/main/java/com/appknox/vnc/WriteStorageRequestActivity.java new file mode 100644 index 0000000..55449c3 --- /dev/null +++ b/app/src/main/java/com/appknox/vnc/WriteStorageRequestActivity.java @@ -0,0 +1,85 @@ +package com.appknox.vnc; + +import android.Manifest; +import android.app.AlertDialog; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Bundle; +import androidx.preference.PreferenceManager; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; + +public class WriteStorageRequestActivity extends AppCompatActivity { + + private static final String TAG = "WriteStorageRequestActivity"; + private static final int REQUEST_WRITE_STORAGE = 44; + private static final String PREFS_KEY_PERMISSION_ASKED_BEFORE = "write_storage_permission_asked_before"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // if file transfer not wanted, bail out early without bothering the user + if(!getIntent().getBooleanExtra(VNCService.EXTRA_FILE_TRANSFER, new Defaults(this).getFileTransfer())) { + postResultAndFinish(false); + return; + } + + if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + Log.i(TAG, "Has no permission! Ask!"); + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + + /* + As per as per https://stackoverflow.com/a/34612503/361413 shouldShowRequestPermissionRationale() + returns false also if user was never asked, so keep track of that with a shared preference. Ouch. + */ + if (!prefs.getBoolean(PREFS_KEY_PERMISSION_ASKED_BEFORE, false) || shouldShowRequestPermissionRationale(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + new AlertDialog.Builder(this) + .setCancelable(false) + .setTitle(R.string.write_storage_title) + .setMessage(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ? R.string.write_storage_msg_android_11 : R.string.write_storage_msg) + .setPositiveButton(R.string.yes, (dialog, which) -> { + requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, REQUEST_WRITE_STORAGE); + SharedPreferences.Editor ed = prefs.edit(); + ed.putBoolean(PREFS_KEY_PERMISSION_ASKED_BEFORE, true); + ed.apply(); + }) + .setNegativeButton(getString(R.string.no), (dialog, which) -> postResultAndFinish(false)) + .show(); + } else { + postResultAndFinish(false); + } + } else { + Log.i(TAG, "Permission already given!"); + postResultAndFinish(true); + } + } + + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == REQUEST_WRITE_STORAGE) { + postResultAndFinish(grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED); + } + } + + private void postResultAndFinish(boolean isPermissionGiven) { + + if (isPermissionGiven) + Log.i(TAG, "permission granted"); + else + Log.i(TAG, "permission denied"); + + Intent intent = new Intent(this, VNCService.class); + intent.setAction(VNCService.ACTION_HANDLE_WRITE_STORAGE_RESULT); + intent.putExtra(VNCService.EXTRA_WRITE_STORAGE_RESULT, isPermissionGiven); + startService(intent); + finish(); + } + +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..14894e2 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,493 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +