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).
+
+[](https://f-droid.org/packages/net.christianbeier.droidvnc_ng/)
+[](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 0000000..1f9b10e
Binary files /dev/null and b/app/gastest.o differ
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/appknox/vnc/ExampleInstrumentedTest.java b/app/src/androidTest/java/com/appknox/vnc/ExampleInstrumentedTest.java
new file mode 100644
index 0000000..067f91f
--- /dev/null
+++ b/app/src/androidTest/java/com/appknox/vnc/ExampleInstrumentedTest.java
@@ -0,0 +1,26 @@
+package com.appknox.vnc;
+
+import android.content.Context;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import static org.junit.Assert.*;
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * @see Testing 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..bf5ac92
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..ae43fa2
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/banner.png b/app/src/main/res/mipmap-xhdpi/banner.png
new file mode 100644
index 0000000..8cb9876
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/banner.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..50133c2
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..6274cc1
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..bb8a8bf
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
new file mode 100644
index 0000000..f6fa0ba
--- /dev/null
+++ b/app/src/main/res/values-de/strings.xml
@@ -0,0 +1,59 @@
+
+ droidVNC-NG Admin-Panel
+ Einstellungen
+ Port
+ Eingehende Verbindungen deaktiviert
+ Passwort
+ Zugangsschlüssel
+ Start bei Gerätestart
+ Startverzögerung (s)
+ Dateiübertragung
+ Skalierung
+ Nur Anschauen
+ Mauszeiger
+ Berechtigungs-Dashboard
+ :
+ Bildschirmaufnahme
+ Unbekannt
+ gewährt
+ verweigert
+ Dateizugriff
+ Benachrichtigungen
+ Eingabe
+ Der Server ist jetzt erreichbar unter
+ Der Server akzeptiert keine eingehenden Verbindungen.
+ Sie können ausgehende Verbindungen herstellen:
+ Verbinde zu %1$s
+ Verbindung zu VNC-Viewer
+ Host:Port
+ Verbindung zu %1$s:%2$d erfolgreich
+ Die Verbindung zu %1$s:%2$d ist fehlgeschlagen
+ Verbindung zu VNC-Repeater
+ Host:Port
+ ID auf Repeater
+ Die Verbindung zu %1$s:%2$d mit der ID %3$s war erfolgreich
+ Die Verbindung zu %1$s:%2$d mit der ID %3$s ist fehlgeschlagen
+ Host und/oder ID fehlen
+ Von einem VNC-Viewer aus löst Strg-Umschalt-Esc die Übersicht „Letzte Apps“ aus, Pos1 fungiert als Home-Button, Esc als Zurück-Taste.
+ Dies ist droidVNC-NG %1$s. Bitte beachten Sie, dass zu droidVNC-NG noch weitere Funktionen hinzugefügt werden. Insbesondere können die meisten Tastatureingaben derzeit nur über die Android-Softtastatur gemacht werden, Tastatureingaben von VNC-Viewern werden (momentan) nur eingeschränkt unterstützt. Bitte melden Sie alle Probleme und Funktionswünsche unter https://github.com/bk138/droidVNC-NG/issues
+ Eingehende Verbindungen sind deaktiviert
+
+ - Eingehende Verbindungen möglich auf Port %1$d, %2$d Client verbunden
+ - Eingehende Verbindungen möglich auf Port %1$d, %2$d Clients verbunden
+
+ Start
+ Stop
+ Fehler
+ Ja
+ Nein
+ oder
+ Dies erlaubt es droidVNC-NG, Eingabeereignisse an das Android-Betriebssystem senden und so die Fernsteuerung von einem angeschlossenen VNC-Viewer aus zu ermöglichen. Alle eingegebenen Eingaben werden direkt an Android gesendet, es werden keine Eingaben protokolliert, gespeichert oder geteilt.
+ Eingabehilfe deaktiviert
+ Um Ihr Gerät fernsteuern zu können, müssen Sie die Eingabehilfe für droidVNC-NG aktivieren. Möchten Sie die Eingabehilfe jetzt aktivieren?
+ Die Standardeinstellungen für die Eingabehilfen konnten nicht gefunden werden. Bitte aktivieren Sie droidVNC-NG in den Systemeinstellungen für Eingabehilfen manuell.
+ Dateizugriff deaktiviert
+ Um Dateien von und zu Ihrem Gerät übertragen zu können, müssen Sie den Dateizugriff für droidVNC-NG aktivieren. Möchten Sie den Dateizugriff jetzt aktivieren?
+ Um Dateien von und zu Ihrem Gerät übertragen zu können, müssen Sie den Dateizugriff für droidVNC-NG aktivieren. Bitte beachten Sie, dass das Speichern von Dateien nur in den Verzeichnissen „Download“ und „Dokumente“ möglich ist. Möchten Sie den Dateizugriff jetzt aktivieren\?
+ Benachrichtigungen deaktiviert
+ Um Informationen zum Serverstatus anzeigen zu können und einfacher zum Admin-Panel zurücknavigieren zu können, wird empfohlen, Benachrichtigungen zu aktivieren. Möchten Sie Benachrichtigungen jetzt aktivieren?
+
\ No newline at end of file
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..e339e7f
--- /dev/null
+++ b/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,8 @@
+
+
+ #a877ee
+ #755ab3
+ #6ddacf
+ @android:color/holo_green_light
+ @android:color/holo_red_light
+
\ No newline at end of file
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
new file mode 100644
index 0000000..0ce0b64
--- /dev/null
+++ b/app/src/main/res/values-pt/strings.xml
@@ -0,0 +1,59 @@
+
+ Painel Administrativo do droidVNC-NG
+ Configurações
+ Porta
+ Conexões de entrada desativadas
+ Senha
+ Chave de acesso
+ Iniciar na inicialização
+ Atraso de início (s)
+ Transferência de arquivo
+ Dimensionamento
+ Ver apenas
+ Ponteiros do Mouse
+ Painel de Permissões
+ :
+ Captura de tela
+ desconhecido
+ garantido
+ negado
+ Acesso a arquivos
+ Notificações
+ Entrada
+ O servidor agora pode ser acessado em
+ O servidor não está escutando conexões de entrada.
+ Você pode fazer conexões de saída:
+ Conectando-se a %1$s
+ Conecte-se a um visualizador ouvinte
+ Host:Porta
+ Conexão com %1$s:%2$d bem-sucedida
+ A conexão com %1$s:%2$d falhou
+ Conectar a um repetidor
+ Host:Porta
+ ID no repetidor
+ Conexão com %1$s:%2$d com ID %3$s bem-sucedida
+ A conexão com %1$s:%2$d com ID %3$s falhou
+ Host e/ou ID ausente
+ De um visualizador VNC, Ctrl-Shift-Esc aciona a visão geral de \'Aplicativos recentes\', Casa/Pos1 atua como botão Home, Escapar funciona como botão Voltar.
+ Aqui é droidVNC-NG %1$s. Observe que mais recursos ainda estão sendo adicionados ao droidVNC-NG. Em particular, a maioria dos toques de teclado a partir de agora só podem ser inseridos por meio do teclado programável do Android, há apenas suporte limitado para eventos de teclado de visualizadores VNC (ainda). Relate quaisquer problemas e solicitações de recursos em https://github.com/bk138/droidVNC-NG/issues
+ Não está ouvindo conexões de entrada
+
+ - Escutando na porta %1$d, %2$d cliente conectado
+ - Escutando na porta %1$d, %2$d clientes conectados
+
+ Começar
+ Parar
+ Erro
+ Sim
+ Não
+ ou
+ Isso permite que o droidVNC-NG poste eventos de entrada no sistema operacional Android, permitindo assim o controle remoto de um visualizador VNC conectado. Todas as entradas inseridas são postadas diretamente no Android, nenhuma entrada é registrada, salva ou compartilhada.
+ Acessibilidade desativada
+ Para poder controlar remotamente seu dispositivo, você precisa habilitar a acessibilidade para droidVNC-NG. Deseja ativar a acessibilidade agora?
+ Não foi possível encontrar a atividade das definições de acessibilidade predefinidas. Por favor, active manualmente o droidVNC-NG nas definições de acessibilidade do sistema.
+ Acesso a arquivos desativado
+ Para poder transferir arquivos de e para o seu dispositivo, você precisa habilitar o acesso a arquivos para o droidVNC-NG. Deseja habilitar o acesso ao arquivo agora?
+ Para poder transferir arquivos de e para o seu dispositivo, você precisa habilitar o acesso a arquivos para o droidVNC-NG. Observe que salvar arquivos só será possível nos diretórios \'Download\' e \'Documentos\'. Deseja habilitar o acesso ao arquivo agora?
+ Notificações desativadas
+ Para poder mostrar informações de status do servidor e fornecer um meio de navegar de volta ao painel de administração com mais facilidade, é recomendável ativar as notificações. Quer ativar as notificações agora?
+
\ No newline at end of file
diff --git a/app/src/main/res/values-television/colors.xml b/app/src/main/res/values-television/colors.xml
new file mode 120000
index 0000000..7fee38c
--- /dev/null
+++ b/app/src/main/res/values-television/colors.xml
@@ -0,0 +1 @@
+../values-night/colors.xml
\ No newline at end of file
diff --git a/app/src/main/res/values-television/styles.xml b/app/src/main/res/values-television/styles.xml
new file mode 100644
index 0000000..eaee49d
--- /dev/null
+++ b/app/src/main/res/values-television/styles.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
new file mode 100644
index 0000000..f06a2af
--- /dev/null
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -0,0 +1,59 @@
+
+ droidVNC-NG管理面板
+ 设置
+ 端口
+ 禁用传入连接
+ 密码
+ 访问键
+ 开机启动
+ 启动延迟(秒)
+ 文件传输
+ 缩放
+ 只读
+ 鼠标指针
+ 权限仪表盘
+ :
+ 截屏
+ 未知
+ 已授权
+ 已禁止
+ 文件访问权限
+ 通知
+ 输入
+ 可以访问当前服务在
+ 该服务器没有监听传入的连接。
+ 你可以建立出站连接:
+ 正在连接到%1$s
+ 连接到监听的viewer
+ 域名:端口
+ 成功连接到%1$s:%2$d
+ 连接%1$s:%2$d失败
+ 连接到中继器
+ 域名:端口
+ 中继器上的ID
+ 成功连接到%1$s:%2$d;ID%3$s
+ 连接%1$s:%2$d失败;ID%3$s
+ 域名/ID遗失
+ 从VNC客户端,Ctrl-Shift-Esc 打开 \'最近的app\' 预览,Home/Pos1 作为Home键,Escape 作为返回键。
+ 这是droidVNC-NG%1$s。请注意,更多功能仍在添加到droidVNC-NG中。特别是,目前大多数键盘敲击只能通过Android软键盘输入,对来自VNC查看器的键盘事件的支持有限(目前)。请报告任何问题和功能请求 https://github.com/bk138/droidVNC-NG/issues
+ 不监听传入连接
+
+ - 侦听端口 %1$d,已连接 %2$d 个客户端
+ - 监听 %1$d 端口,连接 %2$d 个客户端
+
+ 开始
+ 停止
+ 错误
+ 是
+ 否
+ 或
+ 这使得droidVNC-NG可以将输入事件发布到Android操作系统,从而可以从连接的VNC查看器进行远程控制。所有录入的输入都直接发布到Android,没有任何输入被记录、保存或共享。
+ 无障碍已禁用
+ 为了能够远程控制设备,需要启用droidVNC-NG的可访问性。现在是否想启用可访问性?
+ 找不到默认的辅助功能设置活动。请在系统辅助功能设置中手动启用 droidVNC-NG。
+ 文件访问已禁用
+ 为了能够在设备之间传输文件,需要为droidVNC-NG启用文件访问。现在是否要启用文件访问?
+ 为了能够在设备之间传输文件,需要为droidVNC-NG启用文件访问。请注意,保存文件将只能在"下载"和"文档"目录中。现在是否要启用文件访问?
+ 通知已禁用
+ 为了能够显示服务器状态信息并提供更轻松地导航回管理面板的方法,建议启用通知。 您想立即启用通知吗?
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..a533564
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,8 @@
+
+
+ #6200EE
+ #3700B3
+ #03DAC5
+ @android:color/holo_green_dark
+ @android:color/holo_red_dark
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..9a1d752
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,60 @@
+
+ KnoxVNC
+ KnoxVNC Admin Panel
+ Settings
+ Port
+ Inbound connections disabled
+ Password
+ Access Key
+ Start on Boot
+ Start Delay (s)
+ File Transfer
+ Scaling
+ View Only
+ Mouse Cursors
+ Permissions Dashboard
+ :
+ Screen Capturing
+ unknown
+ granted
+ denied
+ File Access
+ Notifications
+ Input
+ The server is now reachable at
+ The server is not listening for incoming connections.
+ You can make outbound connections:
+ Connecting to %1$s
+ Connect to a listening viewer
+ Host:Port
+ Connection to %1$s:%2$d succeeded
+ Connection to %1$s:%2$d failed
+ Connect to a repeater
+ Host:Port
+ ID on Repeater
+ Connection to %1$s:%2$d with ID %3$s succeeded
+ Connection to %1$s:%2$d with ID %3$s failed
+ Host and/or ID missing
+ From a VNC viewer, Ctrl-Shift-Esc triggers \'Recent Apps\' overview, Home/Pos1 acts as Home button, Escape acts as Back button.
+ This is KnoxVNC-NG %1$s. Please note that more features are still being added to KnoxVNC. In particular, most keyboard strokes as of now can only be input via the Android soft keyboard, there is only limited support for keyboard events from VNC viewers (yet). Please report any issues and feature requests at https://github.com/bk138/droidVNC-NG/issues
+ Not listening for incoming connections
+
+ - Listening on port %1$d, %2$d client connected
+ - Listening on port %1$d, %2$d clients connected
+
+ Start
+ Stop
+ Error
+ Yes
+ No
+ or
+ This allows KnoxVNC to post input events to the Android operating system, thus allowing remote control from a connected VNC viewer. All entered input is directly posted to Android, no input is logged, saved or shared.
+ Accessibility disabled
+ To be able to remotely control your device, you need to enable accessibility for KnoxVNC. Do you want to enable accessibility now?
+ Could not find the default accessibility settings activity. Please enable KnoxVNC in system accessibility settings manually.
+ File access disabled
+ To be able to transfer files to and from your device, you need to enable file access for KnoxVNC. Do you want to enable file access now?
+ To be able to transfer files to and from your device, you need to enable file access for KnoxVNC. Please note that saving files will only be possible in the \'Download\' and \'Documents\' directories. Do you want to enable file access now?
+ Notifications disabled
+ To be able to show server status information and provide a means of navigating back to the admin panel more easily, it is recommended to enable notifications. Do you want to enable notifications now?
+
\ No newline at end of file
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..a965389
--- /dev/null
+++ b/app/src/main/res/values/styles.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/input_service_config.xml b/app/src/main/res/xml/input_service_config.xml
new file mode 100644
index 0000000..b460222
--- /dev/null
+++ b/app/src/main/res/xml/input_service_config.xml
@@ -0,0 +1,8 @@
+
+
diff --git a/app/src/test/java/com/appknox/vnc/ExampleUnitTest.java b/app/src/test/java/com/appknox/vnc/ExampleUnitTest.java
new file mode 100644
index 0000000..6aa84f2
--- /dev/null
+++ b/app/src/test/java/com/appknox/vnc/ExampleUnitTest.java
@@ -0,0 +1,17 @@
+package com.appknox.vnc;
+
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * @see Testing documentation
+ */
+public class ExampleUnitTest {
+ @Test
+ public void addition_isCorrect() {
+ assertEquals(4, 2 + 2);
+ }
+}
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..61e0d90
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,25 @@
+buildscript {
+ ext.kotlin_version = '1.9.0'
+ repositories {
+ google()
+ jcenter()
+ }
+ dependencies {
+ classpath 'com.android.tools.build:gradle:8.2.0'
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+
+ // NOTE: Do not place your application dependencies here; they belong
+ // in the individual module build.gradle files
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ jcenter()
+ }
+}
+
+task clean(type: Delete) {
+ delete rootProject.buildDir
+}
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
new file mode 100644
index 0000000..b2ea4f3
--- /dev/null
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -0,0 +1 @@
+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 is is called droidVNC-NG.
diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.png b/fastlane/metadata/android/en-US/images/featureGraphic.png
new file mode 100644
index 0000000..f112478
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/featureGraphic.png differ
diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png
new file mode 100644
index 0000000..1c8fb50
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png
new file mode 100644
index 0000000..1330b80
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ
diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt
new file mode 100644
index 0000000..4dc1f55
--- /dev/null
+++ b/fastlane/metadata/android/en-US/short_description.txt
@@ -0,0 +1 @@
+VNC server app that does not require root privileges.
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..afa0e84
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,24 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app"s APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Automatically convert third-party libraries to use AndroidX
+android.enableJetifier=true
+android.defaults.buildfeatures.buildconfig=true
+android.nonTransitiveRClass=false
+android.nonFinalResIds=false
+# See CMake output
+android.native.buildOutput=verbose
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..f6b961f
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..d00b9f4
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Thu Nov 26 18:44:43 CET 2020
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..cccdd3d
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,172 @@
+#!/usr/bin/env sh
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=$((i+1))
+ done
+ case $i in
+ (0) set -- ;;
+ (1) set -- "$args0" ;;
+ (2) set -- "$args0" "$args1" ;;
+ (3) set -- "$args0" "$args1" "$args2" ;;
+ (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=$(save "$@")
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
+if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
+ cd "$(dirname "$0")"
+fi
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..e95643d
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,84 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windows variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/libjpeg-turbo b/libjpeg-turbo
new file mode 160000
index 0000000..21b5817
--- /dev/null
+++ b/libjpeg-turbo
@@ -0,0 +1 @@
+Subproject commit 21b5817e609fb0ab66d97d0c644d07f6b44be41a
diff --git a/libvncserver b/libvncserver
new file mode 160000
index 0000000..784cccb
--- /dev/null
+++ b/libvncserver
@@ -0,0 +1 @@
+Subproject commit 784cccbb724517ee4e36d9938f93b9ee168a29e7
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..2501382
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,2 @@
+include ':app'
+rootProject.name = "knoxvnc"
\ No newline at end of file