Compare commits

..

17 Commits

Author SHA1 Message Date
Caten
a956d26f6d Update code to v1.0.14 (10) 2024-02-29 19:35:00 +08:00
Caten
c2ee3b694c Update code to v1.0.13+1 2024-01-24 16:03:45 +08:00
Caten
09bf75beed Change WPS command, remove alipay 2024-01-13 07:52:53 +08:00
Caten
b00ede9483 Merge pull request #9 from wyq0918dev/md3
Modify user interface to material3
2024-01-12 18:57:06 +08:00
王泳淇
b8393dacfd Modify user interface to material3 2024-01-12 16:27:32 +08:00
Caten
ce5ad3b758 Update code to v1.0.12+3 2024-01-07 08:32:56 +08:00
Caten
cf15e2e07d HiDPI options added 2024-01-06 18:44:58 +08:00
Caten
938745036d Update code to v1.0.12+2
wakelock, modified proot with binfmt-like feature, more wine scripts
2023-12-11 15:07:07 +08:00
Caten
b3428555c6 box and wine initial support 2023-11-30 15:57:56 +08:00
Caten
2a19c5eb78 Audio fix, more install scripts 2023-11-25 08:20:12 +08:00
Caten
54a941da63 update readme 2023-11-11 21:28:48 +08:00
Caten
87beedef68 adjust readme 2023-11-11 18:05:11 +08:00
Caten
c6afc4d468 virgl support 2023-11-11 11:23:16 +08:00
Caten
6dbe710fdc getifaddrs fix 2023-11-10 21:29:07 +08:00
Caten
cf8ce47662 Organize the code 2023-11-09 12:48:55 +08:00
Caten
86ce2315d4 Add signal 9 info, adjust readme 2023-11-07 17:29:49 +08:00
Caten
6e51e5b2d2 fix tab key eaten 2023-10-19 23:45:33 +08:00
3210 changed files with 2319501 additions and 931 deletions

1
.gitignore vendored
View File

@@ -42,6 +42,7 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
/android/app/.cxx
/backup

View File

@@ -2,55 +2,68 @@
<img decoding="async" src="readme/cover0.png" width="50%">
即开即用的类PC环境内置火狐浏览器和fcitx输入法等常用软件
给所有安卓arm64设备的“PC应用引擎”平替
Click-to-run debian bookworm xfce on android for Chinese users, with fcitx pinyin input method preinstalled. No termux required.
## 特点
- 一键安装,即开即用
- 来自kali-undercover的win10主题(仅xfce版本),友好的界面
<img decoding="async" src="readme/img1.png" width="50%">
- 提供常用软件的一键安装指令
<img decoding="async" src="readme/img2.png" width="50%">
- 可方便地改变屏幕缩放,不用担心屏幕过大或过小
<img decoding="async" src="readme/img3.gif" width="50%">
- 便捷访问设备文件或通过设备SAF访问软件文件
<img decoding="async" src="readme/img4.png" width="50%">
- 提供终端和众多可调节参数供高级用户使用
<img decoding="async" src="readme/img5.png" width="50%">
## 原理
使用proot运行debian环境
内置[noVNC](https://github.com/novnc/noVNC)显示图形界面
初次启动由于解压的缘故要点时间
以后点开就能用
只支持arm64安卓
## 项目结构
assets的文件来源如下:
assets的文件来源信息可以在[这里](extra/readme.md)找到。
- [proot](https://github.com/Cateners/proot), 使用[build-proot-android](https://github.com/green-green-avk/build-proot-android)脚本编译
- [busybox](https://github.com/meefik/busybox)
- [mediamtx相关](https://github.com/bluenviron/mediamtx)
- [tar](https://github.com/Rprop/tar-android-static)
- [Xserver XSDL, pulseaudio相关文件](https://github.com/pelya/commandergenius/tree/sdl_android/project/jni/application/xserver)
- [Tmoe Linux, debian包来源](https://github.com/2moe/tmoe)
其中tar、busybox和pulseaudio相关文件都是直接用了二进制文件。
对debian容器进行了如下修改
- 使用tmoe安装了xfce环境和全套VNC
- 使用kali-undercover提供的Win10主题美化xfce
- (使用tmoe)安装了fcitx输入法和云拼音组件。按<Ctrl+空格>切换输入法。
- 强烈建议**不要**使用安卓中文输入法直接输入中文,而是使用英文键盘通过容器的输入法输入中文,避免丢字错字。
- 对noVNC进行[修改](https://github.com/Cateners/noVNC)添加了scale factor滑块控制缩放(scale_factor分支)添加了上下左右shift等按键(arrow_key分支),添加了强制显示原系统光标的功能(force_cursor分支),添加了中文翻译(translation_zh_cn分支)
- 在主目录下可以方便地访问手机存储(如果提供了存储权限的话)
- 启动时会尝试挂载手机的一些字体目录(AppFiles/Fonts、Fonts和/system/fonts), 如果这些目录下有字体文件的话会一并加载到系统中,无需额外安装;
- 最后采用tar.xz压缩用split命令分成了xa*等多个文件(低内存设备一次性拷贝大文件会导致软件闪退)。
完整的容器制作过程可以在[这里](extra/build-tiny-rootfs.md)看到。
数据包不再在assets中更新而是随releases提供主要是为了避免git越来越大
lib目录
- main.dart文件页面布局老实说已经有点乱
- main.dart文件页面布局有点乱
- workflow.dart文件逻辑部分目前也还可以理解
- Util 工具类
- TermPty 一个终端
- G 全局变量类
- Workflow 从软件点开到容器启动的所有步骤
## 编译
你需要配置好flutter和安卓sdk然后克隆此项目。
在编译之前需要在release中下载系统rootfs(或者[自行制作](extra/build-tiny-rootfs.md))之后使用split命令分割拷贝到assets。一般我将其分为98MB。
`split -b 98M debian.tar.xz`
接下来就可以编译了。我使用的命令如下:
`flutter build apk --target-platform android-arm64 --split-per-abi --obfuscate --split-debug-info=tiny_computer/sdi`
## 目前已知bug
多用户/分身情形无法sudo, 其它见issue

106
android/app/CMakeLists.txt Normal file
View File

@@ -0,0 +1,106 @@
cmake_minimum_required(VERSION 3.10)
project(NativeVNC C CXX ASM)
set(AVNC_EXTERN_DIR ${PROJECT_SOURCE_DIR}/../extern)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "Build shared Libs" FORCE)
###############################################################################
# Utilities
###############################################################################
# Make sure given submodule is checked out
macro(avnc_check_submodule name)
if (NOT EXISTS "${AVNC_EXTERN_DIR}/${name}/CMakeLists.txt")
message(FATAL_ERROR "git submodule for ${name} is not initialized.
Please run 'git submodule update --init'.")
endif ()
endmacro()
# Required to enable SIMD support on ARM
if (CMAKE_ANDROID_ARCH STREQUAL "arm64")
set(CMAKE_ASM_FLAGS "${CMAKE_ASM_FLAGS} --target=aarch64-linux-android${ANDROID_NATIVE_API_LEVEL}")
elseif (CMAKE_ANDROID_ARCH STREQUAL "arm")
set(CMAKE_ASM_FLAGS "${CMAKE_ASM_FLAGS} --target=arm-linux-androideabi${ANDROID_NATIVE_API_LEVEL}")
endif ()
###############################################################################
# JPEG
###############################################################################
avnc_check_submodule(libjpeg-turbo)
set(AVNC_LIBJPEG_SRC_DIR ${AVNC_EXTERN_DIR}/libjpeg-turbo)
set(AVNC_LIBJPEG_BUILD_DIR ${CMAKE_BINARY_DIR}/libjpeg-turbo)
add_subdirectory(${AVNC_LIBJPEG_SRC_DIR} ${AVNC_LIBJPEG_BUILD_DIR})
# Set these variables so FindJPEG can find the library
set(JPEG_LIBRARY ${AVNC_LIBJPEG_BUILD_DIR}/libturbojpeg.a)
set(JPEG_INCLUDE_DIR ${AVNC_LIBJPEG_SRC_DIR})
include_directories(${AVNC_LIBJPEG_SRC_DIR} ${AVNC_LIBJPEG_BUILD_DIR})
###############################################################################
# SSL
###############################################################################
avnc_check_submodule(wolfssl)
set(AVNC_LIBSSL_SRC_DIR ${AVNC_EXTERN_DIR}/wolfssl)
set(AVNC_LIBSSL_BUILD_DIR ${CMAKE_BINARY_DIR}/wolfssl)
# CMake support in wolfSSl is still under development, so we have to
# manually set some flags to enable OpenSSL compatibility layer
set(WOLFSSL_CRL yes)
set(WOLFSSL_DES3 yes)
set(WOLFSSL_CRYPT_TESTS no)
add_definitions(-DOPENSSL_ALL -DOPENSSL_EXTRA -DHAVE_CRL -DHAVE_EX_DATA
-DHAVE_ANON -DWOLFSSL_AES_DIRECT -DHAVE_AES_ECB -DWOLFSSL_DES_ECB
-DWC_NO_HARDEN)
add_subdirectory(${AVNC_LIBSSL_SRC_DIR} ${AVNC_LIBSSL_BUILD_DIR})
# Set these variables so FindOpenSSL can find the library
set(OPENSSL_SSL_LIBRARY ${AVNC_LIBSSL_BUILD_DIR}/libwolfssl.a)
set(OPENSSL_CRYPTO_LIBRARY ${AVNC_LIBSSL_BUILD_DIR}/libwolfssl.a)
set(OPENSSL_INCLUDE_DIR ${AVNC_LIBSSL_SRC_DIR}/wolfssl)
include_directories(${AVNC_LIBSSL_SRC_DIR})
###############################################################################
# LibVNC
###############################################################################
avnc_check_submodule(libvncserver)
set(AVNC_LIBVNC_SRC_DIR ${AVNC_EXTERN_DIR}/libvncserver)
set(AVNC_LIBVNC_BUILD_DIR ${CMAKE_BINARY_DIR}/libvncserver)
set(WITH_LIBSSH2 OFF CACHE BOOL "Find LibSSH" FORCE)
add_subdirectory(${AVNC_LIBVNC_SRC_DIR} ${AVNC_LIBVNC_BUILD_DIR}) # (source dir, build dir)
include_directories(${AVNC_LIBVNC_SRC_DIR}/include ${AVNC_LIBVNC_BUILD_DIR}/include)
###############################################################################
# Native VNC
#
# It contains implementation of JNI native methods, some NDK scaffolding and
# some helpers for OpenGL ES rendring. This is the library loaded from Java.
###############################################################################
set(AVNC_NATIVE_SOURCE src/main/cpp/native-vnc.cpp)
add_library(native-vnc SHARED ${AVNC_NATIVE_SOURCE})
target_link_libraries(native-vnc vncclient)
# Link NDK libraries
find_library(LIB_LOG log)
target_link_libraries(native-vnc ${LIB_LOG})
find_library(LIB_GLES GLESv2)
target_link_libraries(native-vnc ${LIB_GLES})

View File

@@ -2,6 +2,10 @@ plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
id "org.jetbrains.kotlin.kapt"
id "org.jetbrains.kotlin.plugin.serialization" version "1.9.10"
id "org.jetbrains.kotlin.plugin.parcelize"
}
def localProperties = new Properties()
@@ -50,6 +54,13 @@ android {
targetSdkVersion 28 //https://github.com/termux/termux-app/issues/1072; native; linker; flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
javaCompileOptions {
annotationProcessorOptions {
arguments += ["room.schemaLocation": "$projectDir/roomSchema/".toString()]
}
}
}
buildTypes {
@@ -60,14 +71,56 @@ android {
}
}
buildFeatures {
dataBinding true
buildConfig true
}
externalNativeBuild {
cmake {
version '3.22.1'
path file('CMakeLists.txt')
}
}
lintOptions {
//checkReleaseBuilds false
abortOnError false
}
packagingOptions {
pickFirst 'lib/arm64-v8a/libc++_shared.so'
pickFirst 'lib/x86_64/libc++_shared.so'
pickFirst 'lib/armeabi-v7a/libc++_shared.so'
pickFirst 'lib/arm64-v8a/libc++_shared.so'
}
}
flutter {
source '../..'
}
dependencies {}
dependencies {
implementation "androidx.core:core-ktx:1.12.0"
implementation "androidx.activity:activity-ktx:1.8.2"
implementation "androidx.fragment:fragment-ktx:1.6.2"
implementation "androidx.appcompat:appcompat:1.6.1"
implementation "androidx.preference:preference-ktx:1.2.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.6.2"
implementation "androidx.dynamicanimation:dynamicanimation:1.0.0"
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
implementation "androidx.recyclerview:recyclerview:1.2.1"
def roomVersion = "2.6.1"
implementation "androidx.room:room-runtime:$roomVersion"
implementation "androidx.room:room-ktx:$roomVersion"
kapt "androidx.room:room-compiler:$roomVersion"
implementation "com.google.android.material:material:1.7.0"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0"
implementation "org.connectbot:sshlib:2.2.23"
}

View File

@@ -0,0 +1,160 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "ccb0ad6d8acbefcb44a49c07f353adc8",
"entities": [
{
"tableName": "profiles",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ID` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `host` TEXT NOT NULL, `port` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `securityType` INTEGER NOT NULL, `channelType` INTEGER NOT NULL, `colorLevel` INTEGER NOT NULL, `imageQuality` INTEGER NOT NULL, `viewOnly` INTEGER NOT NULL, `useLocalCursor` INTEGER NOT NULL, `keyCompatMode` INTEGER NOT NULL, `useRepeater` INTEGER NOT NULL, `idOnRepeater` INTEGER NOT NULL, `sshHost` TEXT NOT NULL, `sshPort` INTEGER NOT NULL, `sshUsername` TEXT NOT NULL, `sshAuthType` INTEGER NOT NULL, `sshPassword` TEXT NOT NULL, `sshPrivateKey` TEXT NOT NULL, `sshPrivateKeyPassword` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "ID",
"columnName": "ID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "host",
"columnName": "host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "port",
"columnName": "port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "securityType",
"columnName": "securityType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "channelType",
"columnName": "channelType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "colorLevel",
"columnName": "colorLevel",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imageQuality",
"columnName": "imageQuality",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "viewOnly",
"columnName": "viewOnly",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "useLocalCursor",
"columnName": "useLocalCursor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "keyCompatMode",
"columnName": "keyCompatMode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "useRepeater",
"columnName": "useRepeater",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "idOnRepeater",
"columnName": "idOnRepeater",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sshHost",
"columnName": "sshHost",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sshPort",
"columnName": "sshPort",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sshUsername",
"columnName": "sshUsername",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sshAuthType",
"columnName": "sshAuthType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sshPassword",
"columnName": "sshPassword",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sshPrivateKey",
"columnName": "sshPrivateKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sshPrivateKeyPassword",
"columnName": "sshPrivateKeyPassword",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"ID"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ccb0ad6d8acbefcb44a49c07f353adc8')"
]
}
}

View File

@@ -0,0 +1,188 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "d54a9ffbaa53dfe8f4ce8f5708a719ae",
"entities": [
{
"tableName": "profiles",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ID` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `host` TEXT NOT NULL, `port` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `securityType` INTEGER NOT NULL, `channelType` INTEGER NOT NULL, `colorLevel` INTEGER NOT NULL, `imageQuality` INTEGER NOT NULL, `useRawEncoding` INTEGER NOT NULL DEFAULT 0, `zoom1` REAL NOT NULL DEFAULT 1.0, `zoom2` REAL NOT NULL DEFAULT 1.0, `viewOnly` INTEGER NOT NULL, `useLocalCursor` INTEGER NOT NULL, `keyCompatMode` INTEGER NOT NULL, `gestureStyle` TEXT NOT NULL DEFAULT 'auto', `useRepeater` INTEGER NOT NULL, `idOnRepeater` INTEGER NOT NULL, `sshHost` TEXT NOT NULL, `sshPort` INTEGER NOT NULL, `sshUsername` TEXT NOT NULL, `sshAuthType` INTEGER NOT NULL, `sshPassword` TEXT NOT NULL, `sshPrivateKey` TEXT NOT NULL, `sshPrivateKeyPassword` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "ID",
"columnName": "ID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "host",
"columnName": "host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "port",
"columnName": "port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "securityType",
"columnName": "securityType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "channelType",
"columnName": "channelType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "colorLevel",
"columnName": "colorLevel",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imageQuality",
"columnName": "imageQuality",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "useRawEncoding",
"columnName": "useRawEncoding",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "zoom1",
"columnName": "zoom1",
"affinity": "REAL",
"notNull": true,
"defaultValue": "1.0"
},
{
"fieldPath": "zoom2",
"columnName": "zoom2",
"affinity": "REAL",
"notNull": true,
"defaultValue": "1.0"
},
{
"fieldPath": "viewOnly",
"columnName": "viewOnly",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "useLocalCursor",
"columnName": "useLocalCursor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "keyCompatMode",
"columnName": "keyCompatMode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "gestureStyle",
"columnName": "gestureStyle",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'auto'"
},
{
"fieldPath": "useRepeater",
"columnName": "useRepeater",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "idOnRepeater",
"columnName": "idOnRepeater",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sshHost",
"columnName": "sshHost",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sshPort",
"columnName": "sshPort",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sshUsername",
"columnName": "sshUsername",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sshAuthType",
"columnName": "sshAuthType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sshPassword",
"columnName": "sshPassword",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sshPrivateKey",
"columnName": "sshPrivateKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sshPrivateKeyPassword",
"columnName": "sshPrivateKeyPassword",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"ID"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd54a9ffbaa53dfe8f4ce8f5708a719ae')"
]
}
}

View File

@@ -0,0 +1,209 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "eb32c7692bb75a6297413b807713616c",
"entities": [
{
"tableName": "profiles",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ID` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `host` TEXT NOT NULL, `port` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `securityType` INTEGER NOT NULL, `channelType` INTEGER NOT NULL, `colorLevel` INTEGER NOT NULL, `imageQuality` INTEGER NOT NULL, `useRawEncoding` INTEGER NOT NULL DEFAULT 0, `zoom1` REAL NOT NULL DEFAULT 1.0, `zoom2` REAL NOT NULL DEFAULT 1.0, `viewOnly` INTEGER NOT NULL, `useLocalCursor` INTEGER NOT NULL, `serverTypeHint` TEXT NOT NULL DEFAULT '', `compatFlags` INTEGER NOT NULL, `gestureStyle` TEXT NOT NULL DEFAULT 'auto', `screenOrientation` TEXT NOT NULL DEFAULT 'auto', `shortcutRank` INTEGER NOT NULL DEFAULT 0, `useRepeater` INTEGER NOT NULL, `idOnRepeater` INTEGER NOT NULL, `sshHost` TEXT NOT NULL, `sshPort` INTEGER NOT NULL, `sshUsername` TEXT NOT NULL, `sshAuthType` INTEGER NOT NULL, `sshPassword` TEXT NOT NULL, `sshPrivateKey` TEXT NOT NULL, `sshPrivateKeyPassword` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "ID",
"columnName": "ID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "host",
"columnName": "host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "port",
"columnName": "port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "securityType",
"columnName": "securityType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "channelType",
"columnName": "channelType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "colorLevel",
"columnName": "colorLevel",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imageQuality",
"columnName": "imageQuality",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "useRawEncoding",
"columnName": "useRawEncoding",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "zoom1",
"columnName": "zoom1",
"affinity": "REAL",
"notNull": true,
"defaultValue": "1.0"
},
{
"fieldPath": "zoom2",
"columnName": "zoom2",
"affinity": "REAL",
"notNull": true,
"defaultValue": "1.0"
},
{
"fieldPath": "viewOnly",
"columnName": "viewOnly",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "useLocalCursor",
"columnName": "useLocalCursor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serverTypeHint",
"columnName": "serverTypeHint",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "compatFlags",
"columnName": "compatFlags",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "gestureStyle",
"columnName": "gestureStyle",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'auto'"
},
{
"fieldPath": "screenOrientation",
"columnName": "screenOrientation",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'auto'"
},
{
"fieldPath": "shortcutRank",
"columnName": "shortcutRank",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "useRepeater",
"columnName": "useRepeater",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "idOnRepeater",
"columnName": "idOnRepeater",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sshHost",
"columnName": "sshHost",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sshPort",
"columnName": "sshPort",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sshUsername",
"columnName": "sshUsername",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sshAuthType",
"columnName": "sshAuthType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sshPassword",
"columnName": "sshPassword",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sshPrivateKey",
"columnName": "sshPrivateKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sshPrivateKeyPassword",
"columnName": "sshPrivateKeyPassword",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"ID"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'eb32c7692bb75a6297413b807713616c')"
]
}
}

View File

@@ -0,0 +1,216 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "5ff9de8e52fb13b10ee86f9c75714cd5",
"entities": [
{
"tableName": "profiles",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ID` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `host` TEXT NOT NULL, `port` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `securityType` INTEGER NOT NULL, `channelType` INTEGER NOT NULL, `colorLevel` INTEGER NOT NULL, `imageQuality` INTEGER NOT NULL, `useRawEncoding` INTEGER NOT NULL DEFAULT 0, `zoom1` REAL NOT NULL DEFAULT 1.0, `zoom2` REAL NOT NULL DEFAULT 1.0, `viewOnly` INTEGER NOT NULL, `useLocalCursor` INTEGER NOT NULL, `serverTypeHint` TEXT NOT NULL DEFAULT '', `compatFlags` INTEGER NOT NULL, `gestureStyle` TEXT NOT NULL DEFAULT 'auto', `screenOrientation` TEXT NOT NULL DEFAULT 'auto', `shortcutRank` INTEGER NOT NULL DEFAULT 0, `useRepeater` INTEGER NOT NULL, `idOnRepeater` INTEGER NOT NULL, `resizeRemoteDesktop` INTEGER NOT NULL DEFAULT 0, `sshHost` TEXT NOT NULL, `sshPort` INTEGER NOT NULL, `sshUsername` TEXT NOT NULL, `sshAuthType` INTEGER NOT NULL, `sshPassword` TEXT NOT NULL, `sshPrivateKey` TEXT NOT NULL, `sshPrivateKeyPassword` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "ID",
"columnName": "ID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "host",
"columnName": "host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "port",
"columnName": "port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "securityType",
"columnName": "securityType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "channelType",
"columnName": "channelType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "colorLevel",
"columnName": "colorLevel",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imageQuality",
"columnName": "imageQuality",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "useRawEncoding",
"columnName": "useRawEncoding",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "zoom1",
"columnName": "zoom1",
"affinity": "REAL",
"notNull": true,
"defaultValue": "1.0"
},
{
"fieldPath": "zoom2",
"columnName": "zoom2",
"affinity": "REAL",
"notNull": true,
"defaultValue": "1.0"
},
{
"fieldPath": "viewOnly",
"columnName": "viewOnly",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "useLocalCursor",
"columnName": "useLocalCursor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serverTypeHint",
"columnName": "serverTypeHint",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "compatFlags",
"columnName": "compatFlags",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "gestureStyle",
"columnName": "gestureStyle",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'auto'"
},
{
"fieldPath": "screenOrientation",
"columnName": "screenOrientation",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'auto'"
},
{
"fieldPath": "shortcutRank",
"columnName": "shortcutRank",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "useRepeater",
"columnName": "useRepeater",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "idOnRepeater",
"columnName": "idOnRepeater",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "resizeRemoteDesktop",
"columnName": "resizeRemoteDesktop",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "sshHost",
"columnName": "sshHost",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sshPort",
"columnName": "sshPort",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sshUsername",
"columnName": "sshUsername",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sshAuthType",
"columnName": "sshAuthType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sshPassword",
"columnName": "sshPassword",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sshPrivateKey",
"columnName": "sshPrivateKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sshPrivateKeyPassword",
"columnName": "sshPrivateKeyPassword",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"ID"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5ff9de8e52fb13b10ee86f9c75714cd5')"
]
}
}

View File

@@ -0,0 +1,215 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "8448d51fc9838e5760b91d4b5318ade2",
"entities": [
{
"tableName": "profiles",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ID` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `host` TEXT NOT NULL, `port` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `securityType` INTEGER NOT NULL, `channelType` INTEGER NOT NULL, `colorLevel` INTEGER NOT NULL, `imageQuality` INTEGER NOT NULL, `useRawEncoding` INTEGER NOT NULL DEFAULT 0, `zoom1` REAL NOT NULL DEFAULT 1.0, `zoom2` REAL NOT NULL DEFAULT 1.0, `viewOnly` INTEGER NOT NULL, `useLocalCursor` INTEGER NOT NULL, `serverTypeHint` TEXT NOT NULL DEFAULT '', `flags` INTEGER NOT NULL, `gestureStyle` TEXT NOT NULL DEFAULT 'auto', `screenOrientation` TEXT NOT NULL DEFAULT 'auto', `useCount` INTEGER NOT NULL, `useRepeater` INTEGER NOT NULL, `idOnRepeater` INTEGER NOT NULL, `resizeRemoteDesktop` INTEGER NOT NULL DEFAULT 0, `sshHost` TEXT NOT NULL, `sshPort` INTEGER NOT NULL, `sshUsername` TEXT NOT NULL, `sshAuthType` INTEGER NOT NULL, `sshPassword` TEXT NOT NULL, `sshPrivateKey` TEXT NOT NULL, `sshPrivateKeyPassword` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "ID",
"columnName": "ID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "host",
"columnName": "host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "port",
"columnName": "port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "securityType",
"columnName": "securityType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "channelType",
"columnName": "channelType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "colorLevel",
"columnName": "colorLevel",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imageQuality",
"columnName": "imageQuality",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "useRawEncoding",
"columnName": "useRawEncoding",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "zoom1",
"columnName": "zoom1",
"affinity": "REAL",
"notNull": true,
"defaultValue": "1.0"
},
{
"fieldPath": "zoom2",
"columnName": "zoom2",
"affinity": "REAL",
"notNull": true,
"defaultValue": "1.0"
},
{
"fieldPath": "viewOnly",
"columnName": "viewOnly",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "useLocalCursor",
"columnName": "useLocalCursor",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serverTypeHint",
"columnName": "serverTypeHint",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "flags",
"columnName": "flags",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "gestureStyle",
"columnName": "gestureStyle",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'auto'"
},
{
"fieldPath": "screenOrientation",
"columnName": "screenOrientation",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'auto'"
},
{
"fieldPath": "useCount",
"columnName": "useCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "useRepeater",
"columnName": "useRepeater",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "idOnRepeater",
"columnName": "idOnRepeater",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "resizeRemoteDesktop",
"columnName": "resizeRemoteDesktop",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "sshHost",
"columnName": "sshHost",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sshPort",
"columnName": "sshPort",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sshUsername",
"columnName": "sshUsername",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sshAuthType",
"columnName": "sshAuthType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sshPassword",
"columnName": "sshPassword",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sshPrivateKey",
"columnName": "sshPrivateKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sshPrivateKeyPassword",
"columnName": "sshPrivateKeyPassword",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"ID"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8448d51fc9838e5760b91d4b5318ade2')"
]
}
}

View File

@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
@@ -9,7 +10,8 @@
android:label="小小电脑"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
android:usesCleartextTraffic="true"
android:theme="@style/App.Theme">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -31,6 +33,32 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity-alias
android:name="com.gaurav.avnc.UriReceiverActivity"
android:targetActivity="com.gaurav.avnc.ui.vnc.IntentReceiverActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="vnc" />
</intent-filter>
</activity-alias>
<activity
android:name="com.gaurav.avnc.ui.vnc.VncActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
android:resizeableActivity="true"
android:supportsPictureInPicture="true"
tools:ignore="UnusedAttribute" />
<activity
android:name="com.gaurav.avnc.ui.vnc.IntentReceiverActivity"
android:theme="@style/App.SplashTheme.Dark" />
<activity android:name="com.gaurav.avnc.ui.prefs.PrefsActivity" />
<activity android:name="com.gaurav.avnc.ui.about.AboutActivity" />
<provider
android:name="com.example.tiny_computer.filepicker.TinyDocumentsProvider"
android:authorities="com.example.tiny_computer.documents"

View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,29 @@
The Modified (3-clause) BSD License
===================================
Copyright (C)2009-2021 D. R. Commander. All Rights Reserved.<br>
Copyright (C)2015 Viktor Szathmáry. All Rights Reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
- Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
- Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
- Neither the name of the libjpeg-turbo Project nor the names of its
contributors may be used to endorse or promote products derived from this
software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS",
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.

View File

@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@@ -0,0 +1,48 @@
/***********************************************************
Copyright 1987, 1994, 1998 The Open Group
Permission to use, copy, modify, distribute, and sell this software and its
documentation for any purpose is hereby granted without fee, provided that
the above copyright notice appear in all copies and that both that
copyright notice and this permission notice appear in supporting
documentation.
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE OPEN GROUP BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
Except as contained in this notice, the name of The Open Group shall
not be used in advertising or otherwise to promote the sale, use or
other dealings in this Software without prior written authorization
from The Open Group.
Copyright 1987 by Digital Equipment Corporation, Maynard, Massachusetts
All Rights Reserved
Permission to use, copy, modify, and distribute this software and its
documentation for any purpose and without fee is hereby granted,
provided that the above copyright notice appear in all copies and that
both that copyright notice and this permission notice appear in
supporting documentation, and that the name of Digital not be
used in advertising or publicity pertaining to distribution of the
software without specific, written prior permission.
DIGITAL DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING
ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL
DIGITAL BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR
ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS
SOFTWARE.
******************************************************************/

View File

@@ -0,0 +1,87 @@
Copyright (c) 2007-2008 Trilead AG (http://www.trilead.com)
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
a.) Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
b.) Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
c.) Neither the name of Trilead nor the names of its contributors may
be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
Trilead SSH-2 for Java includes code that was written by Dr. Christian Plattner
during his PhD at ETH Zurich. The license states the following:
Copyright (c) 2005 - 2006 Swiss Federal Institute of Technology (ETH Zurich),
Department of Computer Science (http://www.inf.ethz.ch),
Christian Plattner. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
a.) Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
b.) Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
c.) Neither the name of ETH Zurich nor the names of its contributors may
be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
The Java implementations of the AES, Blowfish and 3DES ciphers have been
taken (and slightly modified) from the cryptography package released by
"The Legion Of The Bouncy Castle".
Their license states the following:
Copyright (c) 2000 - 2004 The Legion Of The Bouncy Castle
(http://www.bouncycastle.org)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,87 @@
/*
* Copyright (c) 2022 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
#ifndef AVNC_CLIENTEX_H
#define AVNC_CLIENTEX_H
#include <jni.h>
#include "Cursor.h"
/**
* We attach some additional data to every rfbClient.
* ClientEx is used as wrapper for this data.
*/
struct ClientEx {
// Reference to managed `VncClient`
jobject managedClient;
// Although frame width & height are maintained in rfbClient, those values
// are modified before our MallocFrameBuffer callback is triggered, and
// we cannot protect them with a mutex. So we maintain the framebuffer
// size here, protected with fbMutex.
int fbRealWidth;
int fbRealHeight;
// Cursor data used for client-side cursor rendering
Cursor *cursor;
// Protects modification to framebuffer & cursor
MUTEX(mutex);
};
const int ClientExTag = 1;
ClientEx *getClientExtension(rfbClient *client) {
return (ClientEx *) rfbClientGetClientData(client, (void *) &ClientExTag);
}
void setClientExtension(rfbClient *client, ClientEx *ex) {
rfbClientSetClientData(client, (void *) &ClientExTag, ex);
}
/**
* Returns reference to managed `VncClient` associated with given rfbClient.
*/
jobject getManagedClient(rfbClient *client) {
return getClientExtension(client)->managedClient;
}
/**
* Associate given rfbClient & managed `VncClient`.
*/
void setManagedClient(rfbClient *client, jobject managedClient) {
getClientExtension(client)->managedClient = managedClient;
}
/**
* Create new ClientEx and assign it to given client.
*/
ClientEx *assignClientExtension(rfbClient *client) {
auto ex = (ClientEx *) malloc(sizeof(ClientEx));
if (ex) {
INIT_MUTEX(ex->mutex);
ex->cursor = nullptr;
setClientExtension(client, ex);
}
return ex;
}
/**
* Free all resources related to client extension.
*/
void freeClientExtension(rfbClient *client) {
auto ex = getClientExtension(client);
if (ex) {
TINI_MUTEX(ex->mutex);
freeCursor(ex->cursor);
free(ex);
setClientExtension(client, nullptr);
}
}
#endif //AVNC_CLIENTEX_H

View File

@@ -0,0 +1,111 @@
/*
* Copyright (c) 2022 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
#ifndef AVNC_CURSOR_H
#define AVNC_CURSOR_H
/******************************************************************************
* Some servers (e.g TigerVNC) may not send the cursor immediately after
* connection. To provide consistent experience to users, we use a default
* cursor as fallback.
*****************************************************************************/
const uint16_t DefaultCursorWidth = 10;
const uint16_t DefaultCursorHeight = 16;
const uint16_t DefaultCursorXHot = 1;
const uint16_t DefaultCursorYHot = 1;
const uint32_t DefaultCursorBuffer[DefaultCursorWidth * DefaultCursorHeight]
= {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x00FFFFFF, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x00FFFFFF, 0x00FFFFFF, 0, 0, 0, 0,
0, 0, 0, 0, 0x00FFFFFF, 0x00FFFFFF, 0x00FFFFFF, 0, 0, 0, 0, 0, 0, 0, 0x00FFFFFF, 0x00FFFFFF, 0x00FFFFFF,
0x00FFFFFF, 0, 0, 0, 0, 0, 0, 0x00FFFFFF, 0x00FFFFFF, 0x00FFFFFF, 0x00FFFFFF, 0x00FFFFFF, 0, 0, 0, 0, 0,
0x00FFFFFF, 0x00FFFFFF, 0x00FFFFFF, 0x00FFFFFF, 0x00FFFFFF, 0x00FFFFFF, 0, 0, 0, 0, 0x00FFFFFF, 0x00FFFFFF,
0x00FFFFFF, 0x00FFFFFF, 0x00FFFFFF, 0x00FFFFFF, 0x00FFFFFF, 0, 0, 0, 0x00FFFFFF, 0x00FFFFFF, 0x00FFFFFF,
0x00FFFFFF, 0x00FFFFFF, 0x00FFFFFF, 0x00FFFFFF, 0x00FFFFFF, 0, 0, 0x00FFFFFF, 0x00FFFFFF, 0x00FFFFFF,
0x00FFFFFF, 0x00FFFFFF, 0, 0, 0, 0, 0, 0x00FFFFFF, 0x00FFFFFF, 0, 0x00FFFFFF, 0x00FFFFFF, 0, 0, 0, 0, 0,
0x00FFFFFF, 0, 0, 0, 0x00FFFFFF, 0x00FFFFFF, 0, 0, 0, 0, 0, 0, 0, 0, 0x00FFFFFF, 0x00FFFFFF, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0x00FFFFFF, 0x00FFFFFF, 0, 0, 0, 0, 0, 0, 0, 0, 0x00FFFFFF, 0x00FFFFFF, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0};
const uint8_t DefaultCursorMask[DefaultCursorWidth * DefaultCursorHeight]
= {1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0,
0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1,
1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0,
0, 0, 1, 1, 1, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0,
0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0};
/******************************************************************************
* Cursor management
*****************************************************************************/
/**
* Wrapper for cursor information.
*
* rfbClient struct does not maintain all cursor related information inside it.
* Things like xHot, yHot are passed only via the cursor shape callback.
* This wrapper holds all information necessary to render the cursor.
*/
struct Cursor {
uint8_t *buffer;
uint8_t *mask;
uint8_t *scratchBuffer; //Used during rendering
uint16_t width;
uint16_t height;
uint16_t xHot;
uint16_t yHot;
};
//Only 4-byte pixels are currently supported
const uint8_t PixelBytes = 4;
/**
* Creates a new CursorData, initialized with default cursor info.
*/
Cursor *newCursor() {
auto cursor = (Cursor *) malloc(sizeof(Cursor));
if (cursor) {
cursor->buffer = (uint8_t *) DefaultCursorBuffer;
cursor->mask = (uint8_t *) DefaultCursorMask;
cursor->scratchBuffer = (uint8_t *) malloc(DefaultCursorWidth * DefaultCursorHeight * PixelBytes);
cursor->width = DefaultCursorWidth;
cursor->height = DefaultCursorHeight;
cursor->xHot = DefaultCursorXHot;
cursor->yHot = DefaultCursorYHot;
}
return cursor;
}
void freeCursorBuffers(Cursor *cursor) {
if (cursor) {
free(cursor->scratchBuffer);
if (cursor->buffer != (uint8_t *) DefaultCursorBuffer) free(cursor->buffer);
if (cursor->mask != (uint8_t *) DefaultCursorMask) free(cursor->mask);
}
}
void freeCursor(Cursor *cursor) {
freeCursorBuffers(cursor);
free(cursor);
}
void updateCursor(Cursor *cursor, uint8_t *buffer, uint8_t *mask, uint16_t width, uint16_t height,
uint16_t xHot, uint16_t yHot) {
freeCursorBuffers(cursor);
cursor->buffer = buffer;
cursor->mask = mask;
cursor->scratchBuffer = (uint8_t *) malloc(width * height * PixelBytes);
cursor->width = width;
cursor->height = height;
cursor->xHot = xHot;
cursor->yHot = yHot;
}
#endif //AVNC_CURSOR_H

View File

@@ -0,0 +1,94 @@
/*
* Copyright (c) 2022 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
#ifndef AVNC_UTILITY_H
#define AVNC_UTILITY_H
#include <stdarg.h>
#include <netdb.h>
#include <errno.h>
#include <android/log.h>
/******************************************************************************
* Utilities
*****************************************************************************/
/**
* Returns a native copy of the given jstring.
* Caller is responsible for releasing the memory.
*/
static char *getNativeStrCopy(JNIEnv *env, jstring jStr) {
const char *cStr = env->GetStringUTFChars(jStr, nullptr);
char *str = strdup(cStr);
env->ReleaseStringUTFChars(jStr, cStr);
return str;
}
/******************************************************************************
* Logging
*****************************************************************************/
const char *LOG_TAG = "NativeVnc";
void log_info(const char *fmt, ...) {
va_list args;
va_start(args, fmt);
__android_log_vprint(ANDROID_LOG_INFO, LOG_TAG, fmt, args);
va_end(args);
}
void log_error(const char *fmt, ...) {
va_list args;
va_start(args, fmt);
__android_log_vprint(ANDROID_LOG_ERROR, LOG_TAG, fmt, args);
va_end(args);
}
/**
* Converts given errno value to its description.
*/
static const char *errnoToStr(int e) {
// LibVNC is patched to report `getaddrinfo` errors as negative 'errno'.
// See ConnectClientToTcpAddr6WithTimeout() in sockets.c
if (e < -1000) {
return gai_strerror((-e) - 1000);
}
switch (e) {
case ENETDOWN:
case ENETRESET:
case ENETUNREACH:
case ECONNABORTED:
case EHOSTDOWN:
case EHOSTUNREACH:
case ETIMEDOUT:
case ENOMEM:
case EPROTO:
case EIO:
return strerror(e);
case ECONNREFUSED:
return "Connection refused! Server may be down or running on different port";
case ECONNRESET:
return "Connection closed by server";
case EACCES:
return "Authentication failed";
default:
// In this case we don't want to display errno description to user
// because it is more likely to be misleading (e.g. EINTR, EAGAIN).
// BUT add it to logs in case LibVNC didn't.
log_error("errnoToStr: (%d %s)", errno, strerror(errno));
return "";
}
}
#endif //AVNC_UTILITY_H

View File

@@ -0,0 +1,561 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
#include <jni.h>
#include <GLES2/gl2.h>
#include <rfb/rfbclient.h>
#include "ClientEx.h"
#include "Utility.h"
/******************************************************************************
* Library Initialization
*****************************************************************************/
struct JniContext {
JavaVM *vm; //JVM Instance
jclass managedCls; //Managed `VncClient` class
jmethodID cbFramebufferUpdated; //Cached reference to managed callback
JNIEnv *getEnv() const {
JNIEnv *env = nullptr;
if (vm != nullptr && vm->GetEnv((void **) &env, JNI_VERSION_1_6) == JNI_OK)
return env;
return nullptr; //Should not happen
}
};
static JniContext context{};
/**
* Called when our library is loaded.
*/
JNIEXPORT jint
JNI_OnLoad(JavaVM *vm, void *unused) {
context.vm = vm;
if (context.getEnv() == nullptr)
return JNI_ERR;
return JNI_VERSION_1_6;
}
JNIEXPORT void
JNI_OnUnload(JavaVM *vm, void *reserved) {
if (context.managedCls != nullptr)
context.getEnv()->DeleteGlobalRef(context.managedCls);
}
extern "C"
JNIEXPORT void JNICALL
Java_com_gaurav_avnc_vnc_VncClient_initLibrary(JNIEnv *env, jclass clazz) {
context.managedCls = (jclass) env->NewGlobalRef(clazz);
context.cbFramebufferUpdated = env->GetMethodID(clazz, "cbFinishedFrameBufferUpdate", "()V");
//TODO: Cache more method IDs so we don't have to repeatedly search them
rfbClientLog = &log_info;
rfbClientErr = &log_error;
}
/******************************************************************************
* rfbClient Callbacks
*****************************************************************************/
static char *onGetPassword(rfbClient *client) {
auto obj = getManagedClient(client);
auto env = context.getEnv();
auto cls = context.managedCls;
auto mid = env->GetMethodID(cls, "cbGetPassword", "()Ljava/lang/String;");
auto jPassword = (jstring) env->CallObjectMethod(obj, mid);
return getNativeStrCopy(env, jPassword);
}
static rfbCredential *onGetCredential(rfbClient *client, int credentialType) {
if (credentialType != rfbCredentialTypeUser) {
//Only user credentials (i.e. username & password) are currently supported
rfbClientErr("Unsupported credential type requested");
return nullptr;
}
auto obj = getManagedClient(client);
auto env = context.getEnv();
auto cls = context.managedCls;
//Retrieve credentials
jmethodID mid = env->GetMethodID(cls, "cbGetCredential",
"()Lcom/gaurav/avnc/vnc/UserCredential;");
jobject jCredential = env->CallObjectMethod(obj, mid);
if (jCredential == nullptr) {
return nullptr;
}
//Extract username & password
auto jCredentialCls = env->GetObjectClass(jCredential);
auto usernameField = env->GetFieldID(jCredentialCls, "username", "Ljava/lang/String;");
auto jUsername = env->GetObjectField(jCredential, usernameField);
auto passwordField = env->GetFieldID(jCredentialCls, "password", "Ljava/lang/String;");
auto jPassword = env->GetObjectField(jCredential, passwordField);
//Create native rfbCredential
auto credential = (rfbCredential *) malloc(sizeof(rfbCredential));
credential->userCredential.username = getNativeStrCopy(env, (jstring) jUsername);
credential->userCredential.password = getNativeStrCopy(env, (jstring) jPassword);
return credential;
}
static void onBell(rfbClient *client) {
auto obj = getManagedClient(client);
auto env = context.getEnv();
auto cls = context.managedCls;
jmethodID mid = env->GetMethodID(cls, "cbBell", "()V");
env->CallVoidMethod(obj, mid);
}
static void onGotXCutText(rfbClient *client, const char *text, int len, bool is_utf8) {
auto obj = getManagedClient(client);
auto env = context.getEnv();
auto cls = context.managedCls;
jmethodID mid = env->GetMethodID(cls, "cbGotXCutText", "([BZ)V");
jbyteArray bytes = env->NewByteArray(len);
env->SetByteArrayRegion(bytes, 0, len, reinterpret_cast<const jbyte *>(text));
env->CallVoidMethod(obj, mid, bytes, is_utf8);
}
static void onGotXCutTextLatin1(rfbClient *client, const char *text, int len) {
onGotXCutText(client, text, len, false);
}
static void onGotXCutTextUTF8(rfbClient *client, const char *text, int len) {
onGotXCutText(client, text, len, true);
}
static rfbBool onHandleCursorPos(rfbClient *client, int x, int y) {
auto obj = getManagedClient(client);
auto env = context.getEnv();
auto cls = context.managedCls;
jmethodID mid = env->GetMethodID(cls, "cbHandleCursorPos", "(II)V");
env->CallVoidMethod(obj, mid, x, y);
return TRUE;
}
static void onFinishedFrameBufferUpdate(rfbClient *client) {
auto obj = getManagedClient(client);
auto env = context.getEnv();
env->CallVoidMethod(obj, context.cbFramebufferUpdated);
}
/**
* We need to use our own allocator to know when frame size has changed.
* and to acquire framebuffer lock during modification.
*/
static rfbBool onMallocFrameBuffer(rfbClient *client) {
const auto width = client->width;
const auto height = client->height;
const auto requestedSize = (uint64_t) width * height * client->format.bitsPerPixel / 8;
if (requestedSize >= SIZE_MAX) {
rfbClientErr("CRITICAL: cannot allocate frameBuffer, requested size is too large\n");
return FALSE;
}
auto allocSize = (size_t) requestedSize;
auto ex = getClientExtension(client);
LOCK(ex->mutex);
{
if (client->frameBuffer)
free(client->frameBuffer);
client->frameBuffer = static_cast<uint8_t *>(malloc(allocSize));
if (client->frameBuffer) {
ex->fbRealWidth = width;
ex->fbRealHeight = height;
memset(client->frameBuffer, 0, allocSize); //Clear any garbage
} else {
ex->fbRealWidth = 0;
ex->fbRealHeight = 0;
}
}
UNLOCK(ex->mutex);
if (client->frameBuffer == nullptr) {
rfbClientErr("CRITICAL: frameBuffer allocation failed\n");
return FALSE;
}
auto obj = getManagedClient(client);
auto env = context.getEnv();
auto cls = context.managedCls;
auto mid = env->GetMethodID(cls, "cbFramebufferSizeChanged", "(II)V");
env->CallVoidMethod(obj, mid, width, height);
return TRUE;
}
static void onGotCursorShape(rfbClient *client, int xHot, int yHot, int width, int height, int bytesPerPixel) {
auto ex = getClientExtension(client);
LOCK(ex->mutex);
//Steel buffers from rfbClient
updateCursor(ex->cursor, client->rcSource, client->rcMask, (uint16_t) width, (uint16_t) height,
(uint16_t) xHot, (uint16_t) yHot);
client->rcSource = NULL;
client->rcMask = NULL;
UNLOCK(ex->mutex);
//Fake framebuffer update to trigger rendering
onFinishedFrameBufferUpdate(client);
}
/**
* Hooks callbacks to rfbClient.
*/
static void setCallbacks(rfbClient *client) {
client->GetPassword = onGetPassword;
client->GetCredential = onGetCredential;
client->Bell = onBell;
client->GotXCutText = onGotXCutTextLatin1;
client->GotXCutTextUTF8 = onGotXCutTextUTF8;
client->HandleCursorPos = onHandleCursorPos;
client->FinishedFrameBufferUpdate = onFinishedFrameBufferUpdate;
client->MallocFrameBuffer = onMallocFrameBuffer;
client->GotCursorShape = onGotCursorShape;
}
/******************************************************************************
* Native method Implementation
*****************************************************************************/
extern "C"
JNIEXPORT jlong JNICALL
Java_com_gaurav_avnc_vnc_VncClient_nativeClientCreate(JNIEnv *env, jobject thiz) {
rfbClient *client = rfbGetClient(8, 3, 4);
if (client == nullptr)
return 0;
if (!assignClientExtension(client))
return 0;
setCallbacks(client);
client->canHandleNewFBSize = TRUE;
//Attach reference to managed object
auto obj = env->NewGlobalRef(thiz);
setManagedClient(client, obj);
return (jlong) client;
}
extern "C"
JNIEXPORT void JNICALL
Java_com_gaurav_avnc_vnc_VncClient_nativeConfigure(JNIEnv *env, jobject thiz, jlong client_ptr,
jint securityType, jboolean use_local_cursor, jint image_quality,
jboolean use_raw_encoding) {
auto client = (rfbClient *) client_ptr;
// 0 means all auth types
if (securityType != 0) {
uint32_t auth[1] = {static_cast<uint32_t>(securityType)};
SetClientAuthSchemes(client, auth, 1);
}
if (use_local_cursor) {
client->appData.useRemoteCursor = TRUE;
getClientExtension(client)->cursor = newCursor();
}
client->appData.qualityLevel = image_quality;
if (use_raw_encoding)
client->appData.encodingsString = "raw";
}
extern "C"
JNIEXPORT void JNICALL
Java_com_gaurav_avnc_vnc_VncClient_nativeSetDest(JNIEnv *env, jobject thiz, jlong client_ptr,
jstring host, jint port) {
auto client = (rfbClient *) client_ptr;
client->destHost = getNativeStrCopy(env, host);
client->destPort = port;
}
extern "C"
JNIEXPORT jboolean JNICALL
Java_com_gaurav_avnc_vnc_VncClient_nativeInit(JNIEnv *env, jobject thiz, jlong client_ptr,
jstring host, jint port) {
auto client = (rfbClient *) client_ptr;
client->serverHost = getNativeStrCopy(env, host);
client->serverPort = port < 100 ? port + 5900 : port;
if (rfbInitClient(client, nullptr, nullptr)) {
return JNI_TRUE;
}
return JNI_FALSE;
}
extern "C"
JNIEXPORT jboolean JNICALL
Java_com_gaurav_avnc_vnc_VncClient_nativeIsServerMacOS(JNIEnv *env, jobject thiz, jlong client_ptr) {
auto client = (rfbClient *) client_ptr;
return client->serverMajor == 3 && client->serverMinor == 889;
}
extern "C"
JNIEXPORT void JNICALL
Java_com_gaurav_avnc_vnc_VncClient_nativeCleanup(JNIEnv *env, jobject thiz,
jlong client_ptr) {
auto client = (rfbClient *) client_ptr;
if (client->frameBuffer) {
free(client->frameBuffer);
client->frameBuffer = nullptr;
}
auto managedClient = getManagedClient(client);
env->DeleteGlobalRef(managedClient);
freeClientExtension(client);
rfbClientCleanup(client);
}
extern "C"
JNIEXPORT jboolean JNICALL
Java_com_gaurav_avnc_vnc_VncClient_nativeProcessServerMessage(JNIEnv *env, jobject thiz,
jlong client_ptr,
jint u_sec_timeout) {
auto client = (rfbClient *) client_ptr;
auto waitResult = WaitForMessage(client, static_cast<unsigned int>(u_sec_timeout));
if (waitResult == 0) // Timeout
return JNI_TRUE;
if (waitResult > 0 && HandleRFBServerMessage(client))
return JNI_TRUE;
return JNI_FALSE;
}
extern "C"
JNIEXPORT jstring JNICALL
Java_com_gaurav_avnc_vnc_VncClient_nativeGetLastErrorStr(JNIEnv *env, jobject thiz) {
auto str = errnoToStr(errno);
return env->NewStringUTF(str);
}
extern "C"
JNIEXPORT jboolean JNICALL
Java_com_gaurav_avnc_vnc_VncClient_nativeSendKeyEvent(JNIEnv *env, jobject thiz, jlong client_ptr,
jint key_sym, jint xt_code, jboolean is_down) {
auto client = (rfbClient *) client_ptr;
rfbBool down = is_down ? TRUE : FALSE;
if (xt_code > 0 && SendExtendedKeyEvent(client, key_sym, xt_code, down))
return JNI_TRUE;
else
return SendKeyEvent(client, key_sym, down);
}
extern "C"
JNIEXPORT jboolean JNICALL
Java_com_gaurav_avnc_vnc_VncClient_nativeSendPointerEvent(JNIEnv *env, jobject thiz, jlong client_ptr, jint x, jint y,
jint mask) {
return (jboolean) SendPointerEvent((rfbClient *) client_ptr, x, y, mask);
}
extern "C"
JNIEXPORT jboolean JNICALL
Java_com_gaurav_avnc_vnc_VncClient_nativeSendCutText(JNIEnv *env, jobject thiz, jlong client_ptr, jbyteArray bytes,
jboolean is_utf8) {
auto client = (rfbClient *) client_ptr;
auto textBuffer = env->GetByteArrayElements(bytes, nullptr);
auto textLen = env->GetArrayLength(bytes);
auto textChars = reinterpret_cast<char *>(textBuffer);
rfbBool result = is_utf8
? SendClientCutTextUTF8(client, textChars, textLen)
: SendClientCutText(client, textChars, textLen);
env->ReleaseByteArrayElements(bytes, textBuffer, JNI_ABORT);
return (jboolean) result;
}
extern "C"
JNIEXPORT jboolean JNICALL
Java_com_gaurav_avnc_vnc_VncClient_nativeIsUTF8CutTextSupported(JNIEnv *env, jobject thiz, jlong client_ptr) {
return (jboolean) (((rfbClient *) client_ptr)->extendedClipboardServerCapabilities != 0);
}
extern "C"
JNIEXPORT jboolean JNICALL
Java_com_gaurav_avnc_vnc_VncClient_nativeSetDesktopSize(JNIEnv *env, jobject thiz, jlong client_ptr, jint width,
jint height) {
return (jboolean) SendExtDesktopSize((rfbClient *) client_ptr, width, height);
}
extern "C"
JNIEXPORT jboolean JNICALL
Java_com_gaurav_avnc_vnc_VncClient_nativeRefreshFrameBuffer(JNIEnv *env, jobject thiz, jlong clientPtr) {
auto client = (rfbClient *) clientPtr;
return (jboolean) SendFramebufferUpdateRequest(client, 0, 0, client->width, client->height, TRUE);
}
extern "C"
JNIEXPORT void JNICALL
Java_com_gaurav_avnc_vnc_VncClient_nativeSetAutomaticFramebufferUpdates(JNIEnv *env, jobject thiz, jlong client_ptr,
jboolean enabled) {
auto client = ((rfbClient *) client_ptr);
client->automaticUpdateRequests = enabled ? TRUE : FALSE;
if (enabled) SendIncrementalFramebufferUpdateRequest(client);
}
extern "C"
JNIEXPORT jstring JNICALL
Java_com_gaurav_avnc_vnc_VncClient_nativeGetDesktopName(JNIEnv *env, jobject thiz, jlong client_ptr) {
auto client = (rfbClient *) client_ptr;
return env->NewStringUTF(client->desktopName ? client->desktopName : "");
}
extern "C"
JNIEXPORT jint JNICALL
Java_com_gaurav_avnc_vnc_VncClient_nativeGetWidth(JNIEnv *env, jobject thiz, jlong client_ptr) {
return ((rfbClient *) client_ptr)->width;
}
extern "C"
JNIEXPORT jint JNICALL
Java_com_gaurav_avnc_vnc_VncClient_nativeGetHeight(JNIEnv *env, jobject thiz, jlong client_ptr) {
return ((rfbClient *) client_ptr)->height;
}
extern "C"
JNIEXPORT jboolean JNICALL
Java_com_gaurav_avnc_vnc_VncClient_nativeIsEncrypted(JNIEnv *env, jobject thiz, jlong client_ptr) {
return static_cast<jboolean>(((rfbClient *) client_ptr)->tlsSession ? JNI_TRUE : JNI_FALSE);
}
extern "C"
JNIEXPORT void JNICALL
Java_com_gaurav_avnc_vnc_VncClient_nativeUploadFrameTexture(JNIEnv *env, jobject thiz,
jlong client_ptr) {
auto client = (rfbClient *) client_ptr;
auto ex = getClientExtension(client);
LOCK(ex->mutex);
if (client->frameBuffer) {
glTexImage2D(GL_TEXTURE_2D,
0,
GL_RGBA,
ex->fbRealWidth,
ex->fbRealHeight,
0,
GL_RGBA,
GL_UNSIGNED_BYTE,
client->frameBuffer);
}
UNLOCK(ex->mutex);
}
extern "C"
JNIEXPORT void JNICALL
Java_com_gaurav_avnc_vnc_VncClient_nativeUploadCursor(JNIEnv *env, jobject thiz, jlong client_ptr, jint px, jint py) {
auto client = (rfbClient *) client_ptr;
auto ex = getClientExtension(client);
auto cursor = ex->cursor;
if (!cursor)
return;
//Current algo for cursor rendering is slightly weird. Main issue is that
//glTexSubImage2D() does not perform any composition with target texture.
//So, we have to manually blend transparent/invalid pixels of the cursor
//with corresponding pixels from framebuffer. scratchBuffer is used for
//this composition.
LOCK(ex->mutex);
//Effective cursor position in framebuffer
int32_t fbCursorX = px - cursor->xHot;
int32_t fbCursorY = py - cursor->yHot;
//Rectangular portion of the framebuffer to be updated.
//Cursor can overflow outside the framebuffer if moved near the edges,
//but glTexSubImage2D() doesn't allow values outside target texture,
//so we need to only update the intersection of framebuffer & cursor.
int32_t left = -1, top = -1, right = -1, bottom = -1;
auto fb = (uint32_t *) client->frameBuffer;
auto buffer = (uint32_t *) cursor->buffer;
auto scratch = (uint32_t *) cursor->scratchBuffer;
auto mask = cursor->mask;
//Scratch buffer index
int32_t z = 0;
for (int32_t y = 0; y < cursor->height; ++y) {
for (int32_t x = 0; x < cursor->width; ++x) {
//Corresponding pixel in framebuffer
auto fbX = fbCursorX + x;
auto fbY = fbCursorY + y;
if (fbX >= 0 && fbX < ex->fbRealWidth && fbY >= 0 && fbY < ex->fbRealHeight) {
auto isValidPixel = mask[y * cursor->width + x];
if (isValidPixel)
scratch[z++] = buffer[y * cursor->width + x];
else
scratch[z++] = fb[fbY * ex->fbRealWidth + fbX];
if (left == -1 && top == -1) {
left = fbX;
top = fbY;
}
right = fbX;
bottom = fbY;
}
}
}
if (left >= 0 && top >= 0)
glTexSubImage2D(GL_TEXTURE_2D,
0,
left,
top,
right - left + 1,
bottom - top + 1,
GL_RGBA,
GL_UNSIGNED_BYTE,
scratch);
UNLOCK(ex->mutex);
}

View File

@@ -1,6 +1,56 @@
package com.example.tiny_computer
import android.content.Intent
import androidx.annotation.NonNull
import androidx.annotation.Keep
import androidx.appcompat.app.AppCompatDelegate
import com.gaurav.avnc.util.AppPreferences
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity: FlutterActivity() {
@Keep
lateinit var prefs: AppPreferences
private fun updateNightMode(theme: String) {
val nightMode = when (theme) {
"light" -> AppCompatDelegate.MODE_NIGHT_NO
"dark" -> AppCompatDelegate.MODE_NIGHT_YES
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
AppCompatDelegate.setDefaultNightMode(nightMode)
}
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
prefs = AppPreferences(this)
prefs.ui.theme.observeForever { updateNightMode(it) }
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "avnc").setMethodCallHandler {
// 注册通道并设置方法调用处理器
call, result ->
// 判断方法名
when (call.method) {
"launchUsingUri" -> {
com.gaurav.avnc.ui.vnc.startVncActivity(this, com.gaurav.avnc.vnc.VncUri(call.argument("vncUri")!!))
result.success(0)
}
"launchPrefsPage" -> {
startActivity(Intent(this, com.gaurav.avnc.ui.prefs.PrefsActivity::class.java))
result.success(0)
}
"launchAboutPage" -> {
startActivity(Intent(this, com.gaurav.avnc.ui.about.AboutActivity::class.java))
result.success(0)
}
else -> {
// 不支持的方法名
result.notImplemented()
}
}
}
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2022 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.model
/**
* Generic wrapper for login information.
* This can be used to hold different [Type]s of credentials.
*/
data class LoginInfo(
var name: String = "", // Profile name
var host: String = "",
var username: String = "",
var password: String = "",
) {
enum class Type {
VNC_PASSWORD,
VNC_CREDENTIAL, // Username & Password
SSH_PASSWORD,
SSH_KEY_PASSWORD
}
}

View File

@@ -0,0 +1,224 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.model
import android.os.Parcelable
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import kotlin.reflect.KProperty
/**
* This class holds connection configuration of a remote VNC server.
*
* Some fields remain unused until that feature is implemented.
*/
@Parcelize
@Serializable
@Entity(tableName = "profiles")
data class ServerProfile(
@PrimaryKey(autoGenerate = true)
var ID: Long = 0,
/**
* Descriptive name of the server (e.g. 'Kitchen PC').
*/
var name: String = "",
/**
* Internet address of the server (without port number).
* This can be hostname or IP address.
*/
var host: String = "",
/**
* Port number of the server.
*/
var port: Int = 5900,
/**
* Username used for authentication.
*/
var username: String = "",
/**
* Password used for authentication.
* Note: Username & password may not be used for all security types.
*/
var password: String = "",
/**
* Security type to use when connecting to this server (e.g. VncAuth).
* 0 enables all supported types.
*/
var securityType: Int = 0,
/**
* Transport channel to be used for communicating with the server.
* e.g. TCP, SSH Tunnel
*/
var channelType: Int = CHANNEL_TCP,
/**
* Specifies the color level of received frames.
* This value determines the pixel-format used for framebuffer.
*/
var colorLevel: Int = 0,
/**
* Specifies the image quality of the frames.
* This mainly affects the compression level used by some encodings.
*/
var imageQuality: Int = 5,
/**
* Use raw encoding for framebuffer.
* This can improve performance when server is running on localhost.
*/
@ColumnInfo(defaultValue = "0")
var useRawEncoding: Boolean = false,
/**
* Initial zoom for the viewer.
* This will be used in portrait orientation, or when per-orientation zooming is disabled.
*/
@ColumnInfo(defaultValue = "1.0")
var zoom1: Float = 1f,
/**
* This will be used in landscape orientation if per-orientation zooming is enabled.
*/
@ColumnInfo(defaultValue = "1.0")
var zoom2: Float = 1f,
/**
* Specifies whether 'View Only' mode should be used.
* In this mode client does not send any input messages to remote server.
*/
var viewOnly: Boolean = false,
/**
* Whether the cursor should be drawn by client instead of server.
* It's value is currently ignored, and hardcoded to true.
* See [com.gaurav.avnc.viewmodel.VncViewModel.preConnect]
*/
var useLocalCursor: Boolean = true,
/**
* Server type hint received from user, e.g. tigervnc, tightvnc, vino
* Can be used in future to handle known server quirks.
*/
@ColumnInfo(defaultValue = "")
var serverTypeHint: String = "",
/**
* Composite field for various flags.
* This is accessed via individual members like [fLegacyKeySym].
*/
var flags: Long = FLAG_LEGACY_KEYSYM,
/**
* Preferred style to use for gesture handling.
* Possible values: auto, touchscreen, touchpad
*/
@ColumnInfo(defaultValue = "auto")
var gestureStyle: String = "auto",
/**
* Preferred screen orientation.
* Possible values: auto, portrait, landscape
*/
@ColumnInfo(defaultValue = "auto")
var screenOrientation: String = "auto",
/**
* Usage count tracks how many times user has connected to a server.
* Can be used to put frequent servers on top.
*/
var useCount: Int = 0,
/**
* Whether UltraVNC Repeater is used for connections.
* When repeater is used, [host] & [port] identifies the repeater.
*/
var useRepeater: Boolean = false,
/**
* When using a repeater, this value identifies the VNC server.
* Valid IDs: [0, 999999999].
*/
var idOnRepeater: Int = 0,
/**
* Resize remote desktop to match with local window size.
*/
@ColumnInfo(defaultValue = "0")
var resizeRemoteDesktop: Boolean = false,
/**
* These values are used for SSH Tunnel
*/
var sshHost: String = "",
var sshPort: Int = 22,
var sshUsername: String = "",
var sshAuthType: Int = SSH_AUTH_KEY,
var sshPassword: String = "",
var sshPrivateKey: String = "",
var sshPrivateKeyPassword: String = ""
) : Parcelable {
companion object {
// Channel types (from RFC 7869)
const val CHANNEL_TCP = 1
const val CHANNEL_SSH_TUNNEL = 24
// SSH auth types
const val SSH_AUTH_KEY = 1
const val SSH_AUTH_PASSWORD = 2
// Flag masks
private const val FLAG_LEGACY_KEYSYM = 0x01L
private const val FLAG_BUTTON_UP_DELAY = 0x02L
private const val FLAG_ZOOM_LOCKED = 0x04L
}
/**
* Delegated property builder for [flags] field.
*/
private class Flag(val flag: Long) {
operator fun getValue(p: ServerProfile, kp: KProperty<*>) = (p.flags and flag) != 0L
operator fun setValue(p: ServerProfile, kp: KProperty<*>, value: Boolean) {
p.flags = if (value) p.flags or flag else p.flags and flag.inv()
}
}
/**
* Flag to emit legacy X KeySym events in certain cases.
*/
@IgnoredOnParcel
var fLegacyKeySym by Flag(FLAG_LEGACY_KEYSYM)
/**
* Flag to insert artificial delay before UP event of left-click.
*/
@IgnoredOnParcel
var fButtonUpDelay by Flag(FLAG_BUTTON_UP_DELAY)
/**
* If zoom is locked, user requests to change [zoom1] & [zoom2]
* should be ignored.
*/
@IgnoredOnParcel
var fZoomLocked by Flag(FLAG_ZOOM_LOCKED)
}

View File

@@ -0,0 +1,67 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.model.db
import android.content.Context
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RenameColumn
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.AutoMigrationSpec
import androidx.sqlite.db.SupportSQLiteDatabase
import com.gaurav.avnc.model.ServerProfile
@Database(entities = [ServerProfile::class], version = MainDb.VERSION, exportSchema = true, autoMigrations = [
AutoMigration(from = 1, to = 2, spec = MainDb.MigrationSpec1to2::class), // in v2.0.0
AutoMigration(from = 2, to = 3, spec = MainDb.MigrationSpec2to3::class), // in v2.1.0
AutoMigration(from = 3, to = 4), // in v2.2.2
AutoMigration(from = 4, to = 5, spec = MainDb.MigrationSpec4to5::class), // in v2.3.0
])
abstract class MainDb : RoomDatabase() {
abstract val serverProfileDao: ServerProfileDao
companion object {
/**
* Current database version
*/
const val VERSION = 5
private var instance: MainDb? = null
/**
* Returns database singleton.
* If database is not yet created then it will be created on first call.
*/
@Synchronized
fun getInstance(context: Context): MainDb {
if (instance == null) {
instance = Room.databaseBuilder(context, MainDb::class.java, "main").build()
}
return instance!!
}
}
/******************************** Migrations ***********************************/
// Added in v2.0.0
class MigrationSpec1to2 : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
db.execSQL("UPDATE profiles SET imageQuality = 5")
}
}
// Added in v2.1.0
@RenameColumn(tableName = "profiles", fromColumnName = "keyCompatMode", toColumnName = "compatFlags")
class MigrationSpec2to3 : AutoMigrationSpec
// Added in v2.3.0
@RenameColumn(tableName = "profiles", fromColumnName = "compatFlags", toColumnName = "flags")
@RenameColumn(tableName = "profiles", fromColumnName = "shortcutRank", toColumnName = "useCount")
class MigrationSpec4to5 : AutoMigrationSpec
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.model.db
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import com.gaurav.avnc.model.ServerProfile
@Dao
interface ServerProfileDao {
@Query("SELECT * FROM profiles")
fun getLiveList(): LiveData<List<ServerProfile>>
@Query("SELECT * FROM profiles ORDER BY name COLLATE NOCASE")
fun getSortedLiveList(): LiveData<List<ServerProfile>>
//Synchronous version
@Query("SELECT * FROM profiles")
suspend fun getList(): List<ServerProfile>
@Query("SELECT * FROM profiles WHERE ID = :id")
suspend fun getByID(id: Long): ServerProfile?
@Query("SELECT * FROM profiles WHERE name = :name")
suspend fun getByName(name: String): List<ServerProfile>
@Query("SELECT * FROM profiles WHERE name LIKE :query OR host LIKE :query OR sshHost LIKE :query ORDER BY useCount DESC")
fun search(query: String): LiveData<List<ServerProfile>>
@Insert
suspend fun insert(profile: ServerProfile): Long
@Insert
suspend fun insert(profiles: List<ServerProfile>)
@Update
suspend fun update(profile: ServerProfile)
@Delete
suspend fun delete(profile: ServerProfile)
@Query("DELETE FROM profiles")
suspend fun deleteAll()
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright (c) 2021 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.about
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.example.tiny_computer.R
/**
* Activity for app details.
*/
class AboutActivity : AppCompatActivity() {
companion object {
const val GIT_REPO_URL = "https://github.com/gujjwal00/avnc"
const val BUG_REPORT_URL = "https://github.com/gujjwal00/avnc/issues/new"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_about)
setSupportActionBar(findViewById(R.id.toolbar))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (savedInstanceState == null) {
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_host, AboutFragment())
.commit()
}
}
override fun onSupportNavigateUp(): Boolean {
onBackPressedDispatcher.onBackPressed()
return true
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright (c) 2021 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.about
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.example.tiny_computer.BuildConfig
import com.example.tiny_computer.R
import com.example.tiny_computer.databinding.FragmentAboutBinding
class AboutFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val binding = FragmentAboutBinding.inflate(inflater, container, false)
binding.apply {
repoBtn.setOnClickListener { openUrl(AboutActivity.GIT_REPO_URL) }
libraryBtn.setOnClickListener { showFragment(LibrariesFragment()) }
licenceBtn.setOnClickListener { showFragment(LicenseFragment()) }
}
return binding.root
}
override fun onResume() {
super.onResume()
requireActivity().setTitle(R.string.title_about)
}
private fun openUrl(url: String) {
if (url.isNotEmpty()) {
runCatching { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
}
}
private fun showFragment(fragment: Fragment) {
parentFragmentManager.beginTransaction()
.replace(R.id.fragment_host, fragment)
.addToBackStack(null)
.commit()
}
}

View File

@@ -0,0 +1,86 @@
/*
* Copyright (c) 2021 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.about
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment
import com.example.tiny_computer.R
import com.example.tiny_computer.databinding.FragmentLibrariesBinding
class LibrariesFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val binding = FragmentLibrariesBinding.inflate(inflater, container, false)
for (library in libraries) {
val textView = inflater.inflate(android.R.layout.simple_list_item_1, binding.libraryList, false) as TextView
textView.text = library.name
textView.setOnClickListener { openUrl(library.homepage) }
//Apply ripple background
with(TypedValue()) {
requireContext().theme.resolveAttribute(android.R.attr.selectableItemBackground, this, true)
textView.setBackgroundResource(resourceId)
}
binding.libraryList.addView(textView)
}
return binding.root
}
override fun onResume() {
super.onResume()
requireActivity().setTitle(R.string.title_open_source_libraries)
}
private fun openUrl(url: String) {
if (url.isNotEmpty()) {
runCatching { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
}
}
private data class Library(
val name: String,
val homepage: String
)
private val libraries = listOf(
Library("LibVNCClient",
"https://github.com/LibVNC/libvncserver"),
Library("Libjpeg-turbo",
"https://github.com/libjpeg-turbo/libjpeg-turbo"),
Library("wolfSSL",
"https://github.com/wolfSSL/wolfssl"),
Library("ConnectBot's SSH library",
"https://github.com/connectbot/sshlib/"),
Library("Android Jetpack (Androidx)",
"https://github.com/libjpeg-turbo/libjpeg-turbo"),
Library("Material Components for Android",
"https://github.com/material-components/material-components-android"),
Library("Material Icons",
"https://fonts.google.com/icons"),
)
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright (c) 2021 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.about
import android.content.res.AssetManager
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.example.tiny_computer.R
import com.example.tiny_computer.databinding.FragmentLicenseBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class LicenseFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val binding = FragmentLicenseBinding.inflate(inflater, container, false)
loadLicenses(binding.licenseText, resources.assets)
return binding.root
}
override fun onResume() {
super.onResume()
requireActivity().setTitle(R.string.title_license)
}
// These are relative to assets directory
private val licenseFiles = listOf(
"license/GPL-3.0.txt",
"license/Apache-2.0.txt",
"license/BSD-libjpeg-turbo.txt",
"license/sshlib.txt",
"license/X11.txt",
)
private fun loadLicenses(tv: TextView, assets: AssetManager) {
lifecycleScope.launch(Dispatchers.IO) {
var combinedText = ""
licenseFiles.forEach {
val reader = assets.open(it).reader()
val text = reader.readText()
reader.close()
combinedText += text
combinedText += "\n\n------------------------------------------------------------------------------\n\n"
}
withContext(Dispatchers.Main) {
tv.text = combinedText
}
}
}
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright (c) 2022 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.prefs
import android.content.Context
import android.util.AttributeSet
import android.widget.ImageButton
import androidx.fragment.app.FragmentActivity
import androidx.preference.ListPreference
import androidx.preference.PreferenceViewHolder
import com.example.tiny_computer.R
import com.gaurav.avnc.util.MsgDialog
/**
* List preference with some extra features.
*/
class ListPreferenceEx(context: Context, attrs: AttributeSet) : ListPreference(context, attrs) {
/**
* Summary used when preference is disabled.
*/
var disabledStateSummary: CharSequence? = null
override fun getSummary(): CharSequence? {
if (!isEnabled && disabledStateSummary != null)
return disabledStateSummary
return super.getSummary()
}
/**
* Message shown in a dialog, when help button of the preference is clicked.
* This will only work if [R.layout.help_btn] is used as widget layout.
*/
var helpMessage: CharSequence? = null
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)
(holder.findViewById(R.id.help_btn) as? ImageButton)?.setOnClickListener { showHelp() }
}
private fun showHelp() {
helpMessage?.let { helpMessage ->
(context as? FragmentActivity)?.let { fragmentActivity ->
MsgDialog.show(fragmentActivity.supportFragmentManager,
fragmentActivity.getString(R.string.desc_help_btn),
helpMessage)
}
}
}
}

View File

@@ -0,0 +1,146 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.prefs
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.annotation.Keep
import androidx.appcompat.app.AppCompatActivity
import androidx.core.text.HtmlCompat
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreference
import com.example.tiny_computer.R
import com.gaurav.avnc.util.DeviceAuthPrompt
import com.google.android.material.appbar.MaterialToolbar
class PrefsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
override fun onCreate(savedInstanceState: Bundle?) {
DeviceAuthPrompt.applyFingerprintDialogFix(supportFragmentManager)
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
if (savedInstanceState == null) {
supportFragmentManager
.beginTransaction()
.replace(R.id.fragment_host, Main())
.commit()
}
setSupportActionBar(findViewById<MaterialToolbar>(R.id.toolbar))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
override fun onSupportNavigateUp(): Boolean {
onBackPressedDispatcher.onBackPressed()
return true
}
/**
* Starts new fragment corresponding to given [pref].
*/
override fun onPreferenceStartFragment(caller: PreferenceFragmentCompat, pref: Preference): Boolean {
val fragment = supportFragmentManager.fragmentFactory.instantiate(classLoader, pref.fragment!!)
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_host, fragment)
.addToBackStack(null)
.commit()
return true
}
/**************************************************************************
* Preference Fragments
**************************************************************************/
abstract class PrefFragment(private val prefResource: Int) : PreferenceFragmentCompat() {
override fun onResume() {
super.onResume()
activity?.title = preferenceScreen.title
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(prefResource, rootKey)
}
}
@Keep class Main : PrefFragment(R.xml.pref_main)
@Keep class Appearance : PrefFragment(R.xml.pref_appearance)
@Keep class Viewer : PrefFragment(R.xml.pref_viewer) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// If system does not support PiP, disable its preference
val hasPiPSupport = Build.VERSION.SDK_INT >= 26 &&
requireActivity().packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
if (!hasPiPSupport) {
findPreference<SwitchPreference>("pip_enabled")!!.apply {
isEnabled = false
summary = getString(R.string.msg_pip_not_supported)
}
}
}
}
@Keep class Input : PrefFragment(R.xml.pref_input) {
private var invertScrollingUpdater: OnSharedPreferenceChangeListener? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val canChangePtrIcon = Build.VERSION.SDK_INT >= 24
if (!canChangePtrIcon) {
findPreference<SwitchPreference>("hide_local_cursor")!!.apply {
isEnabled = false
summary = getString(R.string.msg_ptr_hiding_not_supported)
}
}
val style = findPreference<ListPreferenceEx>("gesture_style")!!
val swipe1 = findPreference<ListPreferenceEx>("gesture_swipe1")!!
val longPressSwipe = findPreference<ListPreferenceEx>("gesture_long_press_swipe")!!
swipe1.disabledStateSummary = getString(R.string.pref_gesture_action_move_pointer)
longPressSwipe.helpMessage = getText(R.string.msg_drag_gesture_help)
swipe1.isEnabled = style.value != "touchpad"
style.setOnPreferenceChangeListener { _, value -> swipe1.isEnabled = value != "touchpad"; true }
val styleHelp = "<b>${getString(R.string.pref_gesture_style_touchscreen)}</b><br/>" +
getString(R.string.pref_gesture_style_touchscreen_summary) + "<br/><br/>" +
"<b>${getString(R.string.pref_gesture_style_touchpad)}</b><br/>" +
getString(R.string.pref_gesture_style_touchpad_summary)
style.helpMessage = HtmlCompat.fromHtml(styleHelp, 0)
// To reduce clutter & avoid 'UI overload', pref to invert vertical scrolling is
// only visible when 'Scroll remote content' option is used.
invertScrollingUpdater = OnSharedPreferenceChangeListener { prefs, _ ->
findPreference<SwitchPreference>("invert_vertical_scrolling")!!.apply {
isVisible = prefs.all.values.contains("remote-scroll")
}
}
invertScrollingUpdater?.onSharedPreferenceChanged(swipe1.sharedPreferences, null) //Initial update
swipe1.sharedPreferences?.registerOnSharedPreferenceChangeListener(invertScrollingUpdater)
}
}
@Keep class Server : PrefFragment(R.xml.pref_server)
}

View File

@@ -0,0 +1,310 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.vnc
import android.graphics.PointF
import com.gaurav.avnc.viewmodel.VncViewModel
import com.gaurav.avnc.vnc.Messenger
import com.gaurav.avnc.vnc.PointerButton
import kotlin.math.abs
/**
* We allow users to customize the actions for different events.
* This class reads those preferences and invokes proper handlers.
*
* Input handling overview:
*
*- +----------------+ +--------------------+ +--------------+
*- | Touch events | | Key events | | Virtual keys |
*- +----------------+ +--------------------+ +--------------+
*- | | |
*- v v |
*- +----------------+ +--------------------+ |
*- | [TouchHandler] | | [KeyHandler] |<------------+
*- +----------------+ +--------------------+
*- | |
*- | v
*- | +--------------------+
*- +------------->+ [Dispatcher] +
*- +--------------------+
*- |
*- |
*- +--------------------+---------------------+
*- | | |
*- v v v
*- +---------------+ +----------------+ +---------------+
*- | [Messenger] | | [VncViewModel] | | [VncActivity] |
*- +---------------+ +----------------+ +---------------+
*-
*-
*
* 1. First we identify which gesture/key was input by the user.
* 2. Then we select an action based on user preferences. This is done here in [Dispatcher].
* 3. Then that action is executed. Some actions change local app state (e.g. zoom in/out),
* while others send events to remote server (e.g. mouse click).
*/
class Dispatcher(private val activity: VncActivity) {
private val viewModel = activity.viewModel
private val profile = viewModel.profile
private val messenger = viewModel.messenger
private val gesturePref = viewModel.pref.input.gesture
/**************************************************************************
* Action configuration
**************************************************************************/
private val directMode = DirectMode()
private val relativeMode = RelativeMode()
private var config = Config()
private inner class Config {
val gestureStyle = if (profile.gestureStyle == "auto") gesturePref.style else profile.gestureStyle
val defaultMode = if (gestureStyle == "touchscreen") directMode else relativeMode
val tap1Action = selectPointAction(gesturePref.tap1)
val tap2Action = selectPointAction(gesturePref.tap2)
val doubleTapAction = selectPointAction(gesturePref.doubleTap)
val longPressAction = selectPointAction(gesturePref.longPress)
val swipe1Pref = if (gestureStyle == "touchpad") "move-pointer" else gesturePref.swipe1
val swipe1Action = selectSwipeAction(swipe1Pref)
val swipe2Action = selectSwipeAction(gesturePref.swipe2)
val doubleTapSwipeAction = selectSwipeAction(gesturePref.doubleTapSwipe)
val longPressSwipeAction = selectSwipeAction(gesturePref.longPressSwipe)
val flingAction = selectFlingAction()
val mouseBackAction = selectPointAction(viewModel.pref.input.mouseBack)
private fun selectPointAction(actionName: String): (PointF) -> Unit {
return when (actionName) {
"left-click" -> { p -> defaultMode.doClick(PointerButton.Left, p) }
"double-click" -> { p -> defaultMode.doDoubleClick(PointerButton.Left, p) }
"middle-click" -> { p -> defaultMode.doClick(PointerButton.Middle, p) }
"right-click" -> { p -> defaultMode.doClick(PointerButton.Right, p) }
"open-keyboard" -> { _ -> doOpenKeyboard() }
else -> { _ -> } //Nothing
}
}
/**
* Returns a lambda which accepts four arguments:
*
* sp: Start point of the gesture
* cp: Current point of the gesture
* dx: Change along x-axis since last event
* dy: Change along y-axis since last event
*/
private fun selectSwipeAction(actionName: String): (PointF, PointF, Float, Float) -> Unit {
return when (actionName) {
"pan" -> { _, _, dx, dy -> doPan(dx, dy) }
"move-pointer" -> { _, cp, dx, dy -> defaultMode.doMovePointer(cp, dx, dy) }
"remote-scroll" -> { sp, _, dx, dy -> defaultMode.doRemoteScroll(sp, dx, dy) }
"remote-drag" -> { _, cp, dx, dy -> defaultMode.doRemoteDrag(PointerButton.Left, cp, dx, dy) }
"remote-drag-middle" -> { _, cp, dx, dy -> defaultMode.doRemoteDrag(PointerButton.Middle, cp, dx, dy) }
else -> { _, _, _, _ -> } //Nothing
}
}
/**
* Fling is only used for smooth-scrolling the frame.
* So it only makes sense when 1-finger-swipe is set to "pan".
*/
private fun selectFlingAction(): (Float, Float) -> Unit {
return if (swipe1Pref == "pan") { vx, vy -> startFrameFling(vx, vy) }
else { _, _ -> }
}
}
/**************************************************************************
* Event receivers
**************************************************************************/
fun onGestureStart() = config.defaultMode.onGestureStart()
fun onGestureStop(p: PointF) = config.defaultMode.onGestureStop(p)
fun onTap1(p: PointF) = config.tap1Action(p)
fun onTap2(p: PointF) = config.tap2Action(p)
fun onDoubleTap(p: PointF) = config.doubleTapAction(p)
fun onLongPress(p: PointF) = config.longPressAction(p)
fun onSwipe1(sp: PointF, cp: PointF, dx: Float, dy: Float) = config.swipe1Action(sp, cp, dx, dy)
fun onSwipe2(sp: PointF, cp: PointF, dx: Float, dy: Float) = config.swipe2Action(sp, cp, dx, dy)
fun onDoubleTapSwipe(sp: PointF, cp: PointF, dx: Float, dy: Float) = config.doubleTapSwipeAction(sp, cp, dx, dy)
fun onLongPressSwipe(sp: PointF, cp: PointF, dx: Float, dy: Float) = config.longPressSwipeAction(sp, cp, dx, dy)
fun onScale(scaleFactor: Float, fx: Float, fy: Float) = doScale(scaleFactor, fx, fy)
fun onFling(vx: Float, vy: Float) = config.flingAction(vx, vy)
fun onMouseButtonDown(button: PointerButton, p: PointF) = directMode.doButtonDown(button, p)
fun onMouseButtonUp(button: PointerButton, p: PointF) = directMode.doButtonUp(button, p)
fun onMouseMove(p: PointF) = directMode.doMovePointer(p, 0f, 0f)
fun onMouseScroll(p: PointF, hs: Float, vs: Float) = directMode.doRemoteScrollFromMouse(p, hs, vs)
fun onMouseBack(p: PointF) = config.mouseBackAction(p)
fun onStylusTap(p: PointF) = directMode.doClick(PointerButton.Left, p)
fun onStylusDoubleTap(p: PointF) = directMode.doDoubleClick(PointerButton.Left, p)
fun onStylusLongPress(p: PointF) = directMode.doClick(PointerButton.Right, p)
fun onStylusScroll(p: PointF) = directMode.doButtonDown(PointerButton.Left, p)
fun onXKey(keySym: Int, xtCode: Int, isDown: Boolean) = messenger.sendKey(keySym, xtCode, isDown)
fun onGestureStyleChanged() {
config = Config()
}
/**************************************************************************
* Available actions
**************************************************************************/
private fun doOpenKeyboard() = activity.showKeyboard()
private fun doScale(scaleFactor: Float, fx: Float, fy: Float) = viewModel.updateZoom(scaleFactor, fx, fy)
private fun doPan(dx: Float, dy: Float) = viewModel.panFrame(dx, dy)
private fun startFrameFling(vx: Float, vy: Float) = viewModel.frameScroller.fling(vx, vy)
private fun stopFrameFling() = viewModel.frameScroller.stop()
/**
* Most actions have the same implementation in both modes, only difference being
* the point where event is sent. [transformPoint] is used for this mode-specific
* point selection.
*/
private abstract inner class AbstractMode {
//Used for remote scrolling
private var accumulatedDx = 0F
private var accumulatedDy = 0F
private val deltaPerScroll = 20F //For how much dx/dy, one scroll event will be sent
private val yScrollDirection = (if (gesturePref.invertVerticalScrolling) -1 else 1)
abstract fun transformPoint(p: PointF): PointF?
abstract fun doMovePointer(p: PointF, dx: Float, dy: Float)
abstract fun doRemoteDrag(button: PointerButton, p: PointF, dx: Float, dy: Float)
open fun onGestureStart() = stopFrameFling()
open fun onGestureStop(p: PointF) = doButtonRelease(p)
fun doButtonDown(button: PointerButton, p: PointF) {
transformPoint(p)?.let { messenger.sendPointerButtonDown(button, it) }
}
fun doButtonUp(button: PointerButton, p: PointF) {
transformPoint(p)?.let { messenger.sendPointerButtonUp(button, it) }
}
fun doButtonRelease(p: PointF) {
transformPoint(p)?.let { messenger.sendPointerButtonRelease(it) }
}
fun doClick(button: PointerButton, p: PointF) {
doButtonDown(button, p)
// Some (obscure) apps seems to ignore click event if button-up is received too early
if (button == PointerButton.Left && profile.fButtonUpDelay)
messenger.insertButtonUpDelay()
doButtonUp(button, p)
}
fun doDoubleClick(button: PointerButton, p: PointF) {
doClick(button, p)
doClick(button, p)
}
fun doRemoteScroll(focus: PointF, dx: Float, dy: Float) {
accumulatedDx += dx
accumulatedDy += dy * yScrollDirection
//Drain horizontal change
while (abs(accumulatedDx) >= deltaPerScroll) {
if (accumulatedDx > 0) {
doClick(PointerButton.WheelLeft, focus)
accumulatedDx -= deltaPerScroll
} else {
doClick(PointerButton.WheelRight, focus)
accumulatedDx += deltaPerScroll
}
}
//Drain vertical change
while (abs(accumulatedDy) >= deltaPerScroll) {
if (accumulatedDy > 0) {
doClick(PointerButton.WheelUp, focus)
accumulatedDy -= deltaPerScroll
} else {
doClick(PointerButton.WheelDown, focus)
accumulatedDy += deltaPerScroll
}
}
}
/**
* [hs] Movement of horizontal scroll wheel
* [vs] Movement of vertical scroll wheel
*/
fun doRemoteScrollFromMouse(p: PointF, hs: Float, vs: Float) {
doRemoteScroll(p, hs * deltaPerScroll, vs * deltaPerScroll)
}
}
/**
* Actions happen at touch-point, which is simply transformed from
* viewport coordinates into corresponding position in framebuffer.
*/
private inner class DirectMode : AbstractMode() {
override fun transformPoint(p: PointF) = viewModel.frameState.toFb(p)
override fun doMovePointer(p: PointF, dx: Float, dy: Float) = doButtonDown(PointerButton.None, p)
override fun doRemoteDrag(button: PointerButton, p: PointF, dx: Float, dy: Float) = doButtonDown(button, p)
}
/**
* Actions happen at [pointerPosition], which is updated by [doMovePointer].
*/
private inner class RelativeMode : AbstractMode() {
private val pointerPosition = PointF(0f, 0f)
override fun onGestureStart() {
super.onGestureStart()
//Initialize with the latest pointer position
pointerPosition.apply {
x = viewModel.client.pointerX.toFloat()
y = viewModel.client.pointerY.toFloat()
}
viewModel.client.ignorePointerMovesByServer = true
}
override fun onGestureStop(p: PointF) {
super.onGestureStop(p)
viewModel.client.ignorePointerMovesByServer = false
}
override fun transformPoint(p: PointF) = pointerPosition
override fun doMovePointer(p: PointF, dx: Float, dy: Float) {
val xLimit = viewModel.frameState.fbWidth - 1
val yLimit = viewModel.frameState.fbHeight - 1
if (xLimit < 0 || yLimit < 0)
return
pointerPosition.apply {
offset(dx, dy)
x = x.coerceIn(0f, xLimit)
y = y.coerceIn(0f, yLimit)
}
doButtonDown(PointerButton.None, pointerPosition)
//Try to keep the pointer centered on screen
val vp = viewModel.frameState.toVP(pointerPosition)
val centerDiffX = viewModel.frameState.safeArea.centerX() - vp.x
val centerDiffY = viewModel.frameState.safeArea.centerY() - vp.y
viewModel.panFrame(centerDiffX, centerDiffY)
}
override fun doRemoteDrag(button: PointerButton, p: PointF, dx: Float, dy: Float) {
doButtonDown(button, p)
doMovePointer(p, dx, dy)
}
}
}

View File

@@ -0,0 +1,86 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.vnc
import androidx.dynamicanimation.animation.FlingAnimation
import androidx.dynamicanimation.animation.FloatValueHolder
import com.gaurav.avnc.viewmodel.VncViewModel
/**
* Implements fling animation for the frame.
*/
class FrameScroller(val viewModel: VncViewModel) {
private val fs = viewModel.frameState
private val xAnimator = FlingAnimation(FloatValueHolder())
private val yAnimator = FlingAnimation(FloatValueHolder())
init {
xAnimator.addUpdateListener { _, x, _ ->
viewModel.moveFrameTo(x, fs.frameY)
}
yAnimator.addUpdateListener { _, y, _ ->
viewModel.moveFrameTo(fs.frameX, y)
}
}
/**
* Stop current animation
*/
fun stop() {
xAnimator.cancel()
yAnimator.cancel()
}
/**
* Starts fling animation according to given velocities
*/
fun fling(vx: Float, vy: Float) {
stop()
val x = fs.frameX
val y = fs.frameY
val safe = fs.safeArea
/**
* Fling limits.
*
* There are two cases:
*
* 1) x >= safeLeft : It means frame is completely visible and centered horizontally.
* In this case both minX,maxX = x (ie. no movement possible).
* 2) x < safeLeft : Here, 'scaled frame width' > 'safe width'. In this case
* minX is negative and maxX = safeLeft.
*
* minY,maxY are calculated similarly.
*/
val minX = if (x >= safe.left) x else (safe.width()) - (fs.fbWidth * fs.scale)
val maxX = if (x >= safe.left) x else safe.left
val minY = if (y >= safe.top) y else (safe.height()) - (fs.fbHeight * fs.scale)
val maxY = if (y >= safe.top) y else safe.top
xAnimator.apply {
setStartValue(x)
setStartVelocity(vx)
setMinValue(minX)
setMaxValue(maxX)
start()
}
yAnimator.apply {
setStartValue(y)
setStartVelocity(vy)
setMinValue(minY)
setMaxValue(maxY)
start()
}
}
}

View File

@@ -0,0 +1,291 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.vnc
import android.graphics.PointF
import android.graphics.RectF
import com.gaurav.avnc.ui.vnc.FrameState.Snapshot
import kotlin.math.max
import kotlin.math.min
/**
* Represents current 'view' state of the frame.
*
* Terminology
* ===========
*
* Framebuffer: This is the buffer holding pixel data. It resides in native memory.
*
* Frame: This is the actual content rendered on screen. It can be thought of as
* 'rendered framebuffer'. Its size changes based on current [scale] and its position
* is stored in [frameX] & [frameY].
*
* Window: Top-level window of the application/activity.
*
* Viewport: This is the area of window where frame is rendered. It is denoted by [FrameView].
*
* Safe area: Area inside viewport which is safe for interaction with frame, maintained in [safeArea].
*
* Window denotes 'total' area available to our activity, viewport denotes 'visible to user'
* area, and safe area denotes 'able to click' area. Most of the time all three will be equal,
* but viewport can be smaller than window (e.g. if soft keyboard is visible), and safe area
* can be smaller than viewport (e.g. due to display cutout).
*
* +---------------------------+ - -
* | \Cutout/ | | | Viewport
* | | | | -
* | | | | | SafeArea
* +---------------------------+ | - -
* | Soft Keyboard | | Window
* +---------------------------+ -
*
* Differentiating between these allows us to handle layout changes more easily and cleanly.
* We use window size to calculate base scale because we don't want to change scale when
* keyboard is shown/hidden. Viewport size is used for rendering the frame, fully immersive.
* Safe area is used to coerce frame position so that user can pan every part of frame inside
* safe area to interact with it.
*
* See [LayoutManager] for more information about these values.
*
* State & Coordinates
* ===================
*
* Both frame & viewport are in same coordinate space. Viewport is assumed to be fixed
* in its place with [0,0] represented by top-left corner. Only frame is scaled/moved.
* To make sure frame does not move off-screen, after each state change, values are
* coerced within range by [coerceValues].
*
* Rendering is done by [com.gaurav.avnc.ui.vnc.gl.Renderer] based on these values.
*
*
* Scaling
* =======
*
* Scaling controls the 'size' of rendered frame. It involves multiple factors, like window size,
* framebuffer size, user choice etc. To achieve best experience, we split scaling in two parts.
* One automatic, and one user controlled.
*
* 1. Base Scale [baseScale] :
* Motivation behind base scale is to start with the most optimal frame size. It is automatically
* calculated (and updated) using window size & framebuffer size. When orientation of local
* device is such that longer edge of the window is aligned with longer edge of the frame,
* base scale will satisfy following constraints (see [calculateBaseScale]):
*
* - Frame is completely visible
* - Frame's aspect ratio is maintained
* - Maximum window space is utilized
*
* 2. Zoom Scale [zoomScale] :
* This is the user controlled part. It is updated only in response to pinch gestures.
* To allow different zoom in different orientations, two separate zoom scales are maintained
* in [zoomScale1] & [zoomScale2]. Based on user preference and current orientation, [zoomScale]
* will be delegated to one of these.
*
* Conceptually, zoom scale works 'on top of' the base scale.
* Effective scale [scale] is calculated as the product of these two parts, so:
* FrameSize = (FramebufferSize * BaseScale) * ZoomScale
*
*
* Thread safety
* =============
*
* Frame state is accessed from two threads: Its properties are updated in UI thread
* and consumed by the renderer thread. There is a chance that Renderer thread may see
* half-updated state (e.g. [frameX] is changed inside [pan] but [coerceValues] is not yet called).
* This half-updated state can cause flickering issues.
*
* To avoid this we use [Snapshot]. All updates to frame state are guarded by [lock].
* Render thread uses [getSnapshot] to retrieve a consistent state to render the frame.
*/
class FrameState(
private val minZoomScale: Float = 0.5F,
private val maxZoomScale: Float = 5F,
private val usePerOrientationZoom: Boolean = false
) {
//Frame position, relative to top-left corner (0,0)
var frameX = 0F; private set
var frameY = 0F; private set
//VNC framebuffer size
var fbWidth = 0F; private set
var fbHeight = 0F; private set
//Viewport/FrameView size
var vpWidth = 0F; private set
var vpHeight = 0F; private set
//Size of activity window
var windowWidth = 0F; private set
var windowHeight = 0F; private set
var safeArea = RectF(); private set
//Scaling
var zoomScale1 = 1F; private set
var zoomScale2 = 1F; private set
private val useZoomScale1 get() = (!usePerOrientationZoom || windowHeight > windowWidth)
var baseScale = 1F; private set
var zoomScale
get() = if (useZoomScale1) zoomScale1 else zoomScale2
private set(value) = if (useZoomScale1) zoomScale1 = value else zoomScale2 = value
val scale get() = baseScale * zoomScale
/**
* Immutable wrapper for frame state
*/
data class Snapshot(
val frameX: Float,
val frameY: Float,
val fbWidth: Float,
val fbHeight: Float,
val vpWidth: Float,
val vpHeight: Float,
val scale: Float
)
private val lock = Any()
private inline fun <T> withLock(block: () -> T) = synchronized(lock) { block() }
fun setFramebufferSize(w: Float, h: Float) = withLock {
fbWidth = w
fbHeight = h
calculateBaseScale()
coerceValues()
}
fun setViewportSize(w: Float, h: Float) = withLock {
vpWidth = w
vpHeight = h
coerceValues()
}
fun setWindowSize(w: Float, h: Float) = withLock {
windowWidth = w
windowHeight = h
calculateBaseScale()
coerceValues()
}
fun setSafeArea(rect: RectF) = withLock {
safeArea = RectF(rect)
coerceValues()
}
/**
* Adjust zoom scale according to give [scaleFactor].
*
* Returns 'how much' scale factor is actually applied (after coercing).
*/
fun updateZoom(scaleFactor: Float): Float = withLock {
val oldScale = zoomScale
zoomScale *= scaleFactor
coerceValues()
return zoomScale / oldScale //Applied scale factor
}
fun setZoom(zoom1: Float, zoom2: Float) = withLock {
zoomScale1 = zoom1
zoomScale2 = zoom2
coerceValues()
}
/**
* Shift frame by given delta.
*/
fun pan(deltaX: Float, deltaY: Float) = withLock {
frameX += deltaX
frameY += deltaY
coerceValues()
}
/**
* Move frame to given position.
*/
fun moveTo(x: Float, y: Float) = withLock {
frameX = x
frameY = y
coerceValues()
}
/**
* Checks if given point is inside of framebuffer.
*/
fun isValidFbPoint(x: Float, y: Float) = (x >= 0F && x < fbWidth) && (y >= 0F && y < fbHeight)
/**
* Converts given viewport point to corresponding framebuffer point.
* Returns null if given point lies outside of framebuffer.
*/
fun toFb(vpPoint: PointF): PointF? {
val fbX = (vpPoint.x - frameX) / scale
val fbY = (vpPoint.y - frameY) / scale
if (isValidFbPoint(fbX, fbY))
return PointF(fbX, fbY)
else
return null
}
/**
* Converts given framebuffer point to corresponding point in viewport.
*/
fun toVP(fbPoint: PointF): PointF {
return PointF(fbPoint.x * scale + frameX, fbPoint.y * scale + frameY)
}
/**
* Returns immutable & consistent snapshot of frame state.
*/
fun getSnapshot(): Snapshot = withLock {
return Snapshot(frameX = frameX, frameY = frameY,
fbWidth = fbWidth, fbHeight = fbHeight,
vpWidth = vpWidth, vpHeight = vpHeight, scale = scale)
}
private fun calculateBaseScale() {
if (fbHeight == 0F || fbWidth == 0F || windowHeight == 0F)
return //Not enough info yet
val s1 = max(windowWidth, windowHeight) / max(fbWidth, fbHeight)
val s2 = min(windowWidth, windowHeight) / min(fbWidth, fbHeight)
baseScale = min(s1, s2)
}
/**
* Makes sure state values are within constraints.
*/
private fun coerceValues() {
zoomScale1 = zoomScale1.coerceIn(minZoomScale, maxZoomScale)
zoomScale2 = zoomScale2.coerceIn(minZoomScale, maxZoomScale)
if (safeArea.isEmpty || !safeArea.intersect(0f, 0f, vpWidth, vpHeight))
safeArea.set(0f, 0f, vpWidth, vpHeight)
frameX = coercePosition(frameX, safeArea.left, safeArea.right, fbWidth)
frameY = coercePosition(frameY, safeArea.top, safeArea.bottom, fbHeight)
}
/**
* Coerce position value in a direction (horizontal/vertical).
*/
private fun coercePosition(current: Float, safeMin: Float, safeMax: Float, fb: Float): Float {
val scaledFb = (fb * scale)
val diff = (safeMax - safeMin) - scaledFb
return if (diff >= 0) diff / 2 + safeMin //Frame will be smaller than safe area, so center it
else current.coerceIn(diff + safeMin, safeMin) //otherwise, make sure safe area is completely filled.
}
}

View File

@@ -0,0 +1,106 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.vnc
import android.annotation.SuppressLint
import android.content.Context
import android.opengl.GLSurfaceView
import android.os.Build
import android.util.AttributeSet
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.PointerIcon
import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.EditorInfo
import com.gaurav.avnc.ui.vnc.gl.Renderer
import com.gaurav.avnc.viewmodel.VncViewModel
import com.gaurav.avnc.vnc.VncClient
/**
* This class renders the VNC framebuffer on screen.
*
* It derives from [GLSurfaceView], which creates an EGL Display, where we can
* render the framebuffer using OpenGL ES. See [GLSurfaceView] for more details.
*
* Actual rendering is done by [Renderer], which is executed on a dedicated
* thread by [GLSurfaceView].
*
*
*- +-------------------+ +--------------------+ +--------------------+
*- | [FrameView] | | [VncViewModel] | | [VncClient] |
*- +--------+----------+ +----------+---------+ +----------+---------+
*- | | |
*- | | |
*- | Render Request | [FrameState] | Framebuffer
*- | v |
*- | +----------+---------+ |
*- +-------------------> | [Renderer] | <------------------+
*- +--------------------+
*/
class FrameView(context: Context?, attrs: AttributeSet? = null) : GLSurfaceView(context, attrs) {
private lateinit var touchHandler: TouchHandler
private lateinit var keyHandler: KeyHandler
/**
* Input connection used for intercepting key events
*/
inner class InputConnection : BaseInputConnection(this, false) {
override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
return keyHandler.onCommitText(text) || super.commitText(text, newCursorPosition)
}
override fun sendKeyEvent(event: KeyEvent): Boolean {
return keyHandler.onKeyEvent(event) || super.sendKeyEvent(event)
}
}
/**
* Should be called from [VncActivity.onCreate].
*/
fun initialize(activity: VncActivity) {
val viewModel = activity.viewModel
touchHandler = activity.touchHandler
keyHandler = activity.keyHandler
setEGLContextClientVersion(2)
setRenderer(Renderer(viewModel))
renderMode = RENDERMODE_WHEN_DIRTY
// Hide local cursor if requested and supported
if (Build.VERSION.SDK_INT >= 24 && viewModel.pref.input.hideLocalCursor)
pointerIcon = PointerIcon.getSystemIcon(context, PointerIcon.TYPE_NULL)
}
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
outAttrs.imeOptions = outAttrs.imeOptions or
EditorInfo.IME_FLAG_NO_EXTRACT_UI or
EditorInfo.IME_FLAG_NO_FULLSCREEN
return InputConnection()
}
override fun onCheckIsTextEditor(): Boolean {
return true
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
return touchHandler.onTouchEvent(event)
}
override fun onGenericMotionEvent(event: MotionEvent): Boolean {
return touchHandler.onGenericMotionEvent(event)
}
override fun onHoverEvent(event: MotionEvent): Boolean {
return touchHandler.onHoverEvent(event)
}
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright (c) 2021 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.vnc
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.activityViewModels
import com.example.tiny_computer.R
import com.gaurav.avnc.viewmodel.VncViewModel
import com.google.android.material.dialog.MaterialAlertDialogBuilder
/**
* This dialog is used to get user-confirmation before connecting to unknown SSH servers.
*/
class HostKeyFragment : DialogFragment() {
val viewModel by activityViewModels<VncViewModel>()
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val request = viewModel.sshHostKeyVerifyRequest
val hostKey = request.value!!
val titleRes = if (hostKey.isKnownHost) R.string.title_ssh_host_key_changed else R.string.title_unknown_ssh_host
val message = """
|
|Host: ${hostKey.host}
|Key type: ${hostKey.algo.uppercase()}
|Key fingerprint:
|
|${hostKey.getFingerprint()}
|
|Please make sure your are connecting to the valid host.
|
|If you continue, this host & key will be marked as known.
""".trimMargin()
return MaterialAlertDialogBuilder(requireContext())
.setTitle(titleRes)
.setMessage(message)
.setPositiveButton(R.string.title_continue) { _, _ -> request.offerResponse(true) }
.setNegativeButton(android.R.string.cancel) { _, _ -> request.offerResponse(false) }
.setCancelable(false)
.create()
}
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright (c) 2021 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.vnc
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.example.tiny_computer.R
import com.gaurav.avnc.model.db.MainDb
import com.gaurav.avnc.vnc.VncUri
import kotlinx.coroutines.launch
/**
* Handles "external" intents and launches [VncActivity] with appropriate profiles.
*
* Current intent types:
* - vnc:// URIs
* - App shortcuts
*/
class IntentReceiverActivity : AppCompatActivity() {
companion object {
private const val SHORTCUT_PROFILE_ID_KEY = "com.gaurav.avnc.shortcut_profile_id"
fun createShortcutIntent(context: Context, profileId: Long): Intent {
check(profileId != 0L) { "Cannot create shortcut with profileId = 0." }
return Intent(context, IntentReceiverActivity::class.java).apply {
action = Intent.ACTION_VIEW
putExtra(SHORTCUT_PROFILE_ID_KEY, profileId)
}
}
}
private val profileDao by lazy { MainDb.getInstance(this).serverProfileDao }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleIntent()
}
private fun handleIntent() = lifecycleScope.launch {
if (intent.data?.scheme == "vnc")
launchFromVncUri(VncUri(intent.data!!.toString()))
else if (intent.hasExtra(SHORTCUT_PROFILE_ID_KEY))
launchFromProfileId(intent.getLongExtra(SHORTCUT_PROFILE_ID_KEY, 0))
else
toast("Invalid intent: Server info is missing!")
finish()
}
private suspend fun launchFromVncUri(uri: VncUri) {
if (uri.connectionName.isNotBlank()) launchFromProfileName(uri.connectionName)
else launchVncUri(uri)
}
private fun launchVncUri(uri: VncUri) {
if (uri.host.isEmpty()) toast(getString(R.string.msg_invalid_vnc_uri))
else startVncActivity(this, uri)
}
private suspend fun launchFromProfileName(name: String) {
val profile = profileDao.getByName(name).firstOrNull()
if (profile == null) toast("No server found with name '$name'")
else startVncActivity(this, profile)
}
private suspend fun launchFromProfileId(profileId: Long) {
val profile = profileDao.getByID(profileId)
if (profile == null) toast(getString(R.string.msg_shortcut_server_deleted))
else startVncActivity(this, profile)
}
private fun toast(msg: String) = Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
}

View File

@@ -0,0 +1,386 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.vnc
import android.os.Build
import android.view.KeyCharacterMap
import android.view.KeyEvent
import com.gaurav.avnc.util.AppPreferences
import com.gaurav.avnc.vnc.XKeySym
import com.gaurav.avnc.vnc.XKeySymAndroid
import com.gaurav.avnc.vnc.XKeySymAndroid.updateKeyMap
import com.gaurav.avnc.vnc.XKeySymUnicode
import com.gaurav.avnc.vnc.XTKeyCode
/**
* Handler for key events
*
* Key handling in RFB protocol works on 'key symbols' instead of key-codes/scan-codes
* which makes it dependent on keyboard layout. VNC servers implement various heuristics
* to compensate for this & maximize portability. Our implementation is derived after
* testing with some popular servers. It might not handle all the edge cases.
*
* There is an extension to RFB protocol (ExtendedKeyEvent) implemented by some servers.
* It includes support for sending XT keycodes along with key symbol. This extension
* greatly reduces the key handling complexity. Unfortunately, as soft keyboards are
* more common on Android, most [KeyEvent]s don't provide raw scan codes.
*
* Basically, job of this class is to convert the received [KeyEvent] into a 'KeySym'.
* That KeySym will be sent to the server.
*
*- [KeyEvent] +----------------+ KeySym +----------------+
*- ----------------> | [KeyHandler] | ------------> | [Dispatcher] |
*- +----------------+ +----------------+
*
* This class emits (conceptually) three types of key symbols:
*
* 1. X KeySym - Individual symbols defined by X Windows System
* 2. Unicode KeySym - Unicode code points encoded as X KeySym
* 3. Legacy X KeySym - Old KeySyms which are now superseded by their Unicode KeySym equivalents
*
*
* To decide which one to emit, we look at following things:
*
* a. Key code of [KeyEvent] (may not be available, e.g. in case of [KeyEvent.ACTION_MULTIPLE])
* b. Unicode character of [KeyEvent] (may not be available, e.g. in case of [KeyEvent.KEYCODE_F1])
* c. Current [cfLegacyKeysym]
*
*
*- [KeyEvent]
*- |
*- v
*- +----------------------------+
*- | Is Unicode Char Available? |
*- +-------------+--------------+
*- |
*- Yes | No
*- +-------------+--------------+
*- | |
*- +---------v----------+ +-------v-------+
*- | Use Unicode Char | | Use Key Code |
*- +---------+----------+ +-------+-------+
*- | |
*- +---------v----------+ |
*- | In compat mode? | |
*- +---------+----------+ |
*- | |
*- Yes | No |
*- +---------+----------+ |
*- | | |
*- v v v
*- (Legacy X KeySym) (Unicode KeySym) (X KeySym)
*
* See [handleKeyEvent] as a starting point.
*
*
* Reference:
* [X Windows System Protocol](https://www.x.org/releases/X11R7.7/doc/xproto/x11protocol.html#keysym_encoding)
*
*/
class KeyHandler(private val dispatcher: Dispatcher, private val cfLegacyKeysym: Boolean, prefs: AppPreferences) {
/**
* Pre-KeyEvent hook.
* This is NOT triggered for all characters.
*/
fun onCommitText(text: CharSequence?): Boolean {
return handleCCedilla(text)
}
/**
* Shortcut to send both up & down events. Useful for Virtual Keys.
*/
fun onKey(keyCode: Int) {
onKeyEvent(keyCode, true)
onKeyEvent(keyCode, false)
}
fun onKeyEvent(keyCode: Int, isDown: Boolean) {
val action = if (isDown) KeyEvent.ACTION_DOWN else KeyEvent.ACTION_UP
onKeyEvent(KeyEvent(action, keyCode))
}
fun onKeyEvent(event: KeyEvent): Boolean {
if (shouldIgnoreEvent(event))
return false
return handleKeyEvent(preProcessEvent(event))
}
/**
* This will parse the [event] and call [emitForKeyEvent] appropriately.
*/
private fun handleKeyEvent(event: KeyEvent): Boolean {
//Deprecated action types are still received for non-ASCII characters
@Suppress("DEPRECATION")
when (event.action) {
KeyEvent.ACTION_DOWN -> return emitForKeyEvent(event.keyCode, getUnicodeChar(event), true, event.scanCode)
KeyEvent.ACTION_UP -> return emitForKeyEvent(event.keyCode, getUnicodeChar(event), false, event.scanCode)
KeyEvent.ACTION_MULTIPLE -> {
if (event.keyCode == KeyEvent.KEYCODE_UNKNOWN) {
// Here, only Unicode characters are available.
for (uChar in toCodePoints(event.characters)) {
emitForKeyEvent(0, uChar, true)
emitForKeyEvent(0, uChar, false)
}
} else {
// Here, only keyCode is available.
// According to Android docs, this case doesn't happen anymore.
for (i in 1..event.repeatCount) {
emitForKeyEvent(event.keyCode, 0, true)
emitForKeyEvent(event.keyCode, 0, false)
}
}
return true
}
}
return false
}
/**
* Emits an event for given details.
* It will call [emitForAndroidKeyCode] or [emitForUnicodeChar] depending on arguments.
*/
private fun emitForKeyEvent(keyCode: Int, unicodeChar: Int, isDown: Boolean, scanCode: Int = 0): Boolean {
val xtCode = if (scanCode == 0) 0 else XTKeyCode.fromAndroidScancode(scanCode)
if (handleDiacritics(keyCode, unicodeChar, isDown))
return true
// Always emit using keyCode for these because Android returns a unicodeChar
// for these but most servers don't handle their Unicode characters.
when (keyCode) {
KeyEvent.KEYCODE_ENTER,
KeyEvent.KEYCODE_NUMPAD_ENTER,
KeyEvent.KEYCODE_SPACE,
KeyEvent.KEYCODE_TAB ->
return emitForAndroidKeyCode(keyCode, isDown, xtCode)
}
// We prefer to use unicodeChar even when keyCode is available because
// most servers ignore previously sent SHIFT/CAPS_LOCK keys.
// As Android takes meta keys into account when calculating unicodeChar,
// it works well with these servers.
if (unicodeChar != 0)
return emitForUnicodeChar(unicodeChar, isDown, xtCode)
else
return emitForAndroidKeyCode(keyCode, isDown, xtCode)
}
/**
* Emits X KeySym corresponding to [keyCode]
*/
private fun emitForAndroidKeyCode(keyCode: Int, isDown: Boolean, xtCode: Int = 0): Boolean {
val keySym = XKeySymAndroid.getKeySymForAndroidKeyCode(keyCode)
return emit(keySym, isDown, xtCode)
}
/**
* Emits either Unicode KeySym or legacy KeySym for [uChar], depending on [cfLegacyKeysym].
*/
private fun emitForUnicodeChar(uChar: Int, isDown: Boolean, xtCode: Int = 0): Boolean {
var uKeySym = 0
if (cfLegacyKeysym)
uKeySym = XKeySymUnicode.getLegacyKeySymForUnicodeChar(uChar)
if (uKeySym == 0)
uKeySym = XKeySymUnicode.getKeySymForUnicodeChar(uChar)
// If we are generating legacy KeySym and the character is uppercase,
// we need to fake press the Shift key. Otherwise, most servers can't
// handle them. This is just a compat shim and ideally server should
// support Unicode KeySym.
val shouldFakeShift = uKeySym in 0x100..0xfffe && uChar.toChar().isUpperCase()
if (shouldFakeShift)
emitForAndroidKeyCode(KeyEvent.KEYCODE_SHIFT_LEFT, true)
emit(uKeySym, isDown, xtCode)
if (shouldFakeShift)
emitForAndroidKeyCode(KeyEvent.KEYCODE_SHIFT_LEFT, false)
return true
}
/**
* Sends given X key to [dispatcher].
*/
private fun emit(keySym: Int, isDown: Boolean, xtCode: Int = 0): Boolean {
if (keySym == 0)
return false
return dispatcher.onXKey(keySym, xtCode, isDown)
}
/**
* Returns unicode character for given event.
* Normally [KeyEvent.getUnicodeChar] is sufficient for our need, but sometimes
* we have to fiddle with meta state to extract a suitable character.
*
* Consider Ctrl+Shift+A: [KeyEvent.getUnicodeChar] returns 0 for this case,
* because there is no character mapping for A when Ctrl & Shift both are pressed.
* But we want to obtain capital 'A' here, so that we can send it to server.
* This ensures proper working of keyboard shortcuts.
*/
private fun getUnicodeChar(event: KeyEvent): Int {
val uChar = event.unicodeChar
if (uChar != 0 || event.metaState == 0)
return uChar
// Try without Alt/Ctrl
val altCtrl = KeyEvent.META_ALT_MASK or KeyEvent.META_CTRL_MASK
return event.getUnicodeChar(event.metaState and altCtrl.inv())
}
/**
* Some cases where we want to ignore events.
*/
private fun shouldIgnoreEvent(event: KeyEvent): Boolean {
val keyCode = event.keyCode
// As if our key-handling wasn't already complex enough, Android
// decided to mess-up NumLock handling. When any numpad number-key
// is pressed (e.g. 7) and NumLock is off, it will _still_ send
// the number keycode (e.g. KEYCODE_NUMPAD_7) first. And if apps don't
// handle that, it will fallback to secondary action (e.g. KEYCODE_MOVE_HOME).
// So we have to ignore the first events when NumLock is off.
return (keyCode in KeyEvent.KEYCODE_NUMPAD_0..KeyEvent.KEYCODE_NUMPAD_9
|| keyCode == KeyEvent.KEYCODE_NUMPAD_DOT) && !event.isNumLockOn
}
/************************************************************************************
* Diacritics (Accents) Support
*
* Instead of sending diacritics directly to server, we handle their composition here.
* This is done because:
*
* - Android does not report real 'combining' accents to us. Instead we get the
* corresponding 'printing' characters, with the `COMBINING_ACCENT` flag set.
* See the source code of [KeyCharacterMap] for more details.
*
* - Although most servers don't support diacritics directly, some of them can
* handle the final composed characters (e.g. TightVNC).
*
*
* Behaviour:
*
* - Until an accent is received, all events are ignored by [handleDiacritics].
* - When first accent is received, we start tracking by adding it to [accentSequence].
* - When next key is received (can be another accent), we add it to [accentSequence].
* - Then we try to compose a printable character from [accentSequence], and if successful,
* the composed character is sent to the server.
* - If composition was successful, or we received non-accent key, we stop tracking
* by clearing [accentSequence].
*
************************************************************************************/
private var accentSequence = ArrayList<Int>(2)
private fun handleDiacritics(keyCode: Int, uChar: Int, isDown: Boolean): Boolean {
val isUp = !isDown
val isAccent = uChar and KeyCharacterMap.COMBINING_ACCENT != 0
val maskedChar = uChar and KeyCharacterMap.COMBINING_ACCENT_MASK
if (!isAccent && accentSequence.size == 0) return false // No tracking yet (most common case)
if (!isAccent && isUp && !accentSequence.contains(maskedChar)) return false // Spurious key-ups
if (KeyEvent.isModifierKey(keyCode)) return false // Modifier keys are passed-on to the server
if (isDown)
accentSequence.add(maskedChar)
if (accentSequence.size <= 1) // Nothing to compose yet
return true
var composed = accentSequence.last()
for (i in 0 until accentSequence.lastIndex)
composed = KeyEvent.getDeadChar(accentSequence[i], composed)
if (composed != 0)
emitForUnicodeChar(composed, isDown)
if (isUp && (composed != 0 || !isAccent))
accentSequence.clear()
return true
}
/**
* 'Ç' & 'ç' requires special handling because Android generates them with extra ALT key press,
* and gives no indication in KeyEvents that accents are involved. So we have to handle these
* before events are synthesized by InputConnection in FrameView.
*/
private fun handleCCedilla(text: CharSequence?): Boolean {
if (text == "ç") {
emitForUnicodeChar('ç'.code, true)
emitForUnicodeChar('ç'.code, false)
return true
}
if (text == "Ç") {
emitForAndroidKeyCode(KeyEvent.KEYCODE_SHIFT_LEFT, true)
emitForUnicodeChar('Ç'.code, true)
emitForUnicodeChar('Ç'.code, false)
emitForAndroidKeyCode(KeyEvent.KEYCODE_SHIFT_LEFT, false)
return true
}
return false
}
/************************************************************************************
* Custom key-mappings
***********************************************************************************/
init {
if (prefs.input.kmLanguageSwitchToSuper) updateKeyMap(KeyEvent.KEYCODE_LANGUAGE_SWITCH, XKeySym.XK_Super_L)
if (prefs.input.kmRightAltToSuper) updateKeyMap(KeyEvent.KEYCODE_ALT_RIGHT, XKeySym.XK_Super_L)
}
// We can't map Back key to Escape inside init because we don't
// want to affect Back key events coming from Navigation Bar.
// So we have to test each event.
private val kmBackToEscape = prefs.input.kmBackToEscape
private fun preProcessEvent(event: KeyEvent): KeyEvent {
if (event.keyCode == KeyEvent.KEYCODE_BACK && kmBackToEscape
&& (event.flags and KeyEvent.FLAG_VIRTUAL_HARD_KEY == 0))
return KeyEvent(event.action, KeyEvent.KEYCODE_ESCAPE)
return event
}
/************************************************************************************
* Convert String to Array of Unicode code-points
***********************************************************************************/
private val cpCache = intArrayOf(0)
private fun toCodePoints(string: String): IntArray {
//Handle simple & most probable case
if (string.length == 1)
return cpCache.apply { this[0] = string[0].code }
if (Build.VERSION.SDK_INT >= 24)
return string.codePoints().toArray()
//Otherwise, do simple conversion (will be incorrect non-MBP code points)
return string.map { it.code }.toIntArray()
}
}

View File

@@ -0,0 +1,319 @@
/*
* Copyright (c) 2022 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.vnc
import android.graphics.Rect
import android.graphics.RectF
import android.os.Build.VERSION.SDK_INT
import android.view.RoundedCorner
import android.view.View
import android.view.WindowManager
import androidx.annotation.RequiresApi
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import kotlin.math.max
/**
* This class is responsible for managing fullscreen layout & insets handling.
* Layout handling is quite complex because of Android APIs (or lack thereof).
* It gets way worse here because AVNC shows fullscreen content.
*
* Situation is so bad, it's almost funny.
*
* Almost every Android version has introduced some new APIs, deprecated others,
* or downright changed existing behaviour (sometimes just for ONE version).
* AndroidX compat library has been the only respite. It has at least hidden a
* bunch of if-else statement from our own code. But all that mess is still there.
*/
class LayoutManager(activity: VncActivity) {
private val viewModel = activity.viewModel
private val rootView = activity.binding.root
private val frameView = activity.binding.frameView
private val virtualKeys = activity.virtualKeys
private val window = activity.window
private val insetController = WindowCompat.getInsetsController(window, window.decorView)
fun initialize() {
if (SDK_INT >= 30)
hookInsetsListener()
else
hookSystemUiChangeListener()
hookGlobalLayoutListener()
}
fun onConnectionStateChanged() {
updateFullscreen()
}
fun onWindowFocusChanged(hasFocus: Boolean) {
if (hasFocus && SDK_INT < 30)
updateFullscreen()
}
@RequiresApi(30)
private fun hookInsetsListener() {
ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v, insets ->
updateWindowInsets(insets)
updateNavigationBarVisibility(insets)
ViewCompat.onApplyWindowInsets(v, insets)
}
}
private fun hookGlobalLayoutListener() {
rootView.viewTreeObserver.addOnGlobalLayoutListener {
viewModel.frameState.setWindowSize(rootView.width.toFloat(), rootView.height.toFloat())
viewModel.frameState.setViewportSize(frameView.width.toFloat(), frameView.height.toFloat())
virtualKeys.container?.let { updateVirtualKeyInsets(it) }
if (SDK_INT < 30)
manuallyGenerateWindowInsets()
applyInsets()
viewModel.resizeRemoteDesktop()
}
}
/**
* System's WindowInsets are basically useless for us on API < 30. Neither are
* they dispatched in all cases, nor they always include necessary information.
* So, on these platforms, insets are generated manually.
*
* We could apply required padding directly here, but simulating insets means that
* [applyInsets] has a single implementation for all platforms.
*/
private fun manuallyGenerateWindowInsets() {
val decorView = window.decorView
val visibleFrame = Rect()
decorView.getWindowVisibleDisplayFrame(visibleFrame)
// Transform from screen coordinates to window coordinates
// Both can be different, e.g. when in PiP mode
val locationOnScreen = intArrayOf(0, 0)
decorView.getLocationOnScreen(locationOnScreen)
visibleFrame.offset(-locationOnScreen[0], -locationOnScreen[1])
// Generate insets (left & top are always 0)
val right = max(0, decorView.right - visibleFrame.right)
val bottom = max(0, decorView.bottom - visibleFrame.bottom)
val insets = WindowInsetsCompat.Builder()
.setInsets(Type.ime(), Insets.of(0, 0, 0, bottom))
.setInsets(Type.navigationBars(), Insets.of(0, 0, right, 0))
.build()
updateWindowInsets(insets)
}
/**
* Flags to hide system bars are not 'sticky' on API < 30. System will automatically
* clear them in a lot of situations, e.g. when IME is shown. So we have to keep
* reminding it that we still want to remain fullscreen.
* Same reason why [onWindowFocusChanged] exists.
*/
private fun hookSystemUiChangeListener() {
@Suppress("DEPRECATION")
window.decorView.setOnSystemUiVisibilityChangeListener { updateFullscreen() }
}
/************************************************************************************
* Fullscreen
************************************************************************************/
private val fullscreenEnabled = viewModel.pref.viewer.fullscreen
private val defaultSystemBarBehaviour = insetController.systemBarsBehavior
private fun updateFullscreen() {
if (!fullscreenEnabled)
return
if (viewModel.client.connected)
enterFullscreen()
else
leaveFullscreen()
}
private fun enterFullscreen() {
WindowCompat.setDecorFitsSystemWindows(window, false)
insetController.hide(Type.systemBars())
insetController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
if (SDK_INT < 30) {
// This flag is required to keep the status bar hidden when IME is visible
@Suppress("DEPRECATION")
window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
}
}
private fun leaveFullscreen() {
WindowCompat.setDecorFitsSystemWindows(window, true)
insetController.show(Type.systemBars())
insetController.systemBarsBehavior = defaultSystemBarBehaviour
}
@RequiresApi(30)
private fun updateNavigationBarVisibility(insets: WindowInsetsCompat) {
// On API 30, Android doesn't automatically show the navigation bar with keyboard.
// So to hide the keyboard, user has to first swipe to un-hide the navigation bar
// and then tap on Back button. To avoid this, we manually show the navigation bar
// whenever keyboard is visible. Although only API 30 seems to be affected, fix is
// applied on 30+ APIs to ensure consistency.
if (insets.isVisible(Type.ime()))
insetController.show(Type.navigationBars())
else if (fullscreenEnabled && viewModel.client.connected) {
insetController.hide(Type.navigationBars())
}
}
/************************************************************************************
* Insets
*
* Insets are Android's way of telling us when our window is obscured by something.
* To provide optimal experience, insets are divided into two categories:
*
* Opaque Insets: These insets don't allow interaction with app's window behind them.
* IME & nav bar are such insets. These are handled by adding padding to [rootView],
* effectively resizing [frameView] to visible portion of the screen.
*
* Safe Area Insets: These obstructs [frameView] only partially, e.g. display cutout.
* We want the frame to be rendered behind these cutouts to provide fully immersive
* experience, but also allow user to pan out of these insets when they need to
* interact with remote content. These are implemented as 'safe area' in [FrameState].
*
* All insets are in window coordinates.
*
************************************************************************************/
private var windowInsets = WindowInsetsCompat.Builder().build()
private var virtualKeyInsets = Insets.NONE // Insets caused by virtual keys
private fun updateWindowInsets(insetsCompat: WindowInsetsCompat) {
// Copy constructor of WindowInsetsCompat is partially broken on API below 30.
// We are manually generating insets on API <30 anyway, so don't need to create a copy.
windowInsets = if (SDK_INT < 30) insetsCompat else WindowInsetsCompat(insetsCompat)
}
private fun updateVirtualKeyInsets(vkRoot: View) {
if (vkRoot.isVisible) {
val vkLocation = intArrayOf(0, 0)
vkRoot.getLocationInWindow(vkLocation)
val b = max(0, window.decorView.height - vkLocation[1])
if (virtualKeyInsets.bottom != b)
virtualKeyInsets = Insets.of(0, 0, 0, b)
} else {
virtualKeyInsets = Insets.NONE
}
}
private fun applyInsets() {
val opaqueInsets = listOf(windowInsets.getInsets(Type.ime()), windowInsets.getInsets(Type.navigationBars()))
val maxOpaqueInsets = opaqueInsets.fold(Insets.NONE) { a, i -> Insets.max(a, i) }
applyOpaqueInsets(maxOpaqueInsets)
//val cutoutInsets = windowInsets.getInsets(Type.displayCutout())
//val cornerInsets = calculateCornerInsets(windowInsets)
val safeAreaInsets = listOf(maxOpaqueInsets, /*cutoutInsets, cornerInsets,*/ virtualKeyInsets)
val maxSafeAreaInsets = safeAreaInsets.fold(Insets.NONE) { a, i -> Insets.max(a, i) }
applySafeAreaInsets(maxSafeAreaInsets)
// TODO: Apply insets to drawer
}
private fun applyOpaqueInsets(opaqueInsets: Insets) {
// Guess if IME is closing
if (!windowInsets.isVisible(Type.ime()) && rootView.paddingBottom != 0)
virtualKeys.onKeyboardClose()
val insets = windowInsetsToViewInsets(opaqueInsets, rootView)
if (rootView.paddingRight != insets.right || rootView.paddingBottom != insets.bottom)
rootView.updatePadding(0, 0, insets.right, insets.bottom)
}
private fun applySafeAreaInsets(safeAreaInsets: Insets) {
val insets = windowInsetsToViewInsets(safeAreaInsets, frameView)
val safeArea = Rect(insets.left,
insets.top,
frameView.width - insets.right,
frameView.height - insets.bottom)
viewModel.setSafeArea(RectF(safeArea))
}
/**
* Returns insets caused by rounded corners.
*
* Android does not provide pre-calculated insets for rounded screen corners.
* Information about corners is available since API 31. We use this information
* to calculate effective insets.
*/
private fun calculateCornerInsets(windowInsetsCompat: WindowInsetsCompat): Insets {
val windowInsets = windowInsetsCompat.toWindowInsets()
if (SDK_INT < 31 || windowInsets == null)
return Insets.NONE
val wWidth = window.decorView.width
val wHeight = window.decorView.height
var l = 0
var t = 0
var r = 0
var b = 0
windowInsets.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT)?.center?.let {
t = max(t, it.y)
l = max(l, it.x)
}
windowInsets.getRoundedCorner(RoundedCorner.POSITION_TOP_RIGHT)?.center?.let {
t = max(t, it.y)
r = max(r, wWidth - it.x)
}
windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT)?.center?.let {
b = max(b, wHeight - it.y)
l = max(l, it.x)
}
windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT)?.center?.let {
b = max(b, wHeight - it.y)
r = max(r, wWidth - it.x)
}
// Corner insets are only applied along longer axis. Applying along both axis is unnecessary.
if (wWidth > wHeight)
return Insets.of(l, 0, r, 0)
else
return Insets.of(0, t, 0, b)
}
/**
* Transforms given insets [insets] from Window's coordinates to [view]'s coordinates
*/
private fun windowInsetsToViewInsets(insets: Insets, view: View): Insets {
val viewLocation = intArrayOf(0, 0)
view.getLocationInWindow(viewLocation)
// View frame in window coordinates
val vl = viewLocation[0]
val vt = viewLocation[1]
val vr = vl + view.width
val vb = vt + view.height
// Insets applicable to given view
val l = max(0, insets.left - vl)
val t = max(0, insets.top - vt)
val r = max(0, insets.right - (window.decorView.width - vr))
val b = max(0, insets.bottom - (window.decorView.height - vb))
return Insets.of(l, t, r, b)
}
}

View File

@@ -0,0 +1,172 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.vnc
import android.app.Dialog
import android.os.Bundle
import android.util.ArrayMap
import android.widget.ArrayAdapter
import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Observer
import com.example.tiny_computer.R
import com.example.tiny_computer.databinding.FragmentCredentialBinding
import com.gaurav.avnc.model.LoginInfo
import com.gaurav.avnc.model.ServerProfile
import com.gaurav.avnc.viewmodel.VncViewModel
import com.gaurav.avnc.viewmodel.VncViewModel.State.Companion.isConnected
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputLayout
/**
* Allows user to enter login information.
*
* There are different types of login information ([LoginInfo.Type]),
* but all of them basically boils down to a username/password combo.
*
* User can choose to "remember" the information, in which case it will be
* saved in the profile.
*
*/
class LoginFragment : DialogFragment() {
private lateinit var binding: FragmentCredentialBinding
private val viewModel by activityViewModels<VncViewModel>()
private val loginType by lazy { viewModel.loginInfoRequest.value!! }
private val loginInfo by lazy { getLoginInfoFromProfile(viewModel.profile) }
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
binding = FragmentCredentialBinding.inflate(layoutInflater, null, false)
binding.loginInfo = loginInfo
binding.usernameLayout.isVisible = loginInfo.username.isBlank() && loginType == LoginInfo.Type.VNC_CREDENTIAL
binding.passwordLayout.isVisible = loginInfo.password.isBlank()
binding.remember.isVisible = viewModel.profile.ID != 0L && loginType != LoginInfo.Type.SSH_KEY_PASSWORD
if (loginType == LoginInfo.Type.SSH_KEY_PASSWORD) {
binding.passwordLayout.setHint(R.string.hint_key_password)
binding.pkPasswordMsg.isVisible = viewModel.profile.sshPrivateKeyPassword.isNotBlank()
}
setupAutoComplete()
isCancelable = false
return MaterialAlertDialogBuilder(requireContext())
.setTitle(getTitle())
.setView(binding.root)
.setPositiveButton(android.R.string.ok) { _, _ -> onOk() }
.setNegativeButton(android.R.string.cancel) { _, _ -> onCancel() }
.create()
}
private fun getTitle() = when (loginType) {
LoginInfo.Type.VNC_PASSWORD,
LoginInfo.Type.VNC_CREDENTIAL -> R.string.title_vnc_login
LoginInfo.Type.SSH_PASSWORD -> R.string.title_ssh_login
LoginInfo.Type.SSH_KEY_PASSWORD -> R.string.title_unlock_private_key
}
private fun getLoginInfoFromProfile(p: ServerProfile): LoginInfo {
return when (loginType) {
LoginInfo.Type.VNC_PASSWORD -> LoginInfo(p.name, p.host, "", p.password)
LoginInfo.Type.VNC_CREDENTIAL -> LoginInfo(p.name, p.host, p.username, p.password)
LoginInfo.Type.SSH_PASSWORD -> LoginInfo(p.name, p.sshHost, "", p.sshPassword)
LoginInfo.Type.SSH_KEY_PASSWORD -> LoginInfo(p.name, p.sshHost, "", "" /*p.sshPrivateKeyPassword*/)
}
}
private fun setLoginInfoInProfile(p: ServerProfile, l: LoginInfo) {
when (loginType) {
LoginInfo.Type.VNC_PASSWORD -> p.password = l.password
LoginInfo.Type.VNC_CREDENTIAL -> {
p.username = l.username
p.password = l.password
}
LoginInfo.Type.SSH_PASSWORD -> p.sshPassword = l.password
LoginInfo.Type.SSH_KEY_PASSWORD -> p.sshPrivateKeyPassword = "" /* key password is not saved anymore */
}
}
private fun onOk() {
loginInfo.password = getRealPassword(loginInfo.password)
viewModel.loginInfoRequest.offerResponse(loginInfo)
if (binding.remember.isChecked || binding.pkPasswordMsg.isVisible /* to forget saved password */)
saveLoginInfo(loginInfo)
}
private fun onCancel() {
viewModel.loginInfoRequest.cancelRequest()
requireActivity().finish()
}
/**
* If user has asked to remember credentials, we need to save them
* to database. But we don't want to save them immediately because
* user might have mistyped them. So, we wait until successful
* connection before saving them.
*/
private fun saveLoginInfo(loginInfo: LoginInfo) {
// Use activity as owner because this fragment will likely be destroyed before connecting
viewModel.state.observe(requireActivity(), object : Observer<VncViewModel.State> {
override fun onChanged(value: VncViewModel.State) {
if (value.isConnected) {
setLoginInfoInProfile(viewModel.profile, loginInfo)
viewModel.saveProfile()
viewModel.state.removeObserver(this)
}
}
})
}
/**
* Hooks completion adapters
*
* This feature might not be that useful to end-users, but it saves a lot of time
* during development because I have to frequently install/uninstall app, test
* different servers running on different addresses/ports.
*/
private fun setupAutoComplete() {
viewModel.savedProfiles.observe(this) { profiles ->
val logins = profiles.map { getLoginInfoFromProfile(it) }
val usernames = logins.map { it.username }.filter { it.isNotEmpty() }.distinct()
val passwords = preparePasswordSuggestions(logins)
if (usernames.isNotEmpty()) {
val usernameAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_list_item_1, usernames)
binding.username.setAdapter(usernameAdapter)
binding.usernameLayout.endIconMode = TextInputLayout.END_ICON_DROPDOWN_MENU
}
if (passwords.isNotEmpty()) {
val passwordAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_list_item_1, passwords)
binding.password.setAdapter(passwordAdapter)
binding.passwordLayout.endIconMode = TextInputLayout.END_ICON_DROPDOWN_MENU
}
}
}
/**
* Instead of showing plaintext passwords, we show server name & host in suggestion
* list. When user taps OK, we convert the suggestion back to real password.
*/
private val passwordMap = ArrayMap<String, String>()
private fun preparePasswordSuggestions(list: List<LoginInfo>): List<String> {
list.filter { it.password.isNotEmpty() }
.map { Pair("from: ${it.name} [${it.host}]", it.password) }
.distinct()
.toMap(passwordMap)
.removeAll(passwordMap.values) //Guard against (very unlikely) clash with real password
return passwordMap.keys.toList()
}
private fun getRealPassword(typedPassword: String) = passwordMap[typedPassword] ?: typedPassword
}

View File

@@ -0,0 +1,294 @@
/*
* Copyright (c) 2024 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.vnc
import android.annotation.SuppressLint
import android.graphics.Rect
import android.os.Build
import android.view.GestureDetector
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.lifecycle.lifecycleScope
import com.example.tiny_computer.R
import com.gaurav.avnc.viewmodel.VncViewModel.State
import com.gaurav.avnc.viewmodel.VncViewModel.State.Companion.isConnected
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
*
* Overview of toolbar layout:
*
* DrawerLayout
* +-------------------+--------------+
* | | |
* | Toolbar Drawer | |
* | [drawerView] | |
* | | |
* |+---+ | |
* || B | | |
* || t | | |
* || n |+------------+| Scrim |
* || s || Flyout || |
* |+---++------------+| |
* | | |
* | | |
* | | |
* | | |
* | | |
* +-------------------+--------------+
*
* User can align the toolbar to left or right edge.
*
*/
class Toolbar(private val activity: VncActivity, private val dispatcher: Dispatcher) {
private val viewModel = activity.viewModel
private val binding = activity.binding.toolbar
private val drawerLayout = activity.binding.drawerLayout
private val drawerView = binding.root
private val openWithSwipe = viewModel.pref.viewer.toolbarOpenWithSwipe
fun initialize() {
binding.keyboardBtn.setOnClickListener { activity.showKeyboard(); close() }
binding.zoomOptions.setOnLongClickListener { resetZoomToDefault(); close(); true }
binding.zoomResetBtn.setOnClickListener { resetZoomToDefault(); close() }
binding.zoomResetBtn.setOnLongClickListener { resetZoom(); close(); true }
binding.zoomLockBtn.isChecked = viewModel.profile.fZoomLocked
binding.zoomLockBtn.setOnCheckedChangeListener { _, checked -> toggleZoomLock(checked); close() }
binding.zoomSaveBtn.setOnClickListener { saveZoom(); close() }
binding.virtualKeysBtn.setOnClickListener { activity.virtualKeys.show(); close() }
// Root view is transparent. Click on it should work just like a click in scrim area
drawerView.setOnClickListener { close() }
viewModel.state.observe(activity) { onStateChange(it) }
setupAlignment()
setupFlyoutClose()
setupGestureStyleSelection()
setupGestureExclusionRect()
setupDrawerCloseOnScrimSwipe()
}
fun open() {
drawerLayout.openDrawer(drawerView)
}
fun close() {
drawerLayout.closeDrawer(drawerView)
}
private fun toast(@StringRes msgRes: Int) = Toast.makeText(activity, msgRes, Toast.LENGTH_SHORT).show()
private fun resetZoom() {
viewModel.resetZoom()
toast(R.string.msg_zoom_reset)
}
private fun resetZoomToDefault() {
viewModel.resetZoomToDefault()
toast(R.string.msg_zoom_reset_default)
}
private fun toggleZoomLock(enabled: Boolean) {
viewModel.toggleZoomLock(enabled)
toast(if (enabled) R.string.msg_zoom_locked else R.string.msg_zoom_unlocked)
}
private fun saveZoom() {
viewModel.saveZoom()
toast(R.string.msg_zoom_saved)
}
private fun setupGestureStyleSelection() {
val styleButtonMap = mapOf(
"auto" to R.id.gesture_style_auto,
"touchscreen" to R.id.gesture_style_touchscreen,
"touchpad" to R.id.gesture_style_touchpad
)
binding.gestureStyleGroup.let { group ->
group.check(styleButtonMap[viewModel.profile.gestureStyle] ?: -1)
group.setOnCheckedChangeListener { _, id ->
for ((k, v) in styleButtonMap)
if (v == id) viewModel.profile.gestureStyle = k
viewModel.saveProfile()
dispatcher.onGestureStyleChanged()
close()
}
}
}
private fun onStateChange(state: State) {
if (state.isConnected)
highlightForFirstTimeUser()
if (Build.VERSION.SDK_INT >= 29)
updateGestureExclusionRect()
updateLockMode(state.isConnected)
}
/**
* Open the drawer for couple of seconds and then close it.
*/
private fun highlightForFirstTimeUser() {
if (!viewModel.pref.runInfo.hasConnectedSuccessfully) {
viewModel.pref.runInfo.hasConnectedSuccessfully = true
activity.lifecycleScope.launch {
open()
delay(2000)
close()
}
}
}
private fun updateLockMode(isConnected: Boolean) {
if (isConnected && openWithSwipe)
drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED)
else
drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
}
/**
* Setup gravity & layout direction
*/
@SuppressLint("RtlHardcoded")
private fun setupAlignment() {
val gravity = if (viewModel.pref.viewer.toolbarAlignment == "start") GravityCompat.START else GravityCompat.END
val lp = drawerView.layoutParams as DrawerLayout.LayoutParams
lp.gravity = gravity
drawerView.layoutParams = lp
// Before layout pass, layoutDirection should be retrieved from Activity config
val layoutDirection = activity.resources.configuration.layoutDirection
val isLeftAligned = Gravity.getAbsoluteGravity(gravity, layoutDirection) == Gravity.LEFT
// We need the layout direction based on alignment rather than language/locale
// so that flyouts and button icons are properly ordered.
drawerView.layoutDirection = if (isLeftAligned) View.LAYOUT_DIRECTION_LTR else View.LAYOUT_DIRECTION_RTL
// Let the gesture group have natural layout as it contains text elements
binding.gestureStyleGroup.layoutDirection = layoutDirection
}
/**
* Setup gesture exclusion updates
*/
private fun setupGestureExclusionRect() {
if (Build.VERSION.SDK_INT >= 29) {
binding.primaryButtons.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
updateGestureExclusionRect()
}
}
}
/**
* Add System Gesture exclusion rects to allow toolbar opening when gesture navigation is active.
* Note: Some ROMs, e.g. MIUI, completely ignore whatever is set here.
*/
@RequiresApi(29)
private fun updateGestureExclusionRect() {
if (!openWithSwipe || !viewModel.state.value.isConnected) {
drawerLayout.systemGestureExclusionRects = listOf()
} else {
// Area covered by primaryButtons, in drawerLayout's coordinate space
val rect = Rect(drawerView.left, binding.primaryButtons.top, drawerView.right, binding.primaryButtons.bottom)
if (rect.left < 0) rect.offset(-rect.left, 0)
if (rect.right > drawerLayout.width) rect.offset(-(rect.right - drawerLayout.width), 0)
if (viewModel.pref.viewer.fullscreen) {
// For fullscreen activities, Android does not enforce the height limit of exclusion area.
// We could use the entire height for opening toolbar, but that will completely disable gestures.
// So we pad by one-third of available space in each direction
val padding = (drawerLayout.height - rect.height()) / 6
if (padding > 0) {
rect.top -= padding
rect.bottom += padding
}
}
drawerLayout.systemGestureExclusionRects = listOf(rect)
}
}
/**
* Close flyouts after drawer is closed.
*
* We can't do this in [close] because that will change toolbar width _while_ drawer
* is closing. This can conflict with close animation, and mess with internal calculations
* of DrawerLayout, resulting in failure of close operation.
*/
private fun setupFlyoutClose() {
drawerLayout.addDrawerListener(object : DrawerLayout.SimpleDrawerListener() {
override fun onDrawerClosed(closedView: View) {
if (closedView == drawerView) {
binding.zoomOptions.isChecked = false
binding.gestureStyleToggle.isChecked = false
}
}
})
}
/**
* Normally, drawers in [DrawerLayout] are closed by two gestures:
* 1. Swipe 'on' the drawer
* 2. Tap inside Scrim (dimmed region outside of drawer)
*
* Notably, swiping inside scrim area does NOT hide the drawer. This can be jarring
* to users if drawer is relatively small & most of the layout area acts as scrim.
* The toolbar drawer is affected by this issue.
*
* This function attempts to detect these swipe gestures and close the drawer
* when they happen.
*
* Note: It will set a custom TouchListener on [drawerLayout].
*/
@SuppressLint("ClickableViewAccessibility", "RtlHardcoded")
private fun setupDrawerCloseOnScrimSwipe() {
drawerLayout.setOnTouchListener(object : View.OnTouchListener {
var drawerOpen = false
var drawerGravity = Gravity.LEFT
val detector = GestureDetector(drawerLayout.context, object : GestureDetector.SimpleOnGestureListener() {
override fun onFling(e1: MotionEvent?, e2: MotionEvent, vX: Float, vY: Float): Boolean {
if ((drawerGravity == Gravity.LEFT && vX < 0) || (drawerGravity == Gravity.RIGHT && vX > 0)) {
close()
drawerOpen = false
}
return true
}
})
override fun onTouch(v: View, event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
drawerOpen = drawerLayout.isDrawerOpen(drawerView)
drawerGravity = Gravity.getAbsoluteGravity(
(drawerView.layoutParams as DrawerLayout.LayoutParams).gravity,
drawerLayout.layoutDirection) and Gravity.HORIZONTAL_GRAVITY_MASK
}
if (drawerOpen)
detector.onTouchEvent(event)
return false
}
})
}
}

View File

@@ -0,0 +1,543 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.vnc
import android.content.Context
import android.graphics.PointF
import android.os.Build
import android.view.GestureDetector
import android.view.GestureDetector.SimpleOnGestureListener
import android.view.HapticFeedbackConstants
import android.view.InputDevice
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.view.ViewConfiguration
import com.gaurav.avnc.viewmodel.VncViewModel
import com.gaurav.avnc.vnc.PointerButton
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.max
/**
* Handler for touch events. It detects various gestures and notifies [dispatcher].
*/
class TouchHandler(private val viewModel: VncViewModel, private val dispatcher: Dispatcher)
: ScaleGestureDetector.OnScaleGestureListener, SimpleOnGestureListener() {
//Extension to easily access touch position
private fun MotionEvent.point() = PointF(x, y)
/****************************************************************************************
* Touch Event receivers
*
* Note: On some devices, 'Source' property for Stylus events is set to both
* [InputDevice.SOURCE_STYLUS] & [InputDevice.SOURCE_MOUSE]. Hence, we should
* pass the events to [handleStylusEvent] before [handleMouseEvent].
****************************************************************************************/
fun onTouchEvent(event: MotionEvent): Boolean {
val handled = handleStylusEvent(event) || handleMouseEvent(event) || handleGestureEvent(event)
handleGestureStartStop(event)
return handled
}
fun onGenericMotionEvent(event: MotionEvent): Boolean {
return onHoverEvent(event) || handleStylusEvent(event) || handleMouseEvent(event)
}
fun onHoverEvent(event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
lastHoverPoint = event.point()
dispatcher.onMouseMove(lastHoverPoint)
return true
}
return false
}
private fun handleGestureStartStop(event: MotionEvent) {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> dispatcher.onGestureStart()
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> dispatcher.onGestureStop(event.point())
}
}
// Used for back-press interception
private var lastHoverPoint = PointF()
fun onMouseBack() {
dispatcher.onMouseBack(lastHoverPoint)
}
/****************************************************************************************
* Mouse
****************************************************************************************/
private val mousePassthrough = viewModel.pref.input.mousePassthrough
private fun handleMouseEvent(e: MotionEvent): Boolean {
if (Build.VERSION.SDK_INT < 23 || !mousePassthrough || !e.isFromSource(InputDevice.SOURCE_MOUSE))
return false
val p = e.point()
when (e.actionMasked) {
MotionEvent.ACTION_BUTTON_PRESS -> dispatcher.onMouseButtonDown(convertButton(e.actionButton), p)
MotionEvent.ACTION_BUTTON_RELEASE -> dispatcher.onMouseButtonUp(convertButton(e.actionButton), p)
MotionEvent.ACTION_MOVE -> dispatcher.onMouseMove(p)
MotionEvent.ACTION_SCROLL -> {
val hs = e.getAxisValue(MotionEvent.AXIS_HSCROLL)
val vs = e.getAxisValue(MotionEvent.AXIS_VSCROLL)
dispatcher.onMouseScroll(p, hs, vs)
}
}
// Allow touchpad gestures to be passed on to GestureDetector
return !(e.buttonState == 0 && e.getToolType(0) != MotionEvent.TOOL_TYPE_MOUSE)
}
/**
* Convert from [MotionEvent] button to [PointerButton]
*/
private fun convertButton(button: Int) = when (button) {
MotionEvent.BUTTON_PRIMARY -> PointerButton.Left
MotionEvent.BUTTON_SECONDARY -> PointerButton.Right
MotionEvent.BUTTON_TERTIARY -> PointerButton.Middle
else -> PointerButton.None
}
/****************************************************************************************
* Stylus
****************************************************************************************/
private val stylusGestureDetector = GestureDetector(viewModel.app, StylusGestureListener())
private fun handleStylusEvent(event: MotionEvent): Boolean {
if (event.isFromSource(InputDevice.SOURCE_STYLUS) &&
event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS) {
stylusGestureDetector.onTouchEvent(event)
return true
}
return false
}
inner class StylusGestureListener : SimpleOnGestureListener() {
private var scrolling = false
override fun onDown(e: MotionEvent): Boolean {
scrolling = false
return true
}
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
dispatcher.onStylusTap(e.point())
return true
}
override fun onDoubleTap(e: MotionEvent): Boolean {
dispatcher.onStylusDoubleTap(e.point())
return true
}
override fun onLongPress(e: MotionEvent) {
viewModel.frameViewRef.get()?.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
dispatcher.onStylusLongPress(e.point())
}
override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
// Scrolling with stylus button pressed is currently used for scale gesture
if (e1 != null && e2.buttonState and MotionEvent.BUTTON_STYLUS_PRIMARY == 0) {
// When scrolling starts, we need to send the first event at initial touch-point.
// Otherwise, we will loose the small distance (touch-slope) required by onScroll().
if (!scrolling) {
scrolling = true
dispatcher.onStylusScroll(e1.point())
}
dispatcher.onStylusScroll(e2.point())
}
return true
}
}
/****************************************************************************************
* Finger Gestures (and everything else beside mouse & stylus)
****************************************************************************************/
private val scaleDetector = ScaleGestureDetector(viewModel.app, this).apply { isQuickScaleEnabled = false }
private val gestureDetector = GestureDetectorEx(viewModel.app, FingerGestureListener())
private val swipeVsScale = SwipeVsScale()
private val longPressSwipeEnabled = viewModel.pref.input.gesture.longPressSwipeEnabled
private val swipeSensitivity = viewModel.pref.input.gesture.swipeSensitivity
private fun handleGestureEvent(event: MotionEvent): Boolean {
swipeVsScale.onTouchEvent(event)
scaleDetector.onTouchEvent(event)
return gestureDetector.onTouchEvent(event)
}
override fun onScaleBegin(detector: ScaleGestureDetector) = true
override fun onScaleEnd(detector: ScaleGestureDetector) {}
override fun onScale(detector: ScaleGestureDetector): Boolean {
if (swipeVsScale.shouldScale())
dispatcher.onScale(detector.scaleFactor, detector.focusX, detector.focusY)
return true
}
private inner class FingerGestureListener : GestureDetectorEx.GestureListenerEx {
override fun onSingleTapConfirmed(e: MotionEvent) = dispatcher.onTap1(e.point())
override fun onDoubleTapConfirmed(e: MotionEvent) = dispatcher.onDoubleTap(e.point())
override fun onMultiFingerTap(e: MotionEvent, fingerCount: Int) {
when (fingerCount) {
2 -> dispatcher.onTap2(e.point())
// Taps by 3+ fingers are not exposed yet
}
}
override fun onLongPress(e: MotionEvent) {
viewModel.frameViewRef.get()?.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
// If long-press-swipe is disabled, we can dispatch long-press immediately
if (!longPressSwipeEnabled) dispatcher.onLongPress(e.point())
}
override fun onLongPressConfirmed(e: MotionEvent) {
if (longPressSwipeEnabled) dispatcher.onLongPress(e.point())
}
override fun onScroll(e1: MotionEvent, e2: MotionEvent, dx: Float, dy: Float) {
val startPoint = e1.point()
val currentPoint = e2.point()
val normalizedDx = dx * swipeSensitivity
val normalizedDy = dy * swipeSensitivity
when (e2.pointerCount) {
1 -> dispatcher.onSwipe1(startPoint, currentPoint, normalizedDx, normalizedDy)
2 -> if (swipeVsScale.shouldSwipe())
dispatcher.onSwipe2(startPoint, currentPoint, normalizedDx, normalizedDy)
}
}
override fun onScrollAfterLongPress(e1: MotionEvent, e2: MotionEvent, dx: Float, dy: Float) {
dispatcher.onLongPressSwipe(e1.point(), e2.point(), dx, dy)
}
override fun onScrollAfterDoubleTap(e1: MotionEvent, e2: MotionEvent, dx: Float, dy: Float) {
dispatcher.onDoubleTapSwipe(e1.point(), e2.point(), dx, dy)
}
override fun onFling(velocityX: Float, velocityY: Float) {
dispatcher.onFling(velocityX, velocityY)
}
}
/**
* Stock [GestureDetector] only detects the most common gestures. But we need to
* detect some more gestures to provide maximum flexibility to the user.
*
* [GestureDetectorEx] is used to for this purpose. It internally uses stock
* [GestureDetector], and some custom event processing to detect more gestures.
*/
private class GestureDetectorEx(context: Context, val listener: GestureListenerEx) {
/**
* Detected gestures. Some of these come directly from stock [GestureDetector],
* while the following are custom detected:
*
* [onDoubleTapConfirmed]
* To support double-tap-swipe gesture, double-tap is not immediately triggered on
* ACTION_DOWN of second tap. Instead, [doubleTapDetected] flag is set, and when
* final ACTION_UP is received (within timeout), [onDoubleTapConfirmed] is called.
*
* [onMultiFingerTap]
* Maximum number of fingers that went down is tracked in [maxFingerDown]. If
* ACTION_UP is received within a timeout, and more than one finger went down
* without any scrolling, [onMultiFingerTap] is called.
*
* [onLongPressConfirmed]
* Similar to [onDoubleTapConfirmed], to support long-press-swipe, we wait for
* ACTION_UP to confirm long-press.
* Note: [onLongPress] is always called immediately. It enables haptic feedback
* and supports cases where waiting for [onLongPressConfirmed] is not necessary.
*
* [onScrollAfterDoubleTap]
* This is the double-tap-swipe gesture. If scrolling after [doubleTapDetected] flag
* is set, [onScrollAfterDoubleTap] is called.
*
* [onScrollAfterLongPress]
* This is the long-press-swipe gesture. If scrolling after [longPressDetected] flag
* is set, [onScrollAfterLongPress] is called.
*/
interface GestureListenerEx {
fun onSingleTapConfirmed(e: MotionEvent)
fun onDoubleTapConfirmed(e: MotionEvent)
fun onMultiFingerTap(e: MotionEvent, fingerCount: Int)
fun onLongPress(e: MotionEvent)
fun onLongPressConfirmed(e: MotionEvent)
fun onScroll(e1: MotionEvent, e2: MotionEvent, dx: Float, dy: Float)
fun onScrollAfterLongPress(e1: MotionEvent, e2: MotionEvent, dx: Float, dy: Float)
fun onScrollAfterDoubleTap(e1: MotionEvent, e2: MotionEvent, dx: Float, dy: Float)
fun onFling(velocityX: Float, velocityY: Float)
}
/**
* Stock [GestureDetector] has two unwanted behaviours:
* - If long-press or double-tap is detected, scroll events will not be reported anymore.
* - If you don't lift the finger after double-tap, a long-press will be triggered.
*
* Fortunately, [GestureDetector] lets us disable long-press detection, which allows us
* to use a combination of multiple [GestureDetector]s to overcome the restrictions:
*
* - +------------------+
* - +->| [innerDetector1] |
* - | +------------------+
* - | (tap, long-press)
* - +----------------+ event |
* - | [onTouchEvent] |---------+
* - +----------------+ |
* - |
* - | +------------------+ double-tap event +------------------+
* - +->| [innerDetector2] |-------------------->| [innerDetector3] |
* - +------------------+ +------------------+
* - (double-tap) (double-tap-swipe)
*
*/
private val innerDetector1 = GestureDetector(context, InnerListener1())
private val innerDetector2 = GestureDetector(context, InnerListener2()).apply { setIsLongpressEnabled(false) }
private val innerDetector3 = GestureDetector(context, InnerListener3()).apply { setIsLongpressEnabled(false) }
private var longPressDetected = false
private var doubleTapDetected = false
private var scrolling = false
private var maxFingerDown = 0
private var currentDownEvent: MotionEvent? = null
private inner class InnerListener1 : SimpleOnGestureListener() {
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
listener.onSingleTapConfirmed(e)
return true
}
override fun onLongPress(e: MotionEvent) {
if (doubleTapDetected)
return // Ignore long-press triggered during double-tap-swipe
longPressDetected = true
listener.onLongPress(e)
}
override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean {
listener.onFling(velocityX, velocityY)
return true
}
}
private inner class InnerListener2 : SimpleOnGestureListener() {
override fun onDoubleTap(e: MotionEvent): Boolean {
doubleTapDetected = true
return true
}
override fun onDoubleTapEvent(e: MotionEvent) = innerDetector3.onTouchEvent(e)
override fun onScroll(e1: MotionEvent?, e2: MotionEvent, dx: Float, dy: Float) = handleScroll(e1, e2, dx, dy)
}
private inner class InnerListener3 : SimpleOnGestureListener() {
override fun onScroll(e1: MotionEvent?, e2: MotionEvent, dx: Float, dy: Float) = handleScroll(e1, e2, dx, dy)
}
private fun handleScroll(e1: MotionEvent?, e2: MotionEvent, dx: Float, dy: Float): Boolean {
e1 ?: return false
if (!scrolling) {
scrolling = true
// Send first scroll event on initial touch-down point, because GestureDetector
// requires certain amount of finger movement before scroll is triggered, and
// we don't want to 'loose' that small movement.
callOnScroll(e1, e1, 0f, 0f)
}
callOnScroll(e1, e2, -dx, -dy)
return true
}
private fun callOnScroll(e1: MotionEvent, e2: MotionEvent, dx: Float, dy: Float) {
if (doubleTapDetected)
listener.onScrollAfterDoubleTap(e1, e2, dx, dy)
else if (longPressDetected)
listener.onScrollAfterLongPress(e1, e2, dx, dy)
else
listener.onScroll(e1, e2, dx, dy)
}
/**
* Event receiver
*/
fun onTouchEvent(e: MotionEvent): Boolean {
innerDetector1.onTouchEvent(e)
innerDetector2.onTouchEvent(e)
when (e.actionMasked) {
MotionEvent.ACTION_DOWN -> {
maxFingerDown = 1
currentDownEvent = MotionEvent.obtain(e)
}
MotionEvent.ACTION_POINTER_DOWN -> {
maxFingerDown = max(maxFingerDown, e.pointerCount)
}
MotionEvent.ACTION_UP -> {
currentDownEvent?.let { downEvent ->
if (longPressDetected && !doubleTapDetected && !scrolling && maxFingerDown <= 1)
listener.onLongPressConfirmed(downEvent)
if (doubleTapDetected && !longPressDetected && !scrolling && maxFingerDown <= 1)
listener.onDoubleTapConfirmed(downEvent)
val gestureDuration = (e.eventTime - downEvent.eventTime)
if (maxFingerDown > 1 && !scrolling && gestureDuration < ViewConfiguration.getDoubleTapTimeout())
listener.onMultiFingerTap(downEvent, maxFingerDown)
}
reset()
}
MotionEvent.ACTION_CANCEL -> reset()
}
return true
}
private fun reset() {
longPressDetected = false
doubleTapDetected = false
scrolling = false
maxFingerDown = 0
currentDownEvent?.recycle()
currentDownEvent = null
}
}
/**
* Swipe vs Scale detector.
*
* Many two-finger gestures are detected as both swipe & scale gestures because
* [GestureDetector] & [ScaleGestureDetector] work independently. This works
* very well when two-finger swipe pref is set to 'pan' (default value).
* But when the pref is set to 'remote-scroll', this independent detection
* becomes an issue. When user tries to scale, it frequently triggers remote
* scrolling. And when user tries to scroll, it triggers scaling.
*
* This class tries to clearly differentiate between these two gestures.
* It works by tracking the fingers, and calculating the angle between two paths.
* Then [decide] between two gestures by comparing the angle to some thresholds.
*
* This class can mis-detect some gestures because fingers don't always move
* perfectly, but it does provide huge improvement over existing situation.
*/
private inner class SwipeVsScale {
private val enabled = viewModel.pref.input.gesture.swipe2 == "remote-scroll"
private var detecting = false
private var scaleDetected = false
private var swipeDetected = false
private var f1Id = 0 // Finger 1
private var f2Id = 0 // Finger 2
private val f1Start = PointF()
private val f2Start = PointF()
private val f1Current = PointF()
private val f2Current = PointF()
fun onTouchEvent(e: MotionEvent) {
if (!enabled)
return
when (e.actionMasked) {
MotionEvent.ACTION_DOWN,
MotionEvent.ACTION_CANCEL,
MotionEvent.ACTION_UP,
MotionEvent.ACTION_POINTER_UP -> {
detecting = false
scaleDetected = false
swipeDetected = false
}
MotionEvent.ACTION_POINTER_DOWN -> {
detecting = e.pointerCount == 2
if (detecting) {
f1Id = e.getPointerId(0)
f2Id = e.getPointerId(1)
f1Start.set(e.getX(0), e.getY(0))
f2Start.set(e.getX(1), e.getY(1))
f1Current.set(f1Start)
f2Current.set(f2Start)
}
}
MotionEvent.ACTION_MOVE -> {
if (detecting) {
val i1 = e.findPointerIndex(f1Id)
val i2 = e.findPointerIndex(f2Id)
if (i1 != -1 && i2 != -1) {
f1Current.set(e.getX(i1), e.getY(i1))
f2Current.set(e.getX(i2), e.getY(i2))
}
}
}
}
}
fun shouldScale(): Boolean {
decide()
return !enabled || (detecting && scaleDetected)
}
fun shouldSwipe(): Boolean {
decide()
return !enabled || (detecting && swipeDetected)
}
/**
* Decides if gesture can be considered a swipe/scale
*/
private fun decide() {
if (!detecting)
return
val t1 = theta(f1Start, f1Current)
val t2 = theta(f2Start, f2Current)
val diff = abs(t1 - t2)
scaleDetected = diff > 45
swipeDetected = diff < 30
}
/**
* Returns the angle made by line [p1]->[p2] with the positive x-axis.
* Returned angle will be in range [0, 360]
*/
private fun theta(p1: PointF, p2: PointF): Double {
val theta = atan2(p2.y - p1.y, p2.x - p1.x)
val degree = (theta / PI) * 180
return (degree + 360) % 360 // Map [-180, 180] to [0, 360]
}
}
}

View File

@@ -0,0 +1,118 @@
/*
* Copyright (c) 2021 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.vnc
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import androidx.databinding.BindingAdapter
import com.example.tiny_computer.databinding.VirtualKeysBinding
/**
* Virtual keys allow the user to input keys which are not normally found on
* keyboards but can be useful for controlling remote server.
*
* This class manages the inflation & visibility of virtual keys.
*/
class VirtualKeys(activity: VncActivity) {
private val pref = activity.viewModel.pref.input
private val keyHandler = activity.keyHandler
private val stub = activity.binding.virtualKeysStub
private var openedWithKb = false
val container: View? get() = stub.root
fun show() {
init()
container?.visibility = View.VISIBLE
}
fun hide() {
container?.visibility = View.GONE
openedWithKb = false //Reset flag
}
fun onKeyboardOpen() {
if (pref.vkOpenWithKeyboard && container?.visibility != View.VISIBLE) {
show()
openedWithKb = true
}
}
fun onKeyboardClose() {
if (openedWithKb) {
hide()
openedWithKb = false
}
}
fun releaseMetaKeys() {
val binding = stub.binding as? VirtualKeysBinding
binding?.apply {
superBtn.isChecked = false
shiftBtn.isChecked = false
ctrlBtn.isChecked = false
altBtn.isChecked = false
}
}
private fun init() {
if (stub.isInflated)
return
stub.viewStub?.inflate()
val binding = stub.binding as VirtualKeysBinding
binding.h = keyHandler
binding.showAll = pref.vkShowAll
binding.hideBtn.setOnClickListener { hide() }
}
}
/**
* When a View is touched, we schedule a callback to to simulate a click.
* As long as finger stays on the view, we keep repeating this callback.
*
* Another option here is to send VNC KeyEvent(down) on [MotionEvent.ACTION_DOWN]
* and then send VNC KeyEvent(up) on [MotionEvent.ACTION_UP].
*/
@BindingAdapter("isRepeatable")
fun repeatableKeyBinding(keyView: View, repeatable: Boolean) {
if (!repeatable)
return
keyView.setOnTouchListener(object : View.OnTouchListener {
private var doRepeat = false
private fun repeat(v: View) {
if (doRepeat) {
v.performClick()
v.postDelayed({ repeat(v) }, ViewConfiguration.getKeyRepeatDelay().toLong())
}
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouch(v: View, event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
doRepeat = true
v.postDelayed({ repeat(v) }, ViewConfiguration.getKeyRepeatTimeout().toLong())
}
MotionEvent.ACTION_POINTER_DOWN,
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> {
doRepeat = false
}
}
return false
}
})
}

View File

@@ -0,0 +1,400 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.vnc
import android.app.Activity
import android.app.PictureInPictureParams
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.os.SystemClock
import android.util.Log
import android.util.Rational
import android.view.InputDevice
import android.view.KeyEvent
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.activity.viewModels
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.core.os.BundleCompat
import androidx.core.view.isVisible
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.example.tiny_computer.R
import com.example.tiny_computer.databinding.ActivityVncBinding
import com.gaurav.avnc.model.ServerProfile
import com.gaurav.avnc.util.DeviceAuthPrompt
import com.gaurav.avnc.util.SamsungDex
import com.gaurav.avnc.viewmodel.VncViewModel
import com.gaurav.avnc.viewmodel.VncViewModel.State.Companion.isConnected
import com.gaurav.avnc.viewmodel.VncViewModel.State.Companion.isDisconnected
import com.gaurav.avnc.vnc.VncUri
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import java.lang.ref.WeakReference
/********** [VncActivity] startup helpers *********************************/
private const val PROFILE_KEY = "com.gaurav.avnc.server_profile"
private const val FRAME_STATE_KEY = "com.gaurav.avnc.frame_state"
fun createVncIntent(context: Context, profile: ServerProfile): Intent {
return Intent(context, VncActivity::class.java).apply {
putExtra(PROFILE_KEY, profile)
}
}
fun startVncActivity(source: Activity, profile: ServerProfile) {
source.startActivity(createVncIntent(source, profile))
}
fun startVncActivity(source: Activity, uri: VncUri) {
startVncActivity(source, uri.toServerProfile())
}
@Parcelize
private data class SavedFrameState(val frameX: Float, val frameY: Float, val zoom1: Float, val zoom2: Float) : Parcelable
private fun startVncActivity(source: Activity, profile: ServerProfile, frameState: SavedFrameState) {
source.startActivity(createVncIntent(source, profile).also { it.putExtra(FRAME_STATE_KEY, frameState) })
}
/**************************************************************************/
/**
* This activity handles the connection to a VNC server.
*/
class VncActivity : AppCompatActivity() {
lateinit var viewModel: VncViewModel
lateinit var binding: ActivityVncBinding
private val dispatcher by lazy { Dispatcher(this) }
val touchHandler by lazy { TouchHandler(viewModel, dispatcher) }
val keyHandler by lazy { KeyHandler(dispatcher, viewModel.profile.fLegacyKeySym, viewModel.pref) }
val virtualKeys by lazy { VirtualKeys(this) }
private val serverUnlockPrompt = DeviceAuthPrompt(this)
private val layoutManager by lazy { LayoutManager(this) }
private val toolbar by lazy { Toolbar(this, dispatcher) }
private var restoredFromBundle = false
private var wasConnectedWhenStopped = false
private var onStartTime = 0L
override fun onCreate(savedInstanceState: Bundle?) {
DeviceAuthPrompt.applyFingerprintDialogFix(supportFragmentManager)
super.onCreate(savedInstanceState)
if (!loadViewModel(savedInstanceState)) {
finish()
return
}
viewModel.initConnection()
//Main UI
binding = DataBindingUtil.setContentView(this, R.layout.activity_vnc)
binding.viewModel = viewModel
binding.lifecycleOwner = this
binding.frameView.initialize(this)
viewModel.frameViewRef = WeakReference(binding.frameView)
toolbar.initialize()
setupLayout()
setupServerUnlock()
//Observers
binding.reconnectBtn.setOnClickListener { retryConnection() }
viewModel.loginInfoRequest.observe(this) { showLoginDialog() }
viewModel.sshHostKeyVerifyRequest.observe(this) { showHostKeyDialog() }
viewModel.state.observe(this) { onClientStateChanged(it) }
savedInstanceState?.let {
restoredFromBundle = true
wasConnectedWhenStopped = it.getBoolean("wasConnectedWhenStopped")
}
}
override fun onStart() {
super.onStart()
binding.frameView.onResume()
viewModel.resumeFrameBufferUpdates()
onStartTime = SystemClock.uptimeMillis()
// Refresh framebuffer on activity restart:
// - It forces read/write on the socket. This allows us to verify the socket, which might have
// been closed by the server while app process was frozen in background
// - It also attempts to fix some unusual cases of old updates requests being lost while AVNC
// was frozen by the system
if (wasConnectedWhenStopped) viewModel.refreshFrameBuffer()
}
override fun onStop() {
super.onStop()
virtualKeys.releaseMetaKeys()
binding.frameView.onPause()
viewModel.pauseFrameBufferUpdates()
wasConnectedWhenStopped = viewModel.state.value.isConnected
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putParcelable(PROFILE_KEY, viewModel.profile)
outState.putBoolean("wasConnectedWhenStopped", wasConnectedWhenStopped || viewModel.state.value.isConnected)
}
private fun loadViewModel(savedState: Bundle?): Boolean {
@Suppress("DEPRECATION")
val profile = savedState?.getParcelable(PROFILE_KEY)
?: intent.getParcelableExtra<ServerProfile?>(PROFILE_KEY)
if (profile == null) {
Toast.makeText(this, "Error: Missing Server Info", Toast.LENGTH_LONG).show()
return false
}
val factory = viewModelFactory { initializer { VncViewModel(profile, application) } }
viewModel = viewModels<VncViewModel> { factory }.value
return true
}
private fun retryConnection(seamless: Boolean = false) {
//We simply create a new activity to force creation of new ViewModel
//which effectively restarts the connection.
if (!isFinishing) {
val savedFrameState = viewModel.frameState.let {
SavedFrameState(frameX = it.frameX, frameY = it.frameY, zoom1 = it.zoomScale1, zoom2 = it.zoomScale2)
}
startVncActivity(this, viewModel.profile, savedFrameState)
if (seamless) {
@Suppress("DEPRECATION")
overridePendingTransition(0, 0)
}
finish()
}
}
private fun setupServerUnlock() {
serverUnlockPrompt.init(
onSuccess = { viewModel.serverUnlockRequest.offerResponse(true) },
onFail = { viewModel.serverUnlockRequest.offerResponse(false) }
)
viewModel.serverUnlockRequest.observe(this) {
if (serverUnlockPrompt.canLaunch())
serverUnlockPrompt.launch(getString(R.string.title_unlock_dialog))
else
viewModel.serverUnlockRequest.offerResponse(true)
}
}
private fun showLoginDialog() {
LoginFragment().show(supportFragmentManager, "LoginDialog")
}
private fun showHostKeyDialog() {
HostKeyFragment().show(supportFragmentManager, "HostKeyFragment")
}
fun showKeyboard() {
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
binding.frameView.requestFocus()
imm.showSoftInput(binding.frameView, 0)
virtualKeys.onKeyboardOpen()
}
private fun onClientStateChanged(newState: VncViewModel.State) {
val isConnected = newState.isConnected
binding.frameView.isVisible = isConnected
binding.frameView.keepScreenOn = isConnected && viewModel.pref.viewer.keepScreenOn
SamsungDex.setMetaKeyCapture(this, isConnected)
layoutManager.onConnectionStateChanged()
updateStatusContainerVisibility(isConnected)
autoReconnect(newState)
if (isConnected && !restoredFromBundle) {
incrementUseCount()
restoreFrameState()
}
}
private fun incrementUseCount() {
viewModel.profile.useCount += 1
viewModel.saveProfile()
}
private fun updateStatusContainerVisibility(isConnected: Boolean) {
binding.statusContainer.isVisible = true
binding.statusContainer
.animate()
.alpha(if (isConnected) 0f else 1f)
.withEndAction { binding.statusContainer.isVisible = !isConnected }
}
private fun restoreFrameState() {
intent.extras?.let { extras ->
BundleCompat.getParcelable(extras, FRAME_STATE_KEY, SavedFrameState::class.java)?.let {
viewModel.setZoom(it.zoom1, it.zoom2)
viewModel.panFrame(it.frameX, it.frameY)
}
}
}
private var autoReconnecting = false
private fun autoReconnect(state: VncViewModel.State) {
if (!state.isDisconnected)
return
// If disconnected when coming back from background, try to reconnect immediately
if (wasConnectedWhenStopped && (SystemClock.uptimeMillis() - onStartTime) in 0..2000) {
Log.d(javaClass.simpleName, "Disconnected while in background, reconnecting ...")
retryConnection(true)
}
if (autoReconnecting || !viewModel.pref.server.autoReconnect)
return
autoReconnecting = true
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
val timeout = 5 //seconds, must be >1
repeat(timeout) {
binding.autoReconnectProgress.setProgressCompat((100 * it) / (timeout - 1), true)
delay(1000)
if (it >= (timeout - 1))
retryConnection()
}
}
}
}
/************************************************************************************
* Layout handling.
************************************************************************************/
private fun setupLayout() {
setupOrientation()
layoutManager.initialize()
if (Build.VERSION.SDK_INT >= 28 && viewModel.pref.viewer.drawBehindCutout) {
window.attributes = window.attributes.apply {
layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
}
}
private fun setupOrientation() {
val choice = viewModel.profile.screenOrientation.let {
if (it != "auto") it else viewModel.pref.viewer.orientation
}
requestedOrientation = when (choice) {
"portrait" -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
"landscape" -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
else -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
layoutManager.onWindowFocusChanged(hasFocus)
if (hasFocus) viewModel.sendClipboardText()
}
/************************************************************************************
* Picture-in-Picture support
************************************************************************************/
override fun onUserLeaveHint() {
super.onUserLeaveHint()
enterPiPMode()
}
@RequiresApi(26)
override fun onPictureInPictureModeChanged(inPiP: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(inPiP, newConfig)
if (inPiP) {
toolbar.close()
viewModel.resetZoom()
virtualKeys.hide()
}
}
private fun enterPiPMode() {
val canEnter = viewModel.pref.viewer.pipEnabled && viewModel.client.connected
if (canEnter && Build.VERSION.SDK_INT >= 26) {
var w = viewModel.frameState.fbWidth
var h = viewModel.frameState.fbHeight
if (w <= 0 || h <= 0)
return
// Android require aspect ratio to be less than 2.39
w = w.coerceIn(1f, 2.3f * h)
h = h.coerceIn(1f, 2.3f * w)
val aspectRatio = Rational(w.toInt(), h.toInt())
val param = PictureInPictureParams.Builder().setAspectRatio(aspectRatio).build()
try {
enterPictureInPictureMode(param)
} catch (e: IllegalStateException) {
Log.w(javaClass.simpleName, "Cannot enter PiP mode", e)
}
}
}
/************************************************************************************
* Input
************************************************************************************/
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
return keyHandler.onKeyEvent(event) || workarounds(event) || super.onKeyDown(keyCode, event)
}
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
return keyHandler.onKeyEvent(event) || workarounds(event) || super.onKeyUp(keyCode, event)
}
override fun onKeyMultiple(keyCode: Int, repeatCount: Int, event: KeyEvent): Boolean {
return keyHandler.onKeyEvent(event) || super.onKeyMultiple(keyCode, repeatCount, event)
}
private fun workarounds(keyEvent: KeyEvent): Boolean {
//It seems that some device manufacturers are hell-bent on making developers'
//life miserable. In their infinite wisdom, they decided that Android apps don't
//need Mouse right-click events. It is hardcoded to act as back-press, without
//giving apps a chance to handle it. For better or worse, they set the 'source'
//for such key events to Mouse, enabling the following workarounds.
if (keyEvent.keyCode == KeyEvent.KEYCODE_BACK &&
InputDevice.getDevice(keyEvent.deviceId)?.supportsSource(InputDevice.SOURCE_MOUSE) == true &&
viewModel.pref.input.interceptMouseBack) {
if (keyEvent.action == KeyEvent.ACTION_DOWN)
touchHandler.onMouseBack()
return true
}
return false
}
}

View File

@@ -0,0 +1,108 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.vnc.gl
import android.opengl.GLES20.GL_FLOAT
import android.opengl.GLES20.GL_TRIANGLES
import android.opengl.GLES20.glDrawArrays
import android.opengl.GLES20.glEnableVertexAttribArray
import android.opengl.GLES20.glVertexAttribPointer
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
/**
* Frame is represented as two triangles:
*
* [0, fbHeight] +-----------+ [fbWidth, fbHeight]
* | /|
* | / |
* | / |
* | / |
* [0, 0] +-----------+ [fbWidth, 0]
*
* Frame texture is mapped onto these triangles.
*/
class Frame {
companion object {
const val FLOAT_SIZE = 4
const val TRIANGLE_COMPONENT = 2 //[x,y]
const val TEXTURE_COMPONENT = 2 //[x,y]
const val STRIDE = (TEXTURE_COMPONENT + TEXTURE_COMPONENT) * FLOAT_SIZE
}
private var fbWidth = 0F
private var fbHeight = 0F
private var vertexData: FloatArray
private var vertexBuffer: FloatBuffer
init {
vertexData = generateVertexData()
vertexBuffer = ByteBuffer.allocateDirect(vertexData.size * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.put(vertexData)
}
/**
* Generates vertex data for frame.
*/
private fun generateVertexData(): FloatArray {
//Note: Textures have their own coordinate system. [0,0] represents bottom-left
// and [1,1] represents upper-right corner.
return floatArrayOf(
//@formatter:off
//Triangle coordinates //Texture coordinates
0F, 0F, 0F, 0F,
fbWidth, 0F, 1F, 0F,
fbWidth, fbHeight, 1F, 1F,
0F, 0F, 0F, 0F,
fbWidth, fbHeight, 1F, 1F,
0F, fbHeight, 0F, 1F
//@formatter:on
)
}
fun bind(program: FrameProgram) {
setVertexAttributePointer(0, program.aPositionLocation, TRIANGLE_COMPONENT, STRIDE)
setVertexAttributePointer(TRIANGLE_COMPONENT, program.aTextureCoordinatesLocation, TEXTURE_COMPONENT, STRIDE)
}
private fun setVertexAttributePointer(dataOffset: Int, attributeLocation: Int, componentCount: Int, stride: Int) {
vertexBuffer.position(dataOffset)
glVertexAttribPointer(attributeLocation, componentCount, GL_FLOAT, false, stride, vertexBuffer)
glEnableVertexAttribArray(attributeLocation)
vertexBuffer.position(0)
}
/**
* Should be called whenever the size of framebuffer is changed.
* This size will be used to calculate frame vertices.
*/
fun updateFbSize(width: Float, height: Float) {
if (width == fbWidth && height == fbHeight)
return //Nothing to do
fbWidth = width
fbHeight = height
vertexData = generateVertexData()
vertexBuffer.position(0)
vertexBuffer.put(vertexData)
}
fun draw() {
glDrawArrays(GL_TRIANGLES, 0, 6)
}
}

View File

@@ -0,0 +1,91 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.vnc.gl
import android.opengl.GLES20.GL_CLAMP_TO_EDGE
import android.opengl.GLES20.GL_LINEAR
import android.opengl.GLES20.GL_TEXTURE0
import android.opengl.GLES20.GL_TEXTURE_2D
import android.opengl.GLES20.GL_TEXTURE_MAG_FILTER
import android.opengl.GLES20.GL_TEXTURE_MIN_FILTER
import android.opengl.GLES20.GL_TEXTURE_WRAP_S
import android.opengl.GLES20.GL_TEXTURE_WRAP_T
import android.opengl.GLES20.glActiveTexture
import android.opengl.GLES20.glBindTexture
import android.opengl.GLES20.glGenTextures
import android.opengl.GLES20.glGetAttribLocation
import android.opengl.GLES20.glGetUniformLocation
import android.opengl.GLES20.glTexParameteri
import android.opengl.GLES20.glUniform1i
import android.opengl.GLES20.glUniformMatrix4fv
import android.opengl.GLES20.glUseProgram
import android.util.Log
import com.example.tiny_computer.BuildConfig
/**
* Represents the GL program used for Frame rendering.
*
* NOTE: It must be instantiated in an OpenGL context.
*/
class FrameProgram {
companion object {
// Attribute constants
const val A_POSITION = "a_Position"
const val A_TEXTURE_COORDINATES = "a_TextureCoordinates"
// Uniform constants
const val U_PROJECTION = "u_Projection"
const val U_TEXTURE_UNIT = "u_TextureUnit"
}
val program = ShaderCompiler.buildProgram(Shaders.VERTEX_SHADER, Shaders.FRAGMENT_SHADER)
val aPositionLocation = glGetAttribLocation(program, A_POSITION)
val aTextureCoordinatesLocation = glGetAttribLocation(program, A_TEXTURE_COORDINATES)
val uProjectionLocation = glGetUniformLocation(program, U_PROJECTION)
val uTexUnitLocation = glGetUniformLocation(program, U_TEXTURE_UNIT)
val textureId = createTexture()
var validated = false
fun setUniforms(projectionMatrix: FloatArray) {
glUniformMatrix4fv(uProjectionLocation, 1, false, projectionMatrix, 0)
glActiveTexture(GL_TEXTURE0)
glBindTexture(GL_TEXTURE_2D, textureId)
glUniform1i(uTexUnitLocation, 0)
}
private fun createTexture(): Int {
val texturesObjects = intArrayOf(0)
glGenTextures(1, texturesObjects, 0)
if (texturesObjects[0] == 0) {
Log.e("Texture", "Could not generate texture.")
return 0
}
glBindTexture(GL_TEXTURE_2D, texturesObjects[0])
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
glBindTexture(GL_TEXTURE_2D, 0)
return texturesObjects[0]
}
fun validate() {
if (BuildConfig.DEBUG && !validated) {
ShaderCompiler.validateProgram(program)
validated = true
}
}
fun useProgram() {
glUseProgram(program)
}
}

View File

@@ -0,0 +1,101 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.vnc.gl
import android.opengl.GLES20.GL_COLOR_BUFFER_BIT
import android.opengl.GLES20.glClear
import android.opengl.GLES20.glClearColor
import android.opengl.GLES20.glViewport
import android.opengl.GLSurfaceView
import android.opengl.Matrix
import com.gaurav.avnc.viewmodel.VncViewModel
import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.opengles.GL10
/**
* Frame renderer.
*/
class Renderer(val viewModel: VncViewModel) : GLSurfaceView.Renderer {
private val projectionMatrix = FloatArray(16)
private val hideCursor = viewModel.pref.input.hideRemoteCursor
private lateinit var program: FrameProgram
private lateinit var frame: Frame
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
glClearColor(0f, 0f, 0f, 1f)
frame = Frame()
program = FrameProgram()
}
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
glViewport(0, 0, width, height)
}
/**
* Draws frame on screen according to current state.
*
* Y-axis of coordinate system used by OpenGL is in opposite direction (upwards)
* relative to Y-axis in screen coordinates (downwards).
*
* To compensate for this, we invert the Y-coordinates of drawn frame. This is
* achieved by:
* 1. Using clipping region [-height, 0] instead of [0, height] for Y-axis
* 2. Inverting sign of Y-axis position
* 3. Inverting sign of Y-axis scale (to flip the frame)
*
* So the frame is drawn as follows in OpenGL:
*
* +Y ^
* |
* |[0,0] [width,0]
* -X <-----|---------------------+-------------> +X
* | |
* | |
* | Frame |
* | |
* | |
* +---------------------+
* |[0,-height] [width,-height]
* |
* -Y v
*
* Doing this inversion here allows rest of the code to not worry about difference
* in Y-axis direction.
*
*/
override fun onDrawFrame(gl: GL10?) {
glClear(GL_COLOR_BUFFER_BIT)
if (!viewModel.client.connected)
return
val state = viewModel.frameState.getSnapshot()
if (state.vpWidth == 0f || state.vpHeight == 0f)
return
Matrix.setIdentityM(projectionMatrix, 0)
Matrix.orthoM(projectionMatrix, 0, 0f, state.vpWidth, -state.vpHeight, 0f, -1f, 1f)
Matrix.translateM(projectionMatrix, 0, state.frameX, -state.frameY, 0f)
Matrix.scaleM(projectionMatrix, 0, state.scale, -state.scale, 1f)
program.useProgram()
program.setUniforms(projectionMatrix)
viewModel.client.uploadFrameTexture()
if (!hideCursor) viewModel.client.uploadCursor()
frame.updateFbSize(state.fbWidth, state.fbHeight)
frame.bind(program)
program.validate()
frame.draw()
}
}

View File

@@ -0,0 +1,97 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.vnc.gl
import android.opengl.GLES20.GL_COMPILE_STATUS
import android.opengl.GLES20.GL_FRAGMENT_SHADER
import android.opengl.GLES20.GL_LINK_STATUS
import android.opengl.GLES20.GL_VALIDATE_STATUS
import android.opengl.GLES20.GL_VERTEX_SHADER
import android.opengl.GLES20.glAttachShader
import android.opengl.GLES20.glCompileShader
import android.opengl.GLES20.glCreateProgram
import android.opengl.GLES20.glCreateShader
import android.opengl.GLES20.glDeleteProgram
import android.opengl.GLES20.glDeleteShader
import android.opengl.GLES20.glGetProgramInfoLog
import android.opengl.GLES20.glGetProgramiv
import android.opengl.GLES20.glGetShaderInfoLog
import android.opengl.GLES20.glGetShaderiv
import android.opengl.GLES20.glLinkProgram
import android.opengl.GLES20.glShaderSource
import android.opengl.GLES20.glValidateProgram
import android.util.Log
object ShaderCompiler {
private const val TAG = "ShaderCompiler"
fun compileShader(type: Int, shaderText: String): Int {
val shaderObjectId = glCreateShader(type)
if (shaderObjectId == 0) {
Log.e(TAG, "Could not create shader object")
return 0
}
glShaderSource(shaderObjectId, shaderText)
glCompileShader(shaderObjectId)
Log.d(TAG, glGetShaderInfoLog(shaderObjectId))
val status = intArrayOf(0)
glGetShaderiv(shaderObjectId, GL_COMPILE_STATUS, status, 0)
if (status[0] == 0) {
Log.e(TAG, "Shader compilation failed")
glDeleteShader(shaderObjectId)
return 0
}
return shaderObjectId
}
fun linkProgram(vertexShaderId: Int, fragmentShaderId: Int): Int {
val programId = glCreateProgram()
if (programId == 0) {
Log.e(TAG, "Could not create program object")
return 0
}
glAttachShader(programId, vertexShaderId)
glAttachShader(programId, fragmentShaderId)
glLinkProgram(programId)
Log.d(TAG, glGetProgramInfoLog(programId))
val status = intArrayOf(0)
glGetProgramiv(programId, GL_LINK_STATUS, status, 0)
if (status[0] == 0) {
Log.e(TAG, "Program linking failed")
glDeleteProgram(programId)
return 0
}
return programId
}
fun validateProgram(programId: Int): Boolean {
glValidateProgram(programId)
val status = intArrayOf(0)
glGetProgramiv(programId, GL_VALIDATE_STATUS, status, 0)
Log.d(TAG, "Program [" + programId + "] validation result: " + status[0])
Log.d(TAG, "Program [" + programId + "] validation log: " + glGetProgramInfoLog(programId))
return status[0] != 0
}
fun buildProgram(vertexShaderSource: String, fragmentShaderSource: String): Int {
val vertexShaderId = compileShader(GL_VERTEX_SHADER, vertexShaderSource)
val fragmentShaderId = compileShader(GL_FRAGMENT_SHADER, fragmentShaderSource)
if (vertexShaderId == 0 || fragmentShaderId == 0)
return 0
return linkProgram(vertexShaderId, fragmentShaderId)
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.vnc.gl
/**
* Shaders used for rendering framebuffer
*/
object Shaders {
//language=GLSL
const val VERTEX_SHADER = """
uniform mat4 u_Projection;
attribute vec2 a_Position;
attribute vec2 a_TextureCoordinates;
varying vec2 v_TextureCoordinates;
void main()
{
v_TextureCoordinates = a_TextureCoordinates;
gl_Position = u_Projection * vec4(a_Position, 0, 1);
}"""
//language=GLSL
const val FRAGMENT_SHADER = """
precision mediump float;
uniform sampler2D u_TextureUnit;
varying vec2 v_TextureCoordinates;
void main()
{
gl_FragColor = texture2D(u_TextureUnit, v_TextureCoordinates);
}"""
}

View File

@@ -0,0 +1,156 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.util
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.lifecycle.LiveData
import androidx.preference.PreferenceManager
/**
* Utility class for accessing app preferences
*/
class AppPreferences(context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
inner class UI {
val theme = LivePref("theme", "system")
}
inner class Viewer {
val orientation; get() = prefs.getString("viewer_orientation", "landscape")
val fullscreen; get() = prefs.getBoolean("fullscreen_display", true)
val pipEnabled; get() = prefs.getBoolean("pip_enabled", false)
val drawBehindCutout; get() = prefs.getBoolean("viewer_draw_behind_cutout", false)
val keepScreenOn; get() = prefs.getBoolean("keep_screen_on", true)
val toolbarAlignment; get() = prefs.getString("toolbar_alignment", "start")
val toolbarOpenWithSwipe; get() = prefs.getBoolean("toolbar_open_with_swipe", true)
val zoomMax; get() = prefs.getInt("zoom_max", 500) / 100F
val zoomMin; get() = prefs.getInt("zoom_min", 50) / 100F
val perOrientationZoom; get() = prefs.getBoolean("per_orientation_zoom", true)
val toolbarShowGestureStyleToggle; get() = prefs.getBoolean("toolbar_show_gesture_style_toggle", true)
}
inner class Gesture {
val style; get() = prefs.getString("gesture_style", "touchscreen")!!
val tap1 = "left-click" //Preference UI was removed
val tap2; get() = prefs.getString("gesture_tap2", "open-keyboard")!!
val doubleTap; get() = prefs.getString("gesture_double_tap", "double-click")!!
val longPress; get() = prefs.getString("gesture_long_press", "right-click")!!
val swipe1; get() = prefs.getString("gesture_swipe1", "pan")!!
val swipe2; get() = prefs.getString("gesture_swipe2", "remote-scroll")!!
val doubleTapSwipe; get() = prefs.getString("gesture_double_tap_swipe", "remote-drag")!!
val longPressSwipe; get() = prefs.getString("gesture_long_press_swipe", "none")!!
val longPressSwipeEnabled; get() = (longPressSwipe != "none")
val swipeSensitivity; get() = prefs.getInt("gesture_swipe_sensitivity", 10) / 10f
val invertVerticalScrolling; get() = prefs.getBoolean("invert_vertical_scrolling", false)
}
inner class Input {
val gesture = Gesture()
val vkOpenWithKeyboard; get() = prefs.getBoolean("vk_open_with_keyboard", false)
val vkShowAll; get() = prefs.getBoolean("vk_show_all", true)
val mousePassthrough; get() = prefs.getBoolean("mouse_passthrough", true)
val hideLocalCursor; get() = prefs.getBoolean("hide_local_cursor", true)
val hideRemoteCursor; get() = prefs.getBoolean("hide_remote_cursor", false)
val mouseBack; get() = prefs.getString("mouse_back", "right-click")!!
val interceptMouseBack; get() = mouseBack != "default"
val kmLanguageSwitchToSuper; get() = prefs.getBoolean("km_language_switch_to_super", false)
val kmRightAltToSuper; get() = prefs.getBoolean("km_right_alt_to_super", false)
val kmBackToEscape; get() = prefs.getBoolean("km_back_to_escape", false)
}
inner class Server {
val clipboardSync; get() = prefs.getBoolean("clipboard_sync", true)
val autoReconnect; get() = prefs.getBoolean("auto_reconnect", false)
}
/**
* These are used for one-time features/tips.
* These are not exposed to user.
*/
inner class RunInfo {
var hasConnectedSuccessfully: Boolean
get() = prefs.getBoolean("run_info_has_connected_successfully", false)
set(value) = prefs.edit { putBoolean("run_info_has_connected_successfully", value) }
var hasShownV2WelcomeMsg
get() = prefs.getBoolean("run_info_has_shown_v2_welcome_msg", false)
set(value) = prefs.edit { putBoolean("run_info_has_shown_v2_welcome_msg", value) }
}
val ui = UI()
val viewer = Viewer()
val input = Input()
val server = Server()
val runInfo = RunInfo()
/**
* For some preference changes we want to provide live feedback to user.
* This class is used for such scenarios. Based on [LiveData], it notifies
* the observers whenever the value of given preference is changed.
*
* For now, each [LivePref] creates a separate change listener, but if
* number of [LivePref]s grow, we can optimize by sharing a single listener.
*/
inner class LivePref<T>(val key: String, private val defValue: T) : LiveData<T>() {
private val prefChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, changedKey ->
if (key == changedKey)
updateValue()
}
private var initialized = false
override fun onActive() {
if (!initialized) {
initialized = true
updateValue()
prefs.registerOnSharedPreferenceChangeListener(prefChangeListener)
}
}
private fun updateValue() {
@Suppress("UNCHECKED_CAST")
when (defValue) {
is Boolean -> value = prefs.getBoolean(key, defValue) as T
is String -> value = prefs.getString(key, defValue) as T
is Int -> value = prefs.getInt(key, defValue) as T
is Long -> value = prefs.getLong(key, defValue) as T
is Float -> value = prefs.getFloat(key, defValue) as T
}
}
}
/****************************** Migrations *******************************/
init {
if (!prefs.getBoolean("gesture_direct_touch", true)) prefs.edit {
remove("gesture_direct_touch")
putString("gesture_style", "touchpad")
}
if (!prefs.getBoolean("natural_scrolling", true)) prefs.edit {
remove("natural_scrolling")
putBoolean("invert_vertical_scrolling", true)
}
prefs.getString("gesture_drag", null)?.let {
prefs.edit {
remove("gesture_drag")
putString("gesture_long_press_swipe", it)
}
}
}
}

View File

@@ -0,0 +1,101 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.util
import android.content.Context
import android.util.Log
import android.view.View
import android.widget.AdapterView
import android.widget.SimpleAdapter
import android.widget.Spinner
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.databinding.BindingAdapter
import androidx.databinding.InverseBindingAdapter
import androidx.databinding.InverseBindingListener
@BindingAdapter("isVisible")
fun visibilityAdapter(view: View, isVisible: Boolean) {
view.isVisible = isVisible
}
@BindingAdapter("isInvisible")
fun invisibilityAdapter(view: View, isInvisible: Boolean) {
view.isInvisible = isInvisible
}
/**************************************************************************************************
* Spinner value binding
* These allows the Spinner to be populated using data-binding in XMl layouts.
* There are 4 attributes used for this:
*
* app:value => Reference to the backing field which actually stores the selected value.
* This can be used with two-way binding to update the backing field whenever
* selection changes in Spinner.
* For now, Only String & Int types are supported for backing field.
*
* app:values => String Array, holds possible values of app:value
*
* app:valueLabels => String Array, Optional. If provided, these labels will be shown in the UI
* instead of raw app:values
*
* app:valueDescriptions => String Array, Optional. Short descriptions of values, shown in Spinner
* Popup below each label.
*
*
*************************************************************************************************/
private fun prepareEntryMap(labels: Array<String>, descriptions: Array<String>?) = mutableListOf<Map<String, *>>().apply {
for (i in labels.indices)
add(mapOf("label" to labels[i], "description" to descriptions?.getOrNull(i)))
}
private class SpinnerAdapter(context: Context, labels: Array<String>, descriptions: Array<String>?, val values: Array<String>)
: SimpleAdapter(context, prepareEntryMap(labels, descriptions), android.R.layout.simple_list_item_1,
arrayOf("label", "description"), intArrayOf(android.R.id.text1, android.R.id.text2))
@BindingAdapter("valueLabels", "valueDescriptions", "values", "value", requireAll = false)
fun spinnerValueAdapter(spinner: Spinner, labels: Array<String>?, descriptions: Array<String>?, values: Array<String>?, value: Any?) {
if (spinner.adapter == null && values != null) {
check(labels == null || labels.size == values.size)
check(descriptions == null || descriptions.size == values.size)
val adapter = SpinnerAdapter(spinner.context, labels ?: values, descriptions, values)
if (descriptions != null)
adapter.setDropDownViewResource(android.R.layout.simple_list_item_2)
spinner.adapter = adapter
}
if (spinner.adapter != null && value != null) {
val adapter = spinner.adapter as SpinnerAdapter
val index = adapter.values.indexOf(value.toString())
if (index != -1)
spinner.setSelection(index)
else
Log.e("SpinnerValue", "value: $value not found in adapter values: ${adapter.values}")
}
}
@BindingAdapter("valueAttrChanged")
fun spinnerValueChangedAdapter(spinner: Spinner, valueChange: InverseBindingListener) {
spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(p: AdapterView<*>, v: View?, pos: Int, id: Long) = valueChange.onChange()
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
}
@InverseBindingAdapter(attribute = "value")
fun spinnerValueInverseAdapter(spinner: Spinner): String {
return (spinner.adapter as SpinnerAdapter).values[spinner.selectedItemPosition]
}
@InverseBindingAdapter(attribute = "value")
fun spinnerValueInverseAdapterInt(spinner: Spinner) = spinnerValueInverseAdapter(spinner).toInt()

View File

@@ -0,0 +1,72 @@
/*
* Copyright (c) 2024 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.util
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Clipboard access is slightly more complex in AVNC, because clip data can be
* unusually large. It includes stuff coming from server, and app logs.
* As clipboard access involves binder IPC, it can lead to ANR issues. So we
* use a background thread for accessing clipboard.
*/
/**
* Puts given text on the clipboard.
*/
suspend fun setClipboardText(context: Context, text: String): Boolean {
var success = false
try {
getClipboardManager(context)?.let {
withContext(Dispatchers.IO) {
it.setPrimaryClip(ClipData.newPlainText(null, text))
success = true
}
}
} catch (e: CancellationException) {
throw e
} catch (t: Throwable) {
Log.e("ClipboardUtil", "Could not copy text to clipboard.", t)
}
return success
}
/**
* Returns current clipboard text.
*/
suspend fun getClipboardText(context: Context): String? {
var result: String? = null
try {
getClipboardManager(context)?.let {
withContext(Dispatchers.IO) {
result = it.primaryClip?.getItemAt(0)?.text?.toString()
}
}
} catch (e: CancellationException) {
throw e
} catch (t: Throwable) {
Log.e("ClipboardUtil", "Could not retrieve text from clipboard.", t)
}
return result
}
/**
* [ClipboardManager] has to be created on a thread where Looper has been initialized.
*/
private suspend fun getClipboardManager(context: Context) = withContext(Dispatchers.Main.immediate) {
ContextCompat.getSystemService(context, ClipboardManager::class.java)
}

View File

@@ -0,0 +1,82 @@
/*
* Copyright (c) 2021 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.util
import android.net.Uri
import android.os.Build
import com.example.tiny_computer.BuildConfig
/**
* Utilities to aid in debugging
*/
object Debugging {
/**
* Returns logcat output.
* Should not be called from main thread.
*/
fun logcat(): String {
try {
return ProcessBuilder("logcat", "-d", "*")
.redirectErrorStream(true)
.start()
.inputStream
.reader()
.readText()
} catch (t: Throwable) {
return "Error getting logs: ${t.message}"
}
}
fun clearLogs() {
try {
ProcessBuilder("logcat", "-c").start()
} catch (t: Throwable) {
//Ignore
}
}
/**
* Generates a query parameter string which can be used with GitHub issue url.
* Currently, only `body` parameter is generated
*/
fun bugReportUrlParams(): String {
val body = """
|**Description**
|
|
|
|
|**Additional Info**
|- App Version: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})
|- Android Version: ${Build.VERSION.RELEASE} (${Build.VERSION.SDK_INT})
""".trimMargin()
return "?body=${Uri.encode(body)}"
}
/**
* Wraps given logs into a <details> element.
* This is useful for GitHub comments.
*/
fun wrapLogs(title: String, logs: String): String {
return """
<details>
<summary>$title</summary>
<p>
```python
{logs}
```
</p>
</details>
""".trimIndent().replace("{logs}", logs)
}
}

View File

@@ -0,0 +1,133 @@
/*
* Copyright (c) 2022 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.util
import android.util.Log
import androidx.activity.viewModels
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.biometric.FingerprintDialogFragment
import androidx.biometric.auth.AuthPromptCallback
import androidx.biometric.auth.startClass2BiometricOrCredentialAuthentication
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentFactory
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.ViewModel
/**
* Wrapper around AndroidX Biometrics library.
*
* - Allow checking for auth availability
* - Provide simplified Kotlin-style callback setup
* - Provide consistent handling for activity restarts
* - Apply workarounds for library bugs
*
* Usage:
* 1. Call [init] to setup callbacks
* 2. Call [launch] to start auth session
*/
class DeviceAuthPrompt(private val activity: FragmentActivity) {
class PromptViewModel : ViewModel() {
var isPromptShown = false
var promptTitle = ""
}
private val viewModel by activity.viewModels<PromptViewModel>()
private var onAuthSuccess: (() -> Unit)? = null
private var onAuthFail: ((String) -> Unit)? = null
/**
* Setup auth callbacks.
* Should be called from onCreate() of the host activity/fragment.
* If an auth session is active, it will be updated with given callbacks.
*/
fun init(onSuccess: () -> Unit, onFail: (String) -> Unit) {
onAuthSuccess = onSuccess
onAuthFail = onFail
if (viewModel.isPromptShown)
launch(viewModel.promptTitle)
}
/**
* Whether user can be authenticated using Biometric or Device credentials (e.g. PIN, Password)
*/
fun canLaunch(): Boolean {
val types = BiometricManager.Authenticators.BIOMETRIC_WEAK or BiometricManager.Authenticators.DEVICE_CREDENTIAL
return BiometricManager.from(activity).canAuthenticate(types) == BiometricManager.BIOMETRIC_SUCCESS
}
/**
* Launch auth prompt.
*/
fun launch(title: String) {
check(onAuthSuccess != null)
check(onAuthFail != null)
activity.startClass2BiometricOrCredentialAuthentication(
title = title,
confirmationRequired = false,
callback = PromptCallback()
)
viewModel.isPromptShown = true
viewModel.promptTitle = title
}
private fun onAuthFinished() {
viewModel.isPromptShown = false
}
private inner class PromptCallback : AuthPromptCallback() {
override fun onAuthenticationSucceeded(activity: FragmentActivity?, result: BiometricPrompt.AuthenticationResult) {
onAuthSuccess?.invoke()
onAuthFinished()
}
override fun onAuthenticationError(activity: FragmentActivity?, errorCode: Int, errString: CharSequence) {
Log.e(javaClass.simpleName, "Authentication error: $errString [$errorCode] ")
onAuthFail?.invoke(errString.toString())
onAuthFinished()
}
}
companion object {
/**
* The constructor of [FingerprintDialogFragment] is currently marked private.
* When fragment manager tries to re-instantiate it after activity restart,
* it will fail and crash the app. So we install a custom [FragmentFactoryWrapper]
* which instantiates [FingerprintDialogFragment] via reflection.
*
* Issue: https://issuetracker.google.com/issues/181805603
*
* TODO: Remove this after issue is fixed in library.
*/
fun applyFingerprintDialogFix(fm: FragmentManager) {
fm.fragmentFactory = FragmentFactoryWrapper(fm.fragmentFactory)
}
class FragmentFactoryWrapper(private val realFactory: FragmentFactory) : FragmentFactory() {
private val fpClassName = FingerprintDialogFragment::class.java.name
override fun instantiate(classLoader: ClassLoader, className: String): Fragment {
if (className == fpClassName) {
return FingerprintDialogFragment::class.java.getDeclaredConstructor().let {
it.isAccessible = true
it.newInstance()
}
}
return realFactory.instantiate(classLoader, className)
}
}
}
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright (c) 2024 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.util
import androidx.annotation.MainThread
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
/**
* This class implements single-shot observable events.
* It is based on [LiveData] which does the heavy lifting for us.
*
* Single-shot
* ===========
* When this event is fired, it will notify exactly one active observer.
* If there is no active observer, it will wait for one so that the event
* is not "lost".
*
* This is the main difference between this class & [LiveData]. [LiveData] will
* notify the future observers to bring them up-to date. This can happen during
* Activity restarts where old observers are detached and new ones are attached.
*
* This class is used for events which should be handled only once.
* E.g. starting a fragment.
*/
open class LiveEvent<T> {
private class WrappedData<T>(val data: T, var consumed: Boolean = false)
private class WrappedObserver<T>(private val observer: Observer<T>) : Observer<WrappedData<T>> {
override fun onChanged(value: WrappedData<T>) {
if (!value.consumed) {
value.consumed = true
observer.onChanged(value.data)
}
}
}
private val liveData = MutableLiveData<WrappedData<T>>()
private val wrappedObservers = mutableMapOf<Observer<T>, WrappedObserver<T>>()
/**
* Peek current value of this event, irrespective of whether any observer has been notified.
*/
val value get() = liveData.value?.data
/**
* Fire this event with given data.
* Must be called from main thread.
*/
@MainThread
fun fire(data: T) {
liveData.value = WrappedData(data)
}
/**
* Asynchronous version of [fire].
* Can be called from any thread.
*/
fun fireAsync(data: T) {
liveData.postValue(WrappedData(data))
}
@MainThread
fun observe(owner: LifecycleOwner, observer: Observer<T>) {
val wrapped = WrappedObserver(observer)
wrappedObservers[observer] = wrapped
liveData.observe(owner, wrapped)
}
@MainThread
fun observeForever(observer: Observer<T>) {
val wrapped = WrappedObserver(observer)
wrappedObservers[observer] = wrapped
liveData.observeForever(wrapped)
}
@MainThread
fun removeObserver(observer: Observer<T>) {
wrappedObservers.remove(observer)?.let { liveData.removeObserver(it) }
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright (c) 2024 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.util
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.launch
import java.util.concurrent.LinkedBlockingQueue
/**
* Extension of [LiveEvent] to facilitate awaiting some response from observers.
*
* This simplifies the cases where a background thread needs some value from the user (i.e. UI thread),
* and we want the background thread to block until that value is available.
*
* If a request is canceled then [requestResponse] will return [cancellationValue].
*
* @param scope can be specified to auto-cancel this request on scope cancellation.
*/
class LiveRequest<RequestType, ResponseType>(private val cancellationValue: ResponseType, scope: CoroutineScope?)
: LiveEvent<RequestType>() {
private val responses = LinkedBlockingQueue<ResponseType>()
init {
scope?.launch { awaitCancellation() }?.invokeOnCompletion { cancelRequest() }
}
/**
* Fires this request with given value and returns the response.
* Will block until any response is available.
* Can be called from any threads.
*/
fun requestResponse(value: RequestType): ResponseType {
responses.clear()
fireAsync(value)
return responses.take() //Blocking call
}
/**
* Sets response for current request.
*/
fun offerResponse(response: ResponseType) = responses.offer(response)
/**
* Cancels any pending request.
*/
fun cancelRequest() = responses.offer(cancellationValue)
}

View File

@@ -0,0 +1,35 @@
package com.gaurav.avnc.util
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
object MsgDialog {
/**
* Shows a dialog with given title & message,
*/
fun show(manager: FragmentManager, title: CharSequence, msg: CharSequence) {
val fragment = MsgDialogFragment()
val args = Bundle(2)
args.putCharSequence("title", title)
args.putCharSequence("msg", msg)
fragment.arguments = args
fragment.show(manager, null)
}
class MsgDialogFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return MaterialAlertDialogBuilder(requireContext())
.setTitle(requireArguments().getCharSequence("title"))
.setMessage(requireArguments().getCharSequence("msg"))
.setPositiveButton(android.R.string.ok) { _, _ -> /* Let it dismiss */ }
.create()
}
}
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (c) 2021 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.util
import android.content.Context
import android.content.Intent
import androidx.activity.result.contract.ActivityResultContracts
/**
* Contract for openable documents.
* This is needed because OpenDocument doesn't specify [Intent.CATEGORY_OPENABLE]
*/
class OpenableDocument : ActivityResultContracts.OpenDocument() {
override fun createIntent(context: Context, input: Array<String>): Intent {
return super.createIntent(context, input).addCategory(Intent.CATEGORY_OPENABLE)
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright (c) 2021 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.util
import android.app.Activity
import android.content.ComponentName
import android.content.Context
import android.util.Log
/**
* Utilities related to Samsung DeX
*/
object SamsungDex {
/**
* Returns true, if DeX mode is enabled.
*/
private fun isInDexMode(context: Context) = runCatching {
val config = context.resources.configuration
val configClass = config.javaClass
val flag = configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass)
val value = configClass.getField("semDesktopModeEnabled").getInt(config)
value == flag
}.getOrDefault(false)
/**
* Enables/disables meta-key event capturing.
*/
fun setMetaKeyCapture(activity: Activity, isEnabled: Boolean) {
if (!isInDexMode(activity))
return
runCatching {
val managerClass = Class.forName("com.samsung.android.view.SemWindowManager")
val instanceMethod = managerClass.getMethod("getInstance")
val manager = instanceMethod.invoke(null)
val requestMethod = managerClass.getDeclaredMethod("requestMetaKeyEvent",
ComponentName::class.java,
Boolean::class.java)
requestMethod.invoke(manager, activity.componentName, isEnabled)
}.onFailure { Log.d("DeX Support", "Meta key capture error", it) }
}
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright (c) 2021 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.util
import android.content.Context
import android.graphics.drawable.GradientDrawable
import android.util.AttributeSet
import android.util.TypedValue
import androidx.appcompat.widget.AppCompatSpinner
import com.google.android.material.elevation.ElevationOverlayProvider
/**
* This class extends spinner to handle some quirks and add some utility features.
*/
class SpinnerEx(context: Context, attrs: AttributeSet? = null) : AppCompatSpinner(context, attrs) {
init {
setupElevationOverlay()
}
/**
* Popup window of the Spinner does not support elevation overlay
* which makes it hard to differentiate between popup & rest of the controls in dark theme.
*
* So we manually apply the overlay to popup background.
*/
private fun setupElevationOverlay() {
// Elevation is hardcoded to 16dp because we don't have access to popup
val popupElevation = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
16F,
resources.displayMetrics)
val overlay = ElevationOverlayProvider(context)
.compositeOverlayWithThemeSurfaceColorIfNeeded(popupElevation)
val background = popupBackground
if (background is GradientDrawable)
background.setColor(overlay)
else
background.setTint(overlay)
}
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.gaurav.avnc.model.db.MainDb
import com.gaurav.avnc.util.AppPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
/**
* Base view model.
*/
open class BaseViewModel(val app: Application) : AndroidViewModel(app) {
protected val db by lazy { MainDb.getInstance(app) }
protected val serverProfileDao by lazy { db.serverProfileDao }
val pref by lazy { AppPreferences(app) }
/**
* Launches a new coroutine using [viewModelScope], and executes [block] in that coroutine.
*/
protected fun launch(context: CoroutineContext = EmptyCoroutineContext, block: suspend CoroutineScope.() -> Unit): Job {
return viewModelScope.launch(context) { this.block() }
}
protected fun launchMain(block: suspend CoroutineScope.() -> Unit) = launch(Dispatchers.Main, block)
protected fun launchIO(block: suspend CoroutineScope.() -> Unit) = launch(Dispatchers.IO, block)
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright (c) 2024 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.viewmodel
import android.app.Application
import androidx.lifecycle.SavedStateHandle
import com.gaurav.avnc.model.ServerProfile
/**
* ViewModel for profile editor
*/
class EditorViewModel(app: Application, state: SavedStateHandle, initialProfile: ServerProfile) : BaseViewModel(app) {
/**
* Profile being edited
*/
val profile = state["profile"] ?: initialProfile.copy()
init {
state["profile"] = profile
}
/**
* While most fields of [profile] are straightforward to edit, some require
* more complex handling, and live feedback in UI.
* For these, we have to use dedicated LiveData fields.
*/
val useRepeater = state.getLiveData("useRepeater", profile.useRepeater)
val idOnRepeater = state.getLiveData("idOnRepeater", if (profile.useRepeater) profile.idOnRepeater.toString() else "")
val useRawEncoding = state.getLiveData("useRawEncoding", profile.useRawEncoding)
val useSshTunnel = state.getLiveData("useSshTunnel", profile.channelType == ServerProfile.CHANNEL_SSH_TUNNEL)
val sshUsePassword = state.getLiveData("sshUsePassword", profile.sshAuthType == ServerProfile.SSH_AUTH_PASSWORD)
val sshUsePrivateKey = state.getLiveData("sshUsePrivateKey", profile.sshAuthType == ServerProfile.SSH_AUTH_KEY)
val hasSshPrivateKey = state.getLiveData("hasSshPrivateKey", profile.sshPrivateKey.isNotBlank())
fun prepareProfileForSave(): ServerProfile {
profile.useRepeater = useRepeater.value ?: false
profile.idOnRepeater = idOnRepeater.value?.toIntOrNull() ?: 0
profile.useRawEncoding = useRawEncoding.value ?: false
profile.channelType = if (useSshTunnel.value == true) ServerProfile.CHANNEL_SSH_TUNNEL else ServerProfile.CHANNEL_TCP
profile.sshAuthType = if (sshUsePassword.value == true) ServerProfile.SSH_AUTH_PASSWORD else ServerProfile.SSH_AUTH_KEY
return profile
}
}

View File

@@ -0,0 +1,113 @@
/*
* Copyright (c) 2021 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.viewmodel
import android.app.Application
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import androidx.room.withTransaction
import com.gaurav.avnc.model.ServerProfile
import com.gaurav.avnc.util.LiveEvent
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.IOException
/**
* Viewmodel for preferences activity.
*/
class PrefsViewModel(app: Application) : BaseViewModel(app) {
/**************************************************************************
* Import/Export
*
* Currently, we are only exporting server profiles but preferences can be
* exported in the future.
*
* Importing/Exporting is done on a background thread.
**************************************************************************/
@Serializable
private data class Container(
val version: Int = 1,
val profiles: List<ServerProfile>
)
private val serializer = Json {
encodeDefaults = false
ignoreUnknownKeys = true
prettyPrint = true
}
val importFinishedEvent = LiveEvent<Boolean>()
val exportFinishedEvent = LiveEvent<Boolean>()
var importExportError = MutableLiveData<String>()
/**
* Exports data to given [uri].
*/
fun export(uri: Uri) {
launchIO {
runCatching {
// Serialize
val profiles = serverProfileDao.getList()
val data = Container(profiles = profiles)
val json = serializer.encodeToString(data)
// Write out
app.contentResolver.openOutputStream(uri)?.use { stream ->
stream.writer().use { it.write(json) }
} ?: throw IOException("Unable to write the file.")
}.let {
importExportError.postValue(it.exceptionOrNull()?.message)
exportFinishedEvent.fireAsync(it.isSuccess)
}
}
}
/**
* Imports data from given [uri].
*/
fun import(uri: Uri, deleteCurrentServers: Boolean) {
launchIO {
runCatching {
val json = app.contentResolver.openInputStream(uri)?.use { stream ->
stream.reader().use { it.readText() }
} ?: throw IOException("Unable to read the file.")
// Deserialize
val data = serializer.decodeFromString<Container>(json)
//This is where migrations would be applied (if required in future)
//Update database
if (deleteCurrentServers) {
db.withTransaction {
serverProfileDao.deleteAll()
serverProfileDao.insert(data.profiles)
}
} else {
//Reset IDs so that they don't conflict with saved profiles
data.profiles.forEach { it.ID = 0 }
serverProfileDao.insert(data.profiles)
}
}.let {
importExportError.postValue(it.exceptionOrNull()?.message)
importFinishedEvent.fireAsync(it.isSuccess)
}
}
}
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2023 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.viewmodel
import android.app.Application
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.switchMap
class UrlBarViewModel(app: Application) : BaseViewModel(app) {
val query = MutableLiveData("")
val filteredServers = query.switchMap {
if (it.isNotBlank()) serverProfileDao.search("%$it%")
else MutableLiveData(listOf())
}
}

View File

@@ -0,0 +1,421 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.viewmodel
import android.app.Application
import android.graphics.RectF
import android.util.Log
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.gaurav.avnc.model.LoginInfo
import com.gaurav.avnc.model.ServerProfile
import com.gaurav.avnc.ui.vnc.FrameScroller
import com.gaurav.avnc.ui.vnc.FrameState
import com.gaurav.avnc.ui.vnc.FrameView
import com.gaurav.avnc.util.LiveRequest
import com.gaurav.avnc.util.getClipboardText
import com.gaurav.avnc.util.setClipboardText
import com.gaurav.avnc.viewmodel.service.HostKey
import com.gaurav.avnc.viewmodel.service.SshTunnel
import com.gaurav.avnc.vnc.Messenger
import com.gaurav.avnc.vnc.UserCredential
import com.gaurav.avnc.vnc.VncClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.io.IOException
import java.lang.ref.WeakReference
/**
* ViewModel for VncActivity
*
* Connection
* ==========
*
* At construction, we instantiate a [VncClient] referenced by [client]. Then
* activity starts the connection by calling [initConnection] which starts a coroutine to
* handle connection setup.
*
* After successful connection, we continue to operate normally until the remote
* server closes the connection, or an error occurs. Once disconnected, we
* wait for the activity to finish and then cleanup any acquired resources.
*
* Currently, lifecycle of [client] is tied to this view model. So one [VncViewModel]
* manages only one [VncClient].
*
*
* Threading
* =========
*
* Receiver thread :- This thread is started (as a coroutine) in [launchConnection].
* It handles the protocol initialization, and after that processes incoming messages.
* Most of the callbacks of [VncClient.Observer] are invoked on this thread. In most
* cases it is stopped when activity is finished and this view model is cleaned up.
*
* Sender thread :- This thread is created (as an executor) by [messenger]. It is
* used to send messages to remote server. We use this dedicated thread instead
* of coroutines to preserve the order of sent messages.
*
* UI thread :- Main thread of the app. Used for updating UI and controlling other
* Threads. This is where [frameState] is updated.
*
* Renderer thread :- This is managed by [FrameView] and used for rendering frame
* via OpenGL ES. [frameState] is read from this thread to decide how/where frame
* should be drawn.
*/
class VncViewModel(val profile: ServerProfile, app: Application) : BaseViewModel(app), VncClient.Observer {
/**
* Connection lifecycle:
*
* Created
* |
* v
* Connecting ----------+
* | |
* v |
* Connected |
* | |
* v |
* Disconnected <-------+
*
*/
enum class State {
Created,
Connecting,
Connected,
Disconnected;
companion object {
val State?.isConnected get() = (this == Connected)
val State?.isDisconnected get() = (this == Disconnected)
}
}
val client = VncClient(this)
/**
* We have two places for connection state (both are synced):
*
* [VncClient.connected] - Simple boolean state, used most of the time
* [state] - More granular, used by observers & data binding
*/
val state = MutableLiveData(State.Created)
/**
* Reason for disconnecting.
*/
val disconnectReason = MutableLiveData("")
/**
* Fired when we need some credentials from user.
* It will trigger the Login dialog.
*/
val loginInfoRequest = LiveRequest<LoginInfo.Type, LoginInfo>(LoginInfo(), viewModelScope)
/**
* Fired to unlock saved servers.
*/
val serverUnlockRequest = LiveRequest<Any?, Boolean>(false, viewModelScope)
/**
* List of saved profiles.
* Used by login-autocompletion.
*/
val savedProfiles by lazy { serverProfileDao.getLiveList() }
/**
* Holds a weak reference to [FrameView] instance.
*
* This is used to tell [FrameView] to re-render its content when VncClient's
* framebuffer is updated. Instead of using LiveData/LiveEvent, we keep a
* weak reference because:
*
* 1. It avoids a context-switch to UI thread. Rendering request to
* a GlSurfaceView can be sent from any thread.
*
* 2. We don't have to invoke the whole ViewModel machinery just for
* a single call to FrameView.
*/
var frameViewRef = WeakReference<FrameView>(null)
/**
* Holds information about scaling, translation etc.
*/
val frameState = with(pref.viewer) { FrameState(zoomMin, zoomMax, perOrientationZoom) }
/**
* Used for scrolling/animating the frame.
*/
val frameScroller = FrameScroller(this)
/**
* Used for sending events to remote server.
*/
val messenger = Messenger(client)
private val sshTunnel = SshTunnel(this)
/**
* Used to confirm unknown hosts.
*/
val sshHostKeyVerifyRequest = LiveRequest<HostKey, Boolean>(false, viewModelScope)
/**************************************************************************
* Connection management
**************************************************************************/
/**
* Initialize VNC connection.
* It can be called multiple times due to activity restarts.
*/
fun initConnection() {
if (state.value == State.Created) {
state.value = State.Connecting
frameState.setZoom(profile.zoom1, profile.zoom2)
launchConnection()
}
}
private fun launchConnection() {
viewModelScope.launch(Dispatchers.IO) {
runCatching {
preConnect()
connect()
processMessages()
}.onFailure {
if (it is IOException) disconnectReason.postValue(it.message)
Log.e("ReceiverCoroutine", "Connection failed", it)
}
state.postValue(State.Disconnected)
//Wait until activity is finished and viewmodel is cleaned up.
runCatching { awaitCancellation() }
cleanup()
}
}
private fun preConnect() {
if (profile.ID != 0L)
if (!serverUnlockRequest.requestResponse(null))
throw IOException("Could not unlock server")
client.configure(profile.viewOnly, profile.securityType, true /* Hardcoded to true */,
profile.imageQuality, profile.useRawEncoding)
if (profile.useRepeater)
client.setupRepeater(profile.idOnRepeater)
}
private fun connect() {
when (profile.channelType) {
ServerProfile.CHANNEL_TCP ->
client.connect(profile.host, profile.port)
ServerProfile.CHANNEL_SSH_TUNNEL ->
sshTunnel.open().use {
client.connect(it.host, it.port)
}
else -> throw IOException("Unknown Channel: ${profile.channelType}")
}
state.postValue(State.Connected)
// Initial sync, slightly delayed to allow extended clipboard negotiations
launchIO { delay(1000L); sendClipboardText() }
}
private fun processMessages() {
while (viewModelScope.isActive)
client.processServerMessage()
}
private fun cleanup() {
messenger.cleanup()
client.cleanup()
sshTunnel.close()
}
/**
* Can be used to persist any changes made to [profile]
*/
fun saveProfile() {
if (profile.ID != 0L)
launch { serverProfileDao.update(profile) }
}
/**************************************************************************
* Frame management
**************************************************************************/
fun updateZoom(scaleFactor: Float, fx: Float, fy: Float) {
if (profile.fZoomLocked) return
val appliedScaleFactor = frameState.updateZoom(scaleFactor)
//Calculate how much the focus would shift after scaling
val dfx = (fx - frameState.frameX) * (appliedScaleFactor - 1)
val dfy = (fy - frameState.frameY) * (appliedScaleFactor - 1)
//Translate in opposite direction to keep focus fixed
frameState.pan(-dfx, -dfy)
frameViewRef.get()?.requestRender()
}
fun resetZoom() {
frameState.setZoom(1f, 1f)
frameViewRef.get()?.requestRender()
}
fun resetZoomToDefault() {
frameState.setZoom(profile.zoom1, profile.zoom2)
frameViewRef.get()?.requestRender()
}
fun setZoom(zoom1: Float, zoom2: Float) {
frameState.setZoom(zoom1, zoom2)
frameViewRef.get()?.requestRender()
}
fun panFrame(deltaX: Float, deltaY: Float) {
frameState.pan(deltaX, deltaY)
frameViewRef.get()?.requestRender()
}
fun moveFrameTo(x: Float, y: Float) {
frameState.moveTo(x, y)
frameViewRef.get()?.requestRender()
}
fun toggleZoomLock(enabled: Boolean) {
profile.fZoomLocked = enabled
saveProfile()
}
fun saveZoom() {
profile.zoom1 = frameState.zoomScale1
profile.zoom2 = frameState.zoomScale2
saveProfile()
}
fun setSafeArea(safeArea: RectF) {
frameState.setSafeArea(safeArea)
frameViewRef.get()?.requestRender()
}
/**************************************************************************
* Miscellaneous
**************************************************************************/
fun sendClipboardText() {
if (pref.server.clipboardSync && client.connected) launchIO {
getClipboardText(app)?.let { messenger.sendClipboardText(it) }
}
}
private var clipReceiverJob: Job? = null
private fun receiveClipboardText(text: String) {
if (!pref.server.clipboardSync)
return
// This is a protective measure against servers which send every 'selection' made on the server.
// Setting clip text involves IPC, so these events can exhaust Binder resources, leading to ANRs.
if (clipReceiverJob?.isActive == true) {
Log.w(javaClass.simpleName, "Dropping clip text received from server, previous text is still pending")
return
}
clipReceiverJob = launchIO {
setClipboardText(app, text)
}
}
fun getLoginInfo(type: LoginInfo.Type): LoginInfo {
val vu = profile.username
val vp = profile.password
val sp = profile.sshPassword
if (type == LoginInfo.Type.VNC_PASSWORD && vp.isNotBlank())
return LoginInfo(password = vp)
if (type == LoginInfo.Type.VNC_CREDENTIAL && vu.isNotBlank() && vp.isNotBlank())
return LoginInfo(username = vu, password = vp)
if (type == LoginInfo.Type.SSH_PASSWORD && sp.isNotBlank())
return LoginInfo(password = sp)
// Something is missing, so we have to ask the user
return loginInfoRequest.requestResponse(type) // Blocking call
}
/**
* Resize remote desktop to match with local window size (if requested by user).
* In portrait mode, safe area is used instead of window to exclude the keyboard.
*/
fun resizeRemoteDesktop() {
if (profile.resizeRemoteDesktop) frameState.let {
if (it.windowWidth > it.windowHeight)
messenger.setDesktopSize(it.windowWidth.toInt(), it.windowHeight.toInt())
else
messenger.setDesktopSize(it.safeArea.width().toInt(), it.safeArea.height().toInt())
}
}
fun pauseFrameBufferUpdates() {
//client.setAutomaticFrameBufferUpdates(false)
}
fun resumeFrameBufferUpdates() {
//client.setAutomaticFrameBufferUpdates(true)
}
fun refreshFrameBuffer() {
messenger.refreshFrameBuffer()
}
/**************************************************************************
* [VncClient.Observer] Implementation
**************************************************************************/
override fun onPasswordRequired(): String {
return getLoginInfo(LoginInfo.Type.VNC_PASSWORD).password
}
override fun onCredentialRequired(): UserCredential {
return getLoginInfo(LoginInfo.Type.VNC_CREDENTIAL).let { UserCredential(it.username, it.password) }
}
override fun onFramebufferUpdated() {
frameViewRef.get()?.requestRender()
}
override fun onGotXCutText(text: String) {
receiveClipboardText(text)
}
override fun onFramebufferSizeChanged(width: Int, height: Int) {
launchMain {
frameState.setFramebufferSize(width.toFloat(), height.toFloat())
}
}
override fun onPointerMoved(x: Int, y: Int) {
frameViewRef.get()?.requestRender()
}
}

View File

@@ -0,0 +1,191 @@
/*
* Copyright (c) 2024 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.viewmodel.service
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.net.wifi.WifiManager
import android.net.wifi.WifiManager.MulticastLock
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.lifecycle.MutableLiveData
import com.gaurav.avnc.model.ServerProfile
import java.util.concurrent.Executors
/**
* Discovers VNC servers advertising themselves on the network.
*/
object Discovery {
private const val TAG = "VncServiceDiscovery"
/**
* List of servers found by Discovery.
*/
val servers = MutableLiveData<List<ServerProfile>>()
/**
* Status of discovery.
*/
val isRunning = MutableLiveData(false)
private val impl by lazy { Impl() }
/**
* Starts discovery.
*/
fun start(context: Context) = impl.start(context)
/**
* Stops discovery.
*/
fun stop() = impl.stop()
/**
* Due to Android API limitations, service discovery is more complicated than necessary:
*
* - [NsdManager] is asynchronous, which means every command's result is communicated later
* on a separate thread. Also, [NsdManager] throws up if more than one request is made by same
* listener. You can't call [NsdManager.discoverServices] even if discovery is already started
* So to avoid race conditions (and keep my sanity, because its one of those APIs in Android
* where I wish some day the API designers are forced to use this crap themselves), all callbacks
* of [Impl] are run on a dedicated [executor], and [startRequested] is used to track pending start.
*
* - Only one service can resolved at a time via [NsdManager.resolveService]. To handle this,
* newly found service is first added to [pendingResolves]. When resolution finishes for a
* service, we remove that service form [pendingResolves], and start resolution for the next.
*
* - Android can filter/drop multicast WiFi packets to save power. Devices, like Pixel phone, enable
* this feature. This can be turned off by acquiring a multicast lock, but [NsdManager] doesn't
* doesn't do this automatically. So we have to acquire it manually.
*/
@Suppress("DEPRECATION") // Yeah, f**k you too Google
private class Impl {
private val serviceType = "_rfb._tcp"
private var wifiManager: WifiManager? = null
private var multicastLock: MulticastLock? = null
private var nsdManager: NsdManager? = null
private val listener = DiscoveryListener()
private val executor = Executors.newSingleThreadExecutor()
private var started = false
private var startRequested = false
private val pendingResolves = mutableMapOf<ResolveListener, NsdServiceInfo>()
private val resolvedProfiles = mutableSetOf<ServerProfile>()
private fun execute(action: Runnable) {
runCatching { executor.execute(action) }.onFailure { Log.e(TAG, "Cannot execute action", it) }
}
private fun postResolvedProfiles() {
servers.postValue(resolvedProfiles.toList())
}
fun start(context: Context) = execute {
if (startRequested || started)
return@execute
val appContext = context.applicationContext // Need app context to avoid possibility of WiFiManager leaks
wifiManager = wifiManager ?: ContextCompat.getSystemService(appContext, WifiManager::class.java)
nsdManager = nsdManager ?: ContextCompat.getSystemService(appContext, NsdManager::class.java)
nsdManager!!.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, listener)
startRequested = true
// Forget old profiles
resolvedProfiles.clear()
postResolvedProfiles()
}
fun stop() = execute {
if (started)
nsdManager?.stopServiceDiscovery(listener)
}
fun onStarted() = execute {
started = true
startRequested = false
isRunning.postValue(true)
multicastLock = wifiManager?.createMulticastLock(TAG)
multicastLock?.acquire()
}
fun onStopped() = execute {
started = false
isRunning.postValue(false)
multicastLock?.release()
multicastLock = null
}
fun onStartFailed() = execute {
startRequested = false
}
fun onServiceFound(serviceInfo: NsdServiceInfo) = execute {
val listener = ResolveListener()
pendingResolves[listener] = serviceInfo
if (pendingResolves.size == 1) // Kick-start the resolution chain
nsdManager?.resolveService(serviceInfo, listener)
}
fun onServiceLost(serviceInfo: NsdServiceInfo) = execute {
resolvedProfiles.removeAll { it.name == serviceInfo.serviceName }
postResolvedProfiles()
}
fun onResolved(si: NsdServiceInfo) = execute {
resolvedProfiles.add(ServerProfile(name = si.serviceName, host = si.host.hostAddress!!, port = si.port))
postResolvedProfiles()
}
fun onResolveFinished(finishedResolve: ResolveListener) = execute {
pendingResolves.remove(finishedResolve)
pendingResolves.keys.firstOrNull()?.let { nsdManager?.resolveService(pendingResolves[it], it) }
}
}
/**
* Listener for discovery process.
*/
private class DiscoveryListener : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(serviceType: String?) = impl.onStarted()
override fun onDiscoveryStopped(serviceType: String?) = impl.onStopped()
override fun onServiceFound(serviceInfo: NsdServiceInfo) = impl.onServiceFound(serviceInfo)
override fun onServiceLost(serviceInfo: NsdServiceInfo) = impl.onServiceLost(serviceInfo)
override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) {
Log.e(TAG, "Service discovery failed to start [E: $errorCode ]")
impl.onStartFailed()
}
override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) {
Log.w(TAG, "Service discovery failed to stop [E: $errorCode ]")
// From our perspective, this is same as onDiscoveryStopped().
// We can't retry stopping it because NsdManager will clear the listener
// before invoking this callback.
impl.onStopped()
}
}
/**
* Listener for service resolution result.
*/
private class ResolveListener : NsdManager.ResolveListener {
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
impl.onResolved(serviceInfo)
impl.onResolveFinished(this)
}
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
Log.e(TAG, "Service resolution failed for '${serviceInfo}' [E: $errorCode]")
impl.onResolveFinished(this)
}
}
}

View File

@@ -0,0 +1,242 @@
/*
* Copyright (c) 2024 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.viewmodel.service
import android.util.Base64
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.gaurav.avnc.model.LoginInfo
import com.gaurav.avnc.model.ServerProfile
import com.gaurav.avnc.viewmodel.VncViewModel
import com.trilead.ssh2.Connection
import com.trilead.ssh2.KnownHosts
import com.trilead.ssh2.LocalPortForwarder
import com.trilead.ssh2.ServerHostKeyVerifier
import com.trilead.ssh2.crypto.PEMDecoder
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.Closeable
import java.io.File
import java.io.IOException
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.NoRouteToHostException
import java.net.ServerSocket
import java.security.KeyPair
import java.security.MessageDigest
/**
* Container for SSH Host Key
*/
class HostKey(
val host: String,
val isKnownHost: Boolean,
val algo: String,
val key: ByteArray,
) {
/**
* Returns SHA-256 fingerprint of the [key].
*/
fun getFingerprint(): String {
val sha256 = MessageDigest.getInstance("SHA-256").digest(key)
val base64 = Base64.encodeToString(sha256, Base64.NO_PADDING)
return "SHA256:$base64"
}
}
/**
* Implements Host Key verification.
*
* Known hosts & keys are stored in a file inside app's private storage.
* For unknown host, user is prompted to confirm the key.
*/
class HostKeyVerifier(private val viewModel: VncViewModel) : ServerHostKeyVerifier {
private val knownHostsFile = File(viewModel.app.filesDir, "known-hosts")
private val knownHosts = KnownHosts(knownHostsFile)
override fun verifyServerHostKey(hostname: String, port: Int, keyAlgorithm: String, key: ByteArray): Boolean {
val verification = knownHosts.verifyHostkey(hostname, keyAlgorithm, key)
if (verification == KnownHosts.HOSTKEY_IS_OK)
return true
val isKnownHost = (verification == KnownHosts.HOSTKEY_HAS_CHANGED)
val hostKey = HostKey(hostname, isKnownHost, keyAlgorithm, key)
if (viewModel.sshHostKeyVerifyRequest.requestResponse(hostKey)) {
//User has confirmed the key, so remember it.
KnownHosts.addHostkeyToFile(knownHostsFile, arrayOf(hostname), keyAlgorithm, key)
return true
}
return false
}
}
/**
* Small wrapper around [LocalPortForwarder].
*
* Once connection has been successfully established via [host] & [port],
* this gate should be closed to stop new connections.
*/
class TunnelGate(val host: String, val port: Int, private val forwarder: LocalPortForwarder) : Closeable {
override fun close() {
forwarder.close()
}
}
/**
* Manager for SSH Tunnel
*/
class SshTunnel(private val viewModel: VncViewModel) {
private var connection: Connection? = null
private val localHost = "127.0.0.1"
/**
* Opens the tunnel according to current profile in [viewModel].
*/
fun open(): TunnelGate {
val profile = viewModel.profile
val connection = connect(profile)
this.connection = connection
authenticate(connection, profile)
if (!connection.isAuthenticationComplete)
throw IOException("SSH authentication failed")
// SSHLib does not expose internal ServerSocket used for local port forwarder.
// Hence, if we pass 0 as local port to let the system pick a port for us, we have no way
// to know the port system picked.
// So we create a temporary ServerSocket, close it immediately and try to use its port.
// But between the close-reuse, that port can be assigned to someone else, so we try again.
for (i in 1..50) {
val attemptedPort = ServerSocket(0).use { it.localPort }
val address = InetSocketAddress(localHost, attemptedPort)
try {
val forwarder = connection.createLocalPortForwarder(address, profile.host, profile.port)
return TunnelGate(localHost, attemptedPort, forwarder)
} catch (e: IOException) {
//Retry
}
}
throw IOException("Cannot find a local port for SSH Tunnel")
}
/**
* It is possible for a host to have multiple IP addresses.
* If connection failed due to [NoRouteToHostException], we try the next address (if available).
*/
private fun connect(profile: ServerProfile): Connection {
for (address in InetAddress.getAllByName(profile.sshHost)) {
try {
return Connection(address.hostAddress, profile.sshPort).apply { connect(HostKeyVerifier(viewModel)) }
} catch (e: IOException) {
if (e.cause is NoRouteToHostException) continue
else throw e
}
}
// We will reach here only if every address throws NoRouteToHostException
throw NoRouteToHostException("Unreachable SSH host: ${profile.sshHost}")
}
private fun authenticate(connection: Connection, profile: ServerProfile) {
when (profile.sshAuthType) {
ServerProfile.SSH_AUTH_PASSWORD -> {
val password = viewModel.getLoginInfo(LoginInfo.Type.SSH_PASSWORD).password //Possibly blocking call
connection.authenticateWithPassword(profile.sshUsername, password)
}
ServerProfile.SSH_AUTH_KEY -> {
val pk = profile.sshPrivateKey
val cached = KeyCache.get(pk)
if (cached != null) {
connection.authenticateWithPublicKey(profile.sshUsername, cached)
} else {
val pem = PEMDecoder.parsePEM(pk.toCharArray())
var password = ""
if (PEMDecoder.isPEMEncrypted(pem)) {
password = viewModel.getLoginInfo(LoginInfo.Type.SSH_KEY_PASSWORD).password //Blocking call
}
val keyPair = PEMDecoder.decode(pem, password)
connection.authenticateWithPublicKey(profile.sshUsername, keyPair)
KeyCache.put(pk, keyPair)
}
}
else -> throw IOException("Unknown SSH auth type: ${profile.sshAuthType}")
}
}
fun close() {
connection?.close()
}
/**
* A very simple key cache to keep unlocked/decoded keys in memory
* Strategy:
* 1. Keep keys in memory as long as app is in foreground
* 2. Clear cache if app goes in background for more than 15 minutes
*/
private object KeyCache {
private val cache = mutableMapOf<String, KeyPair>()
private var lifecycleObserver: DefaultLifecycleObserver? = null
fun get(pk: String): KeyPair? {
synchronized(cache) {
return cache[pk]
}
}
fun put(pk: String, keyPair: KeyPair) {
synchronized(cache) {
cache[pk] = keyPair
addLifecycleObserver()
}
}
private fun addLifecycleObserver() {
if (lifecycleObserver != null)
return // Already added
lifecycleObserver = object : DefaultLifecycleObserver {
var cleanupJob: Job? = null
override fun onStart(owner: LifecycleOwner) {
cleanupJob?.let { if (it.isActive) it.cancel() }
cleanupJob = null
}
override fun onStop(owner: LifecycleOwner) {
cleanupJob = owner.lifecycleScope.launch {
delay(15 * 60 * 1000)
synchronized(cache) {
cache.values.forEach { it.private.destroy() }
cache.clear()
}
}
}
}
ProcessLifecycleOwner.get().let {
// Observer needs to be set on main thread,
// and lifecycleScope is already bound to main thread
it.lifecycleScope.launch {
it.lifecycle.addObserver(lifecycleObserver!!)
}
}
}
}
}

View File

@@ -0,0 +1,102 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.vnc
import android.graphics.PointF
import android.util.Log
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
/**
* Allows sending different types of messages to remote server.
*/
class Messenger(private val client: VncClient) {
/**************************************************************************
* Sender thread
**************************************************************************/
private val sender = Executors.newSingleThreadExecutor()
private val senderLock = Any()
private fun execute(action: Runnable) {
synchronized(senderLock) {
if (!sender.isShutdown)
sender.execute(action)
}
}
fun cleanup() {
synchronized(senderLock) { sender.shutdown() }
runCatching { sender.awaitTermination(60, TimeUnit.SECONDS) }
if (!sender.isTerminated) Log.w(javaClass.simpleName, "Unable to fully stop Sender thread!")
}
/**************************************************************************
* Input events
**************************************************************************/
/**
* Keeps track of current pointer button state.
*/
private var pointerButtonMask: Int = 0
private fun sendPointerEvent(mask: Int, p: PointF) {
val x = p.x.toInt()
val y = p.y.toInt()
client.moveClientPointer(x, y)
execute { client.sendPointerEvent(x, y, mask) }
}
fun sendPointerButtonDown(button: PointerButton, p: PointF) {
pointerButtonMask = pointerButtonMask or button.bitMask
sendPointerEvent(pointerButtonMask, p)
}
fun sendPointerButtonUp(button: PointerButton, p: PointF) {
pointerButtonMask = pointerButtonMask and button.bitMask.inv()
sendPointerEvent(pointerButtonMask, p)
}
fun sendPointerButtonRelease(p: PointF) {
if (pointerButtonMask != 0) {
pointerButtonMask = 0
sendPointerEvent(pointerButtonMask, p)
}
}
fun sendKey(keySym: Int, xtCode: Int, isDown: Boolean): Boolean {
if (!client.connected)
return false
execute { client.sendKeyEvent(keySym, xtCode, isDown) }
return true
}
fun insertButtonUpDelay() {
execute { runCatching { Thread.sleep(200) } }
}
/**************************************************************************
* Misc
**************************************************************************/
fun sendClipboardText(text: String) {
execute { client.sendCutText(text) }
}
fun setDesktopSize(width: Int, height: Int) {
execute { client.setDesktopSize(width, height) }
}
fun refreshFrameBuffer() {
execute { client.refreshFrameBuffer() }
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.vnc
enum class PointerButton(val bitMask: Int) {
None(0),
Left(1),
Middle(2),
Right(4),
WheelUp(8),
WheelDown(16),
WheelLeft(32),
WheelRight(64)
}

View File

@@ -0,0 +1,17 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.vnc
/**
* This class is used for returning user credentials from callbacks.
*/
data class UserCredential(
@JvmField val username: String = "",
@JvmField val password: String = ""
)

View File

@@ -0,0 +1,348 @@
package com.gaurav.avnc.vnc
import android.view.KeyEvent
import androidx.annotation.Keep
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.charset.StandardCharsets
/**
* This is a thin wrapper around native client.
*
*
* - +------------+ +----------+
* - | Public API | | Observer |
* - +------------+ +-----A----+
* - | |
* - | |
* - JNI -------|------------------------------------------------|-----------
* - | |
* - | |
* - +-------v--------+ +--------------+ +--------v---------+
* - | Native Methods |------>| LibVNCClient |<----->| Native Callbacks |
* - +----------------+ +--------------+ +------------------+
*
*
* For every new instance of [VncClient], we create a native 'rfbClient' and
* store its pointer in [nativePtr]. Parameters for connection can be setup using
* [configure]. Connection is then started using [connect]. Then incoming
* messages are handled by [processServerMessage].
*
* To release the resources you must call [cleanup] after you are done with
* this instance.
*/
class VncClient(private val observer: Observer) {
/**
* Interface for event observer.
* DO NOT throw exceptions from these methods.
* There is NO guarantee about which thread will invoke [Observer] methods.
*/
interface Observer {
fun onPasswordRequired(): String
fun onCredentialRequired(): UserCredential
fun onGotXCutText(text: String)
fun onFramebufferUpdated()
fun onFramebufferSizeChanged(width: Int, height: Int)
fun onPointerMoved(x: Int, y: Int)
//fun onBell()
}
/**
* Value of the pointer to native 'rfbClient'. This is passed to all native methods.
*/
private val nativePtr: Long
init {
nativePtr = nativeClientCreate()
if (nativePtr == 0L)
throw RuntimeException("Could not create native rfbClient!")
}
@Volatile
var connected = false
private set
/**
* Name of remote desktop
*/
val desktopName; get() = nativeGetDesktopName(nativePtr)
/**
* Whether connection is encrypted
*/
val isEncrypted; get() = nativeIsEncrypted(nativePtr)
/**
* In 'View-only' mode input to remote server is disabled
*/
var viewOnlyMode = false; private set
/**
* Latest pointer position. See [moveClientPointer].
*/
var pointerX = 0; private set
var pointerY = 0; private set
/**
* Client-side cursor rendering creates a synchronization issue.
* Suppose if pointer is moved to (50,10) by client. A PointerEvent is sent
* to the server and cursor is immediately rendered on (50,10).
* Some servers (e.g. Vino) will send back a PointerPosition event for (50, 10).
* But, by the time that event is received from server, pointer on client
* might have already moved to (60,20) (this is almost guaranteed to happen
* with touchpad/relative action mode). So the cursor will probably 'jump back'
* depending on the order of these events.
*
* This flags works around the issue by temporarily ignoring serer-side updates.
*/
@Volatile
var ignorePointerMovesByServer = false
@Volatile
private var autoFBRequestsQueued = true
private var autoFBRequests = autoFBRequestsQueued
/**
* Value of the most recent cut text sent/received from server
*/
@Volatile
private var lastCutText: String? = null
/**
* Setup different properties for this client.
*
* @param securityType RFB security type to use.
*/
fun configure(viewOnly: Boolean, securityType: Int, useLocalCursor: Boolean, imageQuality: Int, useRawEncoding: Boolean) {
viewOnlyMode = viewOnly
nativeConfigure(nativePtr, securityType, useLocalCursor, imageQuality, useRawEncoding)
}
fun setupRepeater(serverId: Int) {
nativeSetDest(nativePtr, "ID", serverId)
}
/**
* Initializes VNC connection.
*/
fun connect(host: String, port: Int) {
connected = nativeInit(nativePtr, host, port)
if (!connected) throw IOException(nativeGetLastErrorStr())
applyCompatQuirks()
}
/**
* Waits for incoming server message, parses it and then invokes appropriate callbacks.
*
* @param uSecTimeout Timeout in microseconds.
*/
fun processServerMessage(uSecTimeout: Int = 1000000) {
if (!connected)
return
if (!nativeProcessServerMessage(nativePtr, uSecTimeout)) {
connected = false
throw IOException(nativeGetLastErrorStr())
}
if (autoFBRequests != autoFBRequestsQueued) {
autoFBRequests = autoFBRequestsQueued
nativeSetAutomaticFramebufferUpdates(nativePtr, autoFBRequests)
}
}
/**
* Sends Key event to remote server.
*
* @param keySym Key symbol
* @param xtCode Key code from [XTKeyCode]
* @param isDown true for key down, false for key up
*/
fun sendKeyEvent(keySym: Int, xtCode: Int, isDown: Boolean) = ifConnectedAndInteractive {
nativeSendKeyEvent(nativePtr, keySym, xtCode, isDown)
}
/**
* Sends pointer event to remote server.
*
* @param x Horizontal pointer coordinate
* @param y Vertical pointer coordinate
* @param mask Button mask to identify which button was pressed.
*/
fun sendPointerEvent(x: Int, y: Int, mask: Int) = ifConnectedAndInteractive {
nativeSendPointerEvent(nativePtr, x, y, mask)
}
/**
* Updates client-side pointer position.
* No event is sent to server.
*
* Primary use-case is to update pointer position during gestures.
* This way we can immediately render the cursor on new position without
* waiting for Network IO.
*
* It also helps with servers which don't send pointer-position updates
* if pointer was moved by the client.
*
* @param x Horizontal pointer coordinate
* @param y Vertical pointer coordinate
*/
fun moveClientPointer(x: Int, y: Int) {
pointerX = x
pointerY = y
observer.onPointerMoved(x, y)
}
/**
* Sends text to remote desktop's clipboard.
*/
fun sendCutText(text: String) = ifConnectedAndInteractive {
if (text != lastCutText) {
val sent = if (nativeIsUTF8CutTextSupported(nativePtr))
nativeSendCutText(nativePtr, text.toByteArray(StandardCharsets.UTF_8), true)
else
nativeSendCutText(nativePtr, text.toByteArray(StandardCharsets.ISO_8859_1), false)
if (sent)
lastCutText = text
}
}
/**
* Set remote desktop size to given dimensions.
* This needs server support to actually work.
* Non-positive [width] & [height] are ignored.
*/
fun setDesktopSize(width: Int, height: Int) = ifConnected {
if (width > 0 && height > 0)
nativeSetDesktopSize(nativePtr, width, height)
}
/**
* Sends frame buffer update request to remote server.
*/
fun refreshFrameBuffer() = ifConnected {
nativeRefreshFrameBuffer(nativePtr)
}
/**
* Controls whether framebuffer update requests are sent automatically.
* It takes effect after the next call to [processServerMessage].
*/
/*fun setAutomaticFrameBufferUpdates(enabled: Boolean) = ifConnected {
//autoFBRequestsQueued = enabled
}*/
/**
* Puts framebuffer contents in currently active OpenGL texture.
* Must be called from an OpenGL ES context (i.e. from renderer thread).
*/
fun uploadFrameTexture() = nativeUploadFrameTexture(nativePtr)
/**
* Upload cursor shape into framebuffer texture.
*/
fun uploadCursor() = nativeUploadCursor(nativePtr, pointerX, pointerY)
/**
* Release all resources allocated by the client.
* DO NOT use this client after [cleanup].
*/
fun cleanup() {
connected = false
nativeCleanup(nativePtr)
}
private inline fun ifConnected(block: () -> Unit) {
if (connected)
block()
}
private inline fun ifConnectedAndInteractive(block: () -> Unit) = ifConnected {
if (!viewOnlyMode)
block()
}
private fun applyCompatQuirks() {
if (nativeIsServerMacOS(nativePtr)) {
XKeySymAndroid.updateKeyMap(KeyEvent.KEYCODE_ALT_LEFT, XKeySym.XK_Meta_L)
XKeySymAndroid.updateKeyMap(KeyEvent.KEYCODE_ALT_RIGHT, XKeySym.XK_Meta_R)
}
}
private external fun nativeClientCreate(): Long
private external fun nativeConfigure(clientPtr: Long, securityType: Int, useLocalCursor: Boolean, imageQuality: Int, useRawEncoding: Boolean)
private external fun nativeInit(clientPtr: Long, host: String, port: Int): Boolean
private external fun nativeSetDest(clientPtr: Long, host: String, port: Int)
private external fun nativeProcessServerMessage(clientPtr: Long, uSecTimeout: Int): Boolean
private external fun nativeSendKeyEvent(clientPtr: Long, keySym: Int, xtCode: Int, isDown: Boolean): Boolean
private external fun nativeSendPointerEvent(clientPtr: Long, x: Int, y: Int, mask: Int): Boolean
private external fun nativeSendCutText(clientPtr: Long, bytes: ByteArray, isUTF8: Boolean): Boolean
private external fun nativeIsUTF8CutTextSupported(clientPtr: Long): Boolean
private external fun nativeSetDesktopSize(clientPtr: Long, width: Int, height: Int): Boolean
private external fun nativeRefreshFrameBuffer(clientPtr: Long): Boolean
private external fun nativeSetAutomaticFramebufferUpdates(clientPtr: Long, enabled: Boolean)
private external fun nativeGetDesktopName(clientPtr: Long): String
private external fun nativeGetWidth(clientPtr: Long): Int
private external fun nativeGetHeight(clientPtr: Long): Int
private external fun nativeIsEncrypted(clientPtr: Long): Boolean
private external fun nativeUploadFrameTexture(clientPtr: Long)
private external fun nativeUploadCursor(clientPtr: Long, px: Int, py: Int)
private external fun nativeGetLastErrorStr(): String
private external fun nativeIsServerMacOS(clientPtr: Long): Boolean
private external fun nativeCleanup(clientPtr: Long)
@Keep
private fun cbGetPassword() = observer.onPasswordRequired()
@Keep
private fun cbGetCredential() = observer.onCredentialRequired()
@Keep
private fun cbGotXCutText(bytes: ByteArray, isUTF8: Boolean) {
(if (isUTF8) StandardCharsets.UTF_8 else StandardCharsets.ISO_8859_1).let {
val cutText = it.decode(ByteBuffer.wrap(bytes)).toString()
if (cutText != lastCutText) {
lastCutText = cutText
observer.onGotXCutText(cutText)
}
}
}
@Keep
private fun cbFinishedFrameBufferUpdate() = observer.onFramebufferUpdated()
@Keep
private fun cbFramebufferSizeChanged(w: Int, h: Int) = observer.onFramebufferSizeChanged(w, h)
@Keep
private fun cbBell() = Unit // observer.onBell()
@Keep
private fun cbHandleCursorPos(x: Int, y: Int) {
if (!ignorePointerMovesByServer)
moveClientPointer(x, y)
}
/**
* Native library initialization
*/
companion object {
fun loadLibrary() {
System.loadLibrary("native-vnc")
}
@JvmStatic
private external fun initLibrary()
init {
loadLibrary()
initLibrary()
}
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright (c) 2020 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.vnc
import android.net.Uri
import com.gaurav.avnc.model.ServerProfile
import java.net.URI
/**
* This class implements the `vnc` URI scheme.
* Reference: https://tools.ietf.org/html/rfc7869
*
* If host in given URI string is an IPv6 address, it MUST be wrapped in square brackets.
* (This requirement come from using Java [URI] internally.)
*
* If given URI doesn't start with 'vnc://' scheme, it will be automatically added.
*/
class VncUri(str: String) {
/**
* Add scheme if missing.
* It is also common for users to accidentally type 'vnc:host' instead of 'vnc://host',
* so we gracefully handle that case too.
*/
private val uriString = str.replaceFirst(Regex("^(vnc:/?/?)?", RegexOption.IGNORE_CASE), "vnc://")
private val uri = Uri.parse(uriString)
/**
* Older versions of Android [Uri] does not support IPv6, so we need to use Java [URI] for host & port.
* It also serves as a validation step because [URI] verifies that address is well-formed.
*/
private val javaUri = runCatching { URI(uriString) }.getOrNull()
val host = javaUri?.host?.trim('[', ']') ?: ""
val port = if (javaUri?.port == -1) 5900 else javaUri?.port ?: 5900
val connectionName = uri.getQueryParameter("ConnectionName") ?: ""
val username = uri.getQueryParameter("VncUsername") ?: ""
val password = uri.getQueryParameter("VncPassword") ?: ""
val securityType = uri.getQueryParameter("SecurityType")?.toIntOrNull() ?: 0
val channelType = uri.getQueryParameter("ChannelType")?.toIntOrNull() ?: ServerProfile.CHANNEL_TCP
val colorLevel = uri.getQueryParameter("ColorLevel")?.toIntOrNull() ?: 7
val viewOnly = uri.getBooleanQueryParameter("ViewOnly", false)
val saveConnection = uri.getBooleanQueryParameter("SaveConnection", false)
val sshHost = uri.getQueryParameter("SshHost") ?: host
val sshPort = uri.getQueryParameter("SshPort")?.toIntOrNull() ?: 22
val sshUsername = uri.getQueryParameter("SshUsername") ?: ""
val sshPassword = uri.getQueryParameter("SshPassword") ?: ""
/**
* Generates a [ServerProfile] using this instance.
*/
fun toServerProfile() = ServerProfile(
name = connectionName,
host = host,
port = port,
username = username,
password = password,
securityType = securityType,
channelType = channelType,
colorLevel = colorLevel,
viewOnly = viewOnly,
sshHost = sshHost,
sshPort = sshPort,
sshUsername = sshUsername,
sshAuthType = ServerProfile.SSH_AUTH_PASSWORD,
sshPassword = sshPassword
)
override fun toString() = uriString
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,337 @@
/*
* Copyright (c) 2021 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.vnc
import android.view.KeyEvent
/**
* Implements mapping between [KeyEvent] key codes & X KeySyms.
*/
object XKeySymAndroid {
/**
* Returns X KeySym for given [keyCode].
* Returns 0 if no mapping is found.
*/
fun getKeySymForAndroidKeyCode(keyCode: Int): Int {
if (keyCode >= 0 && keyCode < AndroidKeyCodeToXKeySym.size)
return AndroidKeyCodeToXKeySym[keyCode]
else
return 0
}
fun updateKeyMap(keyCode: Int, xKeySym: Int) {
if (keyCode >= 0 && keyCode < AndroidKeyCodeToXKeySym.size)
AndroidKeyCodeToXKeySym[keyCode] = xKeySym
}
/**
* Lookup table for X KeySym.
*
* Each index represents a keycode from [KeyEvent] and
* value at that index represents the corresponding X KeySym.
*/
private val AndroidKeyCodeToXKeySym = intArrayOf(
0, // KEYCODE_UNKNOWN = 0
0, // KEYCODE_SOFT_LEFT = 1
0, // KEYCODE_SOFT_RIGHT = 2
0, // KEYCODE_HOME = 3
0, // KEYCODE_BACK = 4
0, // KEYCODE_CALL = 5
0, // KEYCODE_ENDCALL = 6
XKeySym.XK_0, // KEYCODE_0 = 7
XKeySym.XK_1, // KEYCODE_1 = 8
XKeySym.XK_2, // KEYCODE_2 = 9
XKeySym.XK_3, // KEYCODE_3 = 10
XKeySym.XK_4, // KEYCODE_4 = 11
XKeySym.XK_5, // KEYCODE_5 = 12
XKeySym.XK_6, // KEYCODE_6 = 13
XKeySym.XK_7, // KEYCODE_7 = 14
XKeySym.XK_8, // KEYCODE_8 = 15
XKeySym.XK_9, // KEYCODE_9 = 16
XKeySym.XK_asterisk, // KEYCODE_STAR = 17
XKeySym.XK_numbersign, // KEYCODE_POUND = 18
XKeySym.XK_Up, // KEYCODE_DPAD_UP = 19
XKeySym.XK_Down, // KEYCODE_DPAD_DOWN = 20
XKeySym.XK_Left, // KEYCODE_DPAD_LEFT = 21
XKeySym.XK_Right, // KEYCODE_DPAD_RIGHT = 22
0, // KEYCODE_DPAD_CENTER = 23
XKeySym.XF86XK_AudioRaiseVolume, // KEYCODE_VOLUME_UP = 24
XKeySym.XF86XK_AudioLowerVolume, // KEYCODE_VOLUME_DOWN = 25
0, // KEYCODE_POWER = 26
0, // KEYCODE_CAMERA = 27
0, // KEYCODE_CLEAR = 28
XKeySym.XK_a, // KEYCODE_A = 29
XKeySym.XK_b, // KEYCODE_B = 30
XKeySym.XK_c, // KEYCODE_C = 31
XKeySym.XK_d, // KEYCODE_D = 32
XKeySym.XK_e, // KEYCODE_E = 33
XKeySym.XK_f, // KEYCODE_F = 34
XKeySym.XK_g, // KEYCODE_G = 35
XKeySym.XK_h, // KEYCODE_H = 36
XKeySym.XK_i, // KEYCODE_I = 37
XKeySym.XK_j, // KEYCODE_J = 38
XKeySym.XK_k, // KEYCODE_K = 39
XKeySym.XK_l, // KEYCODE_L = 40
XKeySym.XK_m, // KEYCODE_M = 41
XKeySym.XK_n, // KEYCODE_N = 42
XKeySym.XK_o, // KEYCODE_O = 43
XKeySym.XK_p, // KEYCODE_P = 44
XKeySym.XK_q, // KEYCODE_Q = 45
XKeySym.XK_r, // KEYCODE_R = 46
XKeySym.XK_s, // KEYCODE_S = 47
XKeySym.XK_t, // KEYCODE_T = 48
XKeySym.XK_u, // KEYCODE_U = 49
XKeySym.XK_v, // KEYCODE_V = 50
XKeySym.XK_w, // KEYCODE_W = 51
XKeySym.XK_x, // KEYCODE_X = 52
XKeySym.XK_y, // KEYCODE_Y = 53
XKeySym.XK_z, // KEYCODE_Z = 54
XKeySym.XK_comma, // KEYCODE_COMMA = 55
XKeySym.XK_period, // KEYCODE_PERIOD = 56
XKeySym.XK_Alt_L, // KEYCODE_ALT_LEFT = 57
XKeySym.XK_Alt_R, // KEYCODE_ALT_RIGHT = 58
XKeySym.XK_Shift_L, // KEYCODE_SHIFT_LEFT = 59
XKeySym.XK_Shift_R, // KEYCODE_SHIFT_RIGHT = 60
XKeySym.XK_Tab, // KEYCODE_TAB = 61
XKeySym.XK_space, // KEYCODE_SPACE = 62
0, // KEYCODE_SYM = 63
0, // KEYCODE_EXPLORER = 64
0, // KEYCODE_ENVELOPE = 65
XKeySym.XK_Return, // KEYCODE_ENTER = 66
XKeySym.XK_BackSpace, // KEYCODE_DEL = 67
XKeySym.XK_grave, // KEYCODE_GRAVE = 68
XKeySym.XK_minus, // KEYCODE_MINUS = 69
XKeySym.XK_equal, // KEYCODE_EQUALS = 70
XKeySym.XK_bracketleft, // KEYCODE_LEFT_BRACKET = 71
XKeySym.XK_bracketright, // KEYCODE_RIGHT_BRACKET = 72
XKeySym.XK_backslash, // KEYCODE_BACKSLASH = 73
XKeySym.XK_semicolon, // KEYCODE_SEMICOLON = 74
XKeySym.XK_apostrophe, // KEYCODE_APOSTROPHE = 75
XKeySym.XK_slash, // KEYCODE_SLASH = 76
XKeySym.XK_at, // KEYCODE_AT = 77
0, // KEYCODE_NUM = 78
0, // KEYCODE_HEADSETHOOK = 79
0, // KEYCODE_FOCUS = 80
XKeySym.XK_plus, // KEYCODE_PLUS = 81
XKeySym.XK_Menu, // KEYCODE_MENU = 82
0, // KEYCODE_NOTIFICATION = 83
0, // KEYCODE_SEARCH = 84
0, // KEYCODE_MEDIA_PLAY_PAUSE = 85
0, // KEYCODE_MEDIA_STOP = 86
0, // KEYCODE_MEDIA_NEXT = 87
0, // KEYCODE_MEDIA_PREVIOUS = 88
0, // KEYCODE_MEDIA_REWIND = 89
0, // KEYCODE_MEDIA_FAST_FORWARD = 90
0, // KEYCODE_MUTE = 91
XKeySym.XK_Page_Up, // KEYCODE_PAGE_UP = 92
XKeySym.XK_Page_Down, // KEYCODE_PAGE_DOWN = 93
0, // KEYCODE_PICTSYMBOLS = 94
0, // KEYCODE_SWITCH_CHARSET = 95
0, // KEYCODE_BUTTON_A = 96
0, // KEYCODE_BUTTON_B = 97
0, // KEYCODE_BUTTON_C = 98
0, // KEYCODE_BUTTON_X = 99
0, // KEYCODE_BUTTON_Y = 100
0, // KEYCODE_BUTTON_Z = 101
0, // KEYCODE_BUTTON_L1 = 102
0, // KEYCODE_BUTTON_R1 = 103
0, // KEYCODE_BUTTON_L2 = 104
0, // KEYCODE_BUTTON_R2 = 105
0, // KEYCODE_BUTTON_THUMBL = 106
0, // KEYCODE_BUTTON_THUMBR = 107
0, // KEYCODE_BUTTON_START = 108
0, // KEYCODE_BUTTON_SELECT = 109
0, // KEYCODE_BUTTON_MODE = 110
XKeySym.XK_Escape, // KEYCODE_ESCAPE = 111
XKeySym.XK_Delete, // KEYCODE_FORWARD_DEL = 112
XKeySym.XK_Control_L, // KEYCODE_CTRL_LEFT = 113
XKeySym.XK_Control_R, // KEYCODE_CTRL_RIGHT = 114
XKeySym.XK_Caps_Lock, // KEYCODE_CAPS_LOCK = 115
XKeySym.XK_Scroll_Lock, // KEYCODE_SCROLL_LOCK = 116
XKeySym.XK_Super_L, // KEYCODE_META_LEFT = 117
XKeySym.XK_Super_R, // KEYCODE_META_RIGHT = 118
0, // KEYCODE_FUNCTION = 119
XKeySym.XK_Sys_Req, // KEYCODE_SYSRQ = 120
XKeySym.XK_Break, // KEYCODE_BREAK = 121
XKeySym.XK_Home, // KEYCODE_MOVE_HOME = 122
XKeySym.XK_End, // KEYCODE_MOVE_END = 123
XKeySym.XK_Insert, // KEYCODE_INSERT = 124
0, // KEYCODE_FORWARD = 125
0, // KEYCODE_MEDIA_PLAY = 126
0, // KEYCODE_MEDIA_PAUSE = 127
0, // KEYCODE_MEDIA_CLOSE = 128
0, // KEYCODE_MEDIA_EJECT = 129
0, // KEYCODE_MEDIA_RECORD = 130
XKeySym.XK_F1, // KEYCODE_F1 = 131
XKeySym.XK_F2, // KEYCODE_F2 = 132
XKeySym.XK_F3, // KEYCODE_F3 = 133
XKeySym.XK_F4, // KEYCODE_F4 = 134
XKeySym.XK_F5, // KEYCODE_F5 = 135
XKeySym.XK_F6, // KEYCODE_F6 = 136
XKeySym.XK_F7, // KEYCODE_F7 = 137
XKeySym.XK_F8, // KEYCODE_F8 = 138
XKeySym.XK_F9, // KEYCODE_F9 = 139
XKeySym.XK_F10, // KEYCODE_F10 = 140
XKeySym.XK_F11, // KEYCODE_F11 = 141
XKeySym.XK_F12, // KEYCODE_F12 = 142
XKeySym.XK_Num_Lock, // KEYCODE_NUM_LOCK = 143
XKeySym.XK_KP_0, // KEYCODE_NUMPAD_0 = 144
XKeySym.XK_KP_1, // KEYCODE_NUMPAD_1 = 145
XKeySym.XK_KP_2, // KEYCODE_NUMPAD_2 = 146
XKeySym.XK_KP_3, // KEYCODE_NUMPAD_3 = 147
XKeySym.XK_KP_4, // KEYCODE_NUMPAD_4 = 148
XKeySym.XK_KP_5, // KEYCODE_NUMPAD_5 = 149
XKeySym.XK_KP_6, // KEYCODE_NUMPAD_6 = 150
XKeySym.XK_KP_7, // KEYCODE_NUMPAD_7 = 151
XKeySym.XK_KP_8, // KEYCODE_NUMPAD_8 = 152
XKeySym.XK_KP_9, // KEYCODE_NUMPAD_9 = 153
XKeySym.XK_KP_Divide, // KEYCODE_NUMPAD_DIVIDE = 154
XKeySym.XK_KP_Multiply, // KEYCODE_NUMPAD_MULTIPLY = 155
XKeySym.XK_KP_Subtract, // KEYCODE_NUMPAD_SUBTRACT = 156
XKeySym.XK_KP_Add, // KEYCODE_NUMPAD_ADD = 157
XKeySym.XK_KP_Decimal, // KEYCODE_NUMPAD_DOT = 158
XKeySym.XK_KP_Separator, // KEYCODE_NUMPAD_COMMA = 159
XKeySym.XK_KP_Enter, // KEYCODE_NUMPAD_ENTER = 160
XKeySym.XK_KP_Equal, // KEYCODE_NUMPAD_EQUALS = 161
0, // KEYCODE_NUMPAD_LEFT_PAREN = 162
0, // KEYCODE_NUMPAD_RIGHT_PAREN = 163
XKeySym.XF86XK_AudioMute, // KEYCODE_VOLUME_MUTE = 164
0, // KEYCODE_INFO = 165
0, // KEYCODE_CHANNEL_UP = 166
0, // KEYCODE_CHANNEL_DOWN = 167
0, // KEYCODE_ZOOM_IN = 168
0, // KEYCODE_ZOOM_OUT = 169
0, // KEYCODE_TV = 170
0, // KEYCODE_WINDOW = 171
0, // KEYCODE_GUIDE = 172
0, // KEYCODE_DVR = 173
0, // KEYCODE_BOOKMARK = 174
0, // KEYCODE_CAPTIONS = 175
0, // KEYCODE_SETTINGS = 176
0, // KEYCODE_TV_POWER = 177
0, // KEYCODE_TV_INPUT = 178
0, // KEYCODE_STB_POWER = 179
0, // KEYCODE_STB_INPUT = 180
0, // KEYCODE_AVR_POWER = 181
0, // KEYCODE_AVR_INPUT = 182
0, // KEYCODE_PROG_RED = 183
0, // KEYCODE_PROG_GREEN = 184
0, // KEYCODE_PROG_YELLOW = 185
0, // KEYCODE_PROG_BLUE = 186
0, // KEYCODE_APP_SWITCH = 187
0, // KEYCODE_BUTTON_1 = 188
0, // KEYCODE_BUTTON_2 = 189
0, // KEYCODE_BUTTON_3 = 190
0, // KEYCODE_BUTTON_4 = 191
0, // KEYCODE_BUTTON_5 = 192
0, // KEYCODE_BUTTON_6 = 193
0, // KEYCODE_BUTTON_7 = 194
0, // KEYCODE_BUTTON_8 = 195
0, // KEYCODE_BUTTON_9 = 196
0, // KEYCODE_BUTTON_10 = 197
0, // KEYCODE_BUTTON_11 = 198
0, // KEYCODE_BUTTON_12 = 199
0, // KEYCODE_BUTTON_13 = 200
0, // KEYCODE_BUTTON_14 = 201
0, // KEYCODE_BUTTON_15 = 202
0, // KEYCODE_BUTTON_16 = 203
0, // KEYCODE_LANGUAGE_SWITCH = 204
/* We currently have no mapping for rest of the key codes.
So these are commented to reduce the lookup table size.
0, // KEYCODE_MANNER_MODE = 205
0, // KEYCODE_3D_MODE = 206
0, // KEYCODE_CONTACTS = 207
0, // KEYCODE_CALENDAR = 208
0, // KEYCODE_MUSIC = 209
0, // KEYCODE_CALCULATOR = 210
0, // KEYCODE_ZENKAKU_HANKAKU = 211
0, // KEYCODE_EISU = 212
0, // KEYCODE_MUHENKAN = 213
0, // KEYCODE_HENKAN = 214
0, // KEYCODE_KATAKANA_HIRAGANA = 215
0, // KEYCODE_YEN = 216
0, // KEYCODE_RO = 217
0, // KEYCODE_KANA = 218
0, // KEYCODE_ASSIST = 219
0, // KEYCODE_BRIGHTNESS_DOWN = 220
0, // KEYCODE_BRIGHTNESS_UP = 221
0, // KEYCODE_MEDIA_AUDIO_TRACK = 222
0, // KEYCODE_SLEEP = 223
0, // KEYCODE_WAKEUP = 224
0, // KEYCODE_PAIRING = 225
0, // KEYCODE_MEDIA_TOP_MENU = 226
0, // KEYCODE_11 = 227
0, // KEYCODE_12 = 228
0, // KEYCODE_LAST_CHANNEL = 229
0, // KEYCODE_TV_DATA_SERVICE = 230
0, // KEYCODE_VOICE_ASSIST = 231
0, // KEYCODE_TV_RADIO_SERVICE = 232
0, // KEYCODE_TV_TELETEXT = 233
0, // KEYCODE_TV_NUMBER_ENTRY = 234
0, // KEYCODE_TV_TERRESTRIAL_ANALOG = 235
0, // KEYCODE_TV_TERRESTRIAL_DIGITAL = 236
0, // KEYCODE_TV_SATELLITE = 237
0, // KEYCODE_TV_SATELLITE_BS = 238
0, // KEYCODE_TV_SATELLITE_CS = 239
0, // KEYCODE_TV_SATELLITE_SERVICE = 240
0, // KEYCODE_TV_NETWORK = 241
0, // KEYCODE_TV_ANTENNA_CABLE = 242
0, // KEYCODE_TV_INPUT_HDMI_1 = 243
0, // KEYCODE_TV_INPUT_HDMI_2 = 244
0, // KEYCODE_TV_INPUT_HDMI_3 = 245
0, // KEYCODE_TV_INPUT_HDMI_4 = 246
0, // KEYCODE_TV_INPUT_COMPOSITE_1 = 247
0, // KEYCODE_TV_INPUT_COMPOSITE_2 = 248
0, // KEYCODE_TV_INPUT_COMPONENT_1 = 249
0, // KEYCODE_TV_INPUT_COMPONENT_2 = 250
0, // KEYCODE_TV_INPUT_VGA_1 = 251
0, // KEYCODE_TV_AUDIO_DESCRIPTION = 252
0, // KEYCODE_TV_AUDIO_DESCRIPTION_MIX_UP = 253
0, // KEYCODE_TV_AUDIO_DESCRIPTION_MIX_DOWN = 254
0, // KEYCODE_TV_ZOOM_MODE = 255
0, // KEYCODE_TV_CONTENTS_MENU = 256
0, // KEYCODE_TV_MEDIA_CONTEXT_MENU = 257
0, // KEYCODE_TV_TIMER_PROGRAMMING = 258
0, // KEYCODE_HELP = 259
0, // KEYCODE_NAVIGATE_PREVIOUS = 260
0, // KEYCODE_NAVIGATE_NEXT = 261
0, // KEYCODE_NAVIGATE_IN = 262
0, // KEYCODE_NAVIGATE_OUT = 263
0, // KEYCODE_STEM_PRIMARY = 264
0, // KEYCODE_STEM_1 = 265
0, // KEYCODE_STEM_2 = 266
0, // KEYCODE_STEM_3 = 267
0, // KEYCODE_DPAD_UP_LEFT = 268
0, // KEYCODE_DPAD_DOWN_LEFT = 269
0, // KEYCODE_DPAD_UP_RIGHT = 270
0, // KEYCODE_DPAD_DOWN_RIGHT = 271
0, // KEYCODE_MEDIA_SKIP_FORWARD = 272
0, // KEYCODE_MEDIA_SKIP_BACKWARD = 273
0, // KEYCODE_MEDIA_STEP_FORWARD = 274
0, // KEYCODE_MEDIA_STEP_BACKWARD = 275
0, // KEYCODE_SOFT_SLEEP = 276
0, // KEYCODE_CUT = 277
0, // KEYCODE_COPY = 278
0, // KEYCODE_PASTE = 279
0, // KEYCODE_SYSTEM_NAVIGATION_UP = 280
0, // KEYCODE_SYSTEM_NAVIGATION_DOWN = 281
0, // KEYCODE_SYSTEM_NAVIGATION_LEFT = 282
0, // KEYCODE_SYSTEM_NAVIGATION_RIGHT = 283
0, // KEYCODE_ALL_APPS = 284
0, // KEYCODE_REFRESH = 285
0, // KEYCODE_THUMBS_UP = 286
0, // KEYCODE_THUMBS_DOWN = 287
0 // KEYCODE_PROFILE_SWITCH = 288
*/
)
}

View File

@@ -0,0 +1,819 @@
/*
* Copyright (c) 2021 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.vnc
/**
* Implements mapping between Unicode code-points and X KeySyms.
*/
object XKeySymUnicode {
/**
* Returns X KeySym for [uChar].
*/
fun getKeySymForUnicodeChar(uChar: Int): Int {
return if (uChar < 0x100) uChar else uChar + 0x01000000
}
/**
* Returns legacy X KeySym for given [uChar].
* Returns 0 if no legacy KeySym is found.
*/
fun getLegacyKeySymForUnicodeChar(uChar: Int): Int {
// Check if character is outside of our map
if (uChar < UnicodeToLegacyKeysym[0] || uChar > UnicodeToLegacyKeysym[UnicodeToLegacyKeysym.size - 2])
return 0
// Binary Search on the 'first column' of map
var low = 0
var high = (UnicodeToLegacyKeysym.size / 2) - 1
while (low <= high) {
val mid = (low + high) / 2
val midChar = UnicodeToLegacyKeysym[mid * 2]
when {
uChar == midChar -> return UnicodeToLegacyKeysym[mid * 2 + 1]
uChar < midChar -> high = mid - 1
uChar > midChar -> low = mid + 1
}
}
return 0
}
/**
* This array maps Unicode code points to X KeySyms that were used before the
* introduction of Unicode KeySyms to X Windows System Protocol.
*
*
* Source of the map: https://www.cl.cam.ac.uk/~mgk25/ucs/keysym2ucs.c
*
*
* This array is used as 2D array with stride = 2.
* First column is kept sorted to allow binary search.
*/
private val UnicodeToLegacyKeysym = intArrayOf(
// Unicode, X KeySym
0x100, 0x3C0,
0x101, 0x3E0,
0x102, 0x1C3,
0x103, 0x1E3,
0x104, 0x1A1,
0x105, 0x1B1,
0x106, 0x1C6,
0x107, 0x1E6,
0x108, 0x2C6,
0x109, 0x2E6,
0x10A, 0x2C5,
0x10B, 0x2E5,
0x10C, 0x1C8,
0x10D, 0x1E8,
0x10E, 0x1CF,
0x10F, 0x1EF,
0x110, 0x1D0,
0x111, 0x1F0,
0x112, 0x3AA,
0x113, 0x3BA,
0x116, 0x3CC,
0x117, 0x3EC,
0x118, 0x1CA,
0x119, 0x1EA,
0x11A, 0x1CC,
0x11B, 0x1EC,
0x11C, 0x2D8,
0x11D, 0x2F8,
0x11E, 0x2AB,
0x11F, 0x2BB,
0x120, 0x2D5,
0x121, 0x2F5,
0x122, 0x3AB,
0x123, 0x3BB,
0x124, 0x2A6,
0x125, 0x2B6,
0x126, 0x2A1,
0x127, 0x2B1,
0x128, 0x3A5,
0x129, 0x3B5,
0x12A, 0x3CF,
0x12B, 0x3EF,
0x12E, 0x3C7,
0x12F, 0x3E7,
0x130, 0x2A9,
0x131, 0x2B9,
0x134, 0x2AC,
0x135, 0x2BC,
0x136, 0x3D3,
0x137, 0x3F3,
0x138, 0x3A2,
0x139, 0x1C5,
0x13A, 0x1E5,
0x13B, 0x3A6,
0x13C, 0x3B6,
0x13D, 0x1A5,
0x13E, 0x1B5,
0x141, 0x1A3,
0x142, 0x1B3,
0x143, 0x1D1,
0x144, 0x1F1,
0x145, 0x3D1,
0x146, 0x3F1,
0x147, 0x1D2,
0x148, 0x1F2,
0x14A, 0x3BD,
0x14B, 0x3BF,
0x14C, 0x3D2,
0x14D, 0x3F2,
0x150, 0x1D5,
0x151, 0x1F5,
0x152, 0x13BC,
0x153, 0x13BD,
0x154, 0x1C0,
0x155, 0x1E0,
0x156, 0x3A3,
0x157, 0x3B3,
0x158, 0x1D8,
0x159, 0x1F8,
0x15A, 0x1A6,
0x15B, 0x1B6,
0x15C, 0x2DE,
0x15D, 0x2FE,
0x15E, 0x1AA,
0x15F, 0x1BA,
0x160, 0x1A9,
0x161, 0x1B9,
0x162, 0x1DE,
0x163, 0x1FE,
0x164, 0x1AB,
0x165, 0x1BB,
0x166, 0x3AC,
0x167, 0x3BC,
0x168, 0x3DD,
0x169, 0x3FD,
0x16A, 0x3DE,
0x16B, 0x3FE,
0x16C, 0x2DD,
0x16D, 0x2FD,
0x16E, 0x1D9,
0x16F, 0x1F9,
0x170, 0x1DB,
0x171, 0x1FB,
0x172, 0x3D9,
0x173, 0x3F9,
0x178, 0x13BE,
0x179, 0x1AC,
0x17A, 0x1BC,
0x17B, 0x1AF,
0x17C, 0x1BF,
0x17D, 0x1AE,
0x17E, 0x1BE,
0x192, 0x8F6,
0x2C7, 0x1B7,
0x2D8, 0x1A2,
0x2D9, 0x1FF,
0x2DB, 0x1B2,
0x2DD, 0x1BD,
0x385, 0x7AE,
0x386, 0x7A1,
0x388, 0x7A2,
0x389, 0x7A3,
0x38A, 0x7A4,
0x38C, 0x7A7,
0x38E, 0x7A8,
0x38F, 0x7AB,
0x390, 0x7B6,
0x391, 0x7C1,
0x392, 0x7C2,
0x393, 0x7C3,
0x394, 0x7C4,
0x395, 0x7C5,
0x396, 0x7C6,
0x397, 0x7C7,
0x398, 0x7C8,
0x399, 0x7C9,
0x39A, 0x7CA,
0x39B, 0x7CB,
0x39C, 0x7CC,
0x39D, 0x7CD,
0x39E, 0x7CE,
0x39F, 0x7CF,
0x3A0, 0x7D0,
0x3A1, 0x7D1,
0x3A3, 0x7D2,
0x3A4, 0x7D4,
0x3A5, 0x7D5,
0x3A6, 0x7D6,
0x3A7, 0x7D7,
0x3A8, 0x7D8,
0x3A9, 0x7D9,
0x3AA, 0x7A5,
0x3AB, 0x7A9,
0x3AC, 0x7B1,
0x3AD, 0x7B2,
0x3AE, 0x7B3,
0x3AF, 0x7B4,
0x3B0, 0x7BA,
0x3B1, 0x7E1,
0x3B2, 0x7E2,
0x3B3, 0x7E3,
0x3B4, 0x7E4,
0x3B5, 0x7E5,
0x3B6, 0x7E6,
0x3B7, 0x7E7,
0x3B8, 0x7E8,
0x3B9, 0x7E9,
0x3BA, 0x7EA,
0x3BB, 0x7EB,
0x3BC, 0x7EC,
0x3BD, 0x7ED,
0x3BE, 0x7EE,
0x3BF, 0x7EF,
0x3C0, 0x7F0,
0x3C1, 0x7F1,
0x3C2, 0x7F3,
0x3C3, 0x7F2,
0x3C4, 0x7F4,
0x3C5, 0x7F5,
0x3C6, 0x7F6,
0x3C7, 0x7F7,
0x3C8, 0x7F8,
0x3C9, 0x7F9,
0x3CA, 0x7B5,
0x3CB, 0x7B9,
0x3CC, 0x7B7,
0x3CD, 0x7B8,
0x3CE, 0x7BB,
0x401, 0x6B3,
0x402, 0x6B1,
0x403, 0x6B2,
0x404, 0x6B4,
0x405, 0x6B5,
0x406, 0x6B6,
0x407, 0x6B7,
0x408, 0x6B8,
0x409, 0x6B9,
0x40A, 0x6BA,
0x40B, 0x6BB,
0x40C, 0x6BC,
0x40E, 0x6BE,
0x40F, 0x6BF,
0x410, 0x6E1,
0x411, 0x6E2,
0x412, 0x6F7,
0x413, 0x6E7,
0x414, 0x6E4,
0x415, 0x6E5,
0x416, 0x6F6,
0x417, 0x6FA,
0x418, 0x6E9,
0x419, 0x6EA,
0x41A, 0x6EB,
0x41B, 0x6EC,
0x41C, 0x6ED,
0x41D, 0x6EE,
0x41E, 0x6EF,
0x41F, 0x6F0,
0x420, 0x6F2,
0x421, 0x6F3,
0x422, 0x6F4,
0x423, 0x6F5,
0x424, 0x6E6,
0x425, 0x6E8,
0x426, 0x6E3,
0x427, 0x6FE,
0x428, 0x6FB,
0x429, 0x6FD,
0x42A, 0x6FF,
0x42B, 0x6F9,
0x42C, 0x6F8,
0x42D, 0x6FC,
0x42E, 0x6E0,
0x42F, 0x6F1,
0x430, 0x6C1,
0x431, 0x6C2,
0x432, 0x6D7,
0x433, 0x6C7,
0x434, 0x6C4,
0x435, 0x6C5,
0x436, 0x6D6,
0x437, 0x6DA,
0x438, 0x6C9,
0x439, 0x6CA,
0x43A, 0x6CB,
0x43B, 0x6CC,
0x43C, 0x6CD,
0x43D, 0x6CE,
0x43E, 0x6CF,
0x43F, 0x6D0,
0x440, 0x6D2,
0x441, 0x6D3,
0x442, 0x6D4,
0x443, 0x6D5,
0x444, 0x6C6,
0x445, 0x6C8,
0x446, 0x6C3,
0x447, 0x6DE,
0x448, 0x6DB,
0x449, 0x6DD,
0x44A, 0x6DF,
0x44B, 0x6D9,
0x44C, 0x6D8,
0x44D, 0x6DC,
0x44E, 0x6C0,
0x44F, 0x6D1,
0x451, 0x6A3,
0x452, 0x6A1,
0x453, 0x6A2,
0x454, 0x6A4,
0x455, 0x6A5,
0x456, 0x6A6,
0x457, 0x6A7,
0x458, 0x6A8,
0x459, 0x6A9,
0x45A, 0x6AA,
0x45B, 0x6AB,
0x45C, 0x6AC,
0x45E, 0x6AE,
0x45F, 0x6AF,
0x5D0, 0xCE0,
0x5D1, 0xCE1,
0x5D2, 0xCE2,
0x5D3, 0xCE3,
0x5D4, 0xCE4,
0x5D5, 0xCE5,
0x5D6, 0xCE6,
0x5D7, 0xCE7,
0x5D8, 0xCE8,
0x5D9, 0xCE9,
0x5DA, 0xCEA,
0x5DB, 0xCEB,
0x5DC, 0xCEC,
0x5DD, 0xCED,
0x5DE, 0xCEE,
0x5DF, 0xCEF,
0x5E0, 0xCF0,
0x5E1, 0xCF1,
0x5E2, 0xCF2,
0x5E3, 0xCF3,
0x5E4, 0xCF4,
0x5E5, 0xCF5,
0x5E6, 0xCF6,
0x5E7, 0xCF7,
0x5E8, 0xCF8,
0x5E9, 0xCF9,
0x5EA, 0xCFA,
0x60C, 0x5AC,
0x61B, 0x5BB,
0x61F, 0x5BF,
0x621, 0x5C1,
0x622, 0x5C2,
0x623, 0x5C3,
0x624, 0x5C4,
0x625, 0x5C5,
0x626, 0x5C6,
0x627, 0x5C7,
0x628, 0x5C8,
0x629, 0x5C9,
0x62A, 0x5CA,
0x62B, 0x5CB,
0x62C, 0x5CC,
0x62D, 0x5CD,
0x62E, 0x5CE,
0x62F, 0x5CF,
0x630, 0x5D0,
0x631, 0x5D1,
0x632, 0x5D2,
0x633, 0x5D3,
0x634, 0x5D4,
0x635, 0x5D5,
0x636, 0x5D6,
0x637, 0x5D7,
0x638, 0x5D8,
0x639, 0x5D9,
0x63A, 0x5DA,
0x640, 0x5E0,
0x641, 0x5E1,
0x642, 0x5E2,
0x643, 0x5E3,
0x644, 0x5E4,
0x645, 0x5E5,
0x646, 0x5E6,
0x647, 0x5E7,
0x648, 0x5E8,
0x649, 0x5E9,
0x64A, 0x5EA,
0x64B, 0x5EB,
0x64C, 0x5EC,
0x64D, 0x5ED,
0x64E, 0x5EE,
0x64F, 0x5EF,
0x650, 0x5F0,
0x651, 0x5F1,
0x652, 0x5F2,
0xE01, 0xDA1,
0xE02, 0xDA2,
0xE03, 0xDA3,
0xE04, 0xDA4,
0xE05, 0xDA5,
0xE06, 0xDA6,
0xE07, 0xDA7,
0xE08, 0xDA8,
0xE09, 0xDA9,
0xE0A, 0xDAA,
0xE0B, 0xDAB,
0xE0C, 0xDAC,
0xE0D, 0xDAD,
0xE0E, 0xDAE,
0xE0F, 0xDAF,
0xE10, 0xDB0,
0xE11, 0xDB1,
0xE12, 0xDB2,
0xE13, 0xDB3,
0xE14, 0xDB4,
0xE15, 0xDB5,
0xE16, 0xDB6,
0xE17, 0xDB7,
0xE18, 0xDB8,
0xE19, 0xDB9,
0xE1A, 0xDBA,
0xE1B, 0xDBB,
0xE1C, 0xDBC,
0xE1D, 0xDBD,
0xE1E, 0xDBE,
0xE1F, 0xDBF,
0xE20, 0xDC0,
0xE21, 0xDC1,
0xE22, 0xDC2,
0xE23, 0xDC3,
0xE24, 0xDC4,
0xE25, 0xDC5,
0xE26, 0xDC6,
0xE27, 0xDC7,
0xE28, 0xDC8,
0xE29, 0xDC9,
0xE2A, 0xDCA,
0xE2B, 0xDCB,
0xE2C, 0xDCC,
0xE2D, 0xDCD,
0xE2E, 0xDCE,
0xE2F, 0xDCF,
0xE30, 0xDD0,
0xE31, 0xDD1,
0xE32, 0xDD2,
0xE33, 0xDD3,
0xE34, 0xDD4,
0xE35, 0xDD5,
0xE36, 0xDD6,
0xE37, 0xDD7,
0xE38, 0xDD8,
0xE39, 0xDD9,
0xE3A, 0xDDA,
0xE3F, 0xDDF,
0xE40, 0xDE0,
0xE41, 0xDE1,
0xE42, 0xDE2,
0xE43, 0xDE3,
0xE44, 0xDE4,
0xE45, 0xDE5,
0xE46, 0xDE6,
0xE47, 0xDE7,
0xE48, 0xDE8,
0xE49, 0xDE9,
0xE4A, 0xDEA,
0xE4B, 0xDEB,
0xE4C, 0xDEC,
0xE4D, 0xDED,
0xE50, 0xDF0,
0xE51, 0xDF1,
0xE52, 0xDF2,
0xE53, 0xDF3,
0xE54, 0xDF4,
0xE55, 0xDF5,
0xE56, 0xDF6,
0xE57, 0xDF7,
0xE58, 0xDF8,
0xE59, 0xDF9,
0x11A8, 0xED4,
0x11A9, 0xED5,
0x11AA, 0xED6,
0x11AB, 0xED7,
0x11AC, 0xED8,
0x11AD, 0xED9,
0x11AE, 0xEDA,
0x11AF, 0xEDB,
0x11B0, 0xEDC,
0x11B1, 0xEDD,
0x11B2, 0xEDE,
0x11B3, 0xEDF,
0x11B4, 0xEE0,
0x11B5, 0xEE1,
0x11B6, 0xEE2,
0x11B7, 0xEE3,
0x11B8, 0xEE4,
0x11B9, 0xEE5,
0x11BA, 0xEE6,
0x11BB, 0xEE7,
0x11BC, 0xEE8,
0x11BD, 0xEE9,
0x11BE, 0xEEA,
0x11BF, 0xEEB,
0x11C0, 0xEEC,
0x11C1, 0xEED,
0x11C2, 0xEEE,
0x11EB, 0xEF8,
0x11F0, 0xEF9,
0x11F9, 0xEFA,
0x2002, 0xAA2,
0x2003, 0xAA1,
0x2004, 0xAA3,
0x2005, 0xAA4,
0x2007, 0xAA5,
0x2008, 0xAA6,
0x2009, 0xAA7,
0x200A, 0xAA8,
0x2012, 0xABB,
0x2013, 0xAAA,
0x2014, 0xAA9,
0x2015, 0x7AF,
0x2017, 0xCDF,
0x2018, 0xAD0,
0x2019, 0xAD1,
0x201A, 0xAFD,
0x201C, 0xAD2,
0x201D, 0xAD3,
0x201E, 0xAFE,
0x2020, 0xAF1,
0x2021, 0xAF2,
0x2022, 0xAE6,
0x2025, 0xAAF,
0x2026, 0xAAE,
0x2032, 0xAD6,
0x2033, 0xAD7,
0x2038, 0xAFC,
0x203E, 0x47E,
0x20A9, 0xEFF,
0x20AC, 0x20AC,
0x20AC, 0x13A4,
0x2105, 0xAB8,
0x2116, 0x6B0,
0x2117, 0xAFB,
0x211E, 0xAD4,
0x2122, 0xAC9,
0x2153, 0xAB0,
0x2154, 0xAB1,
0x2155, 0xAB2,
0x2156, 0xAB3,
0x2157, 0xAB4,
0x2158, 0xAB5,
0x2159, 0xAB6,
0x215A, 0xAB7,
0x215B, 0xAC3,
0x215C, 0xAC4,
0x215D, 0xAC5,
0x215E, 0xAC6,
0x2190, 0x8FB,
0x2191, 0x8FC,
0x2192, 0x8FD,
0x2193, 0x8FE,
0x21D2, 0x8CE,
0x21D4, 0x8CD,
0x2202, 0x8EF,
0x2207, 0x8C5,
0x2218, 0xBCA,
0x221A, 0x8D6,
0x221D, 0x8C1,
0x221E, 0x8C2,
0x2227, 0x8DE,
0x2227, 0xBA9,
0x2228, 0x8DF,
0x2228, 0xBA8,
0x2229, 0x8DC,
0x2229, 0xBC3,
0x222A, 0xBD6,
0x222A, 0x8DD,
0x222B, 0x8BF,
0x2234, 0x8C0,
0x223C, 0x8C8,
0x2243, 0x8C9,
0x2260, 0x8BD,
0x2261, 0x8CF,
0x2264, 0x8BC,
0x2265, 0x8BE,
0x2282, 0xBDA,
0x2282, 0x8DA,
0x2283, 0xBD8,
0x2283, 0x8DB,
0x22A2, 0xBDC,
0x22A3, 0xBFC,
0x22A4, 0xBCE,
0x22A5, 0xBC2,
0x2308, 0xBD3,
0x230A, 0xBC4,
0x2315, 0xAFA,
0x2320, 0x8A4,
0x2321, 0x8A5,
0x2329, 0xABC,
0x232A, 0xABE,
0x2395, 0xBCC,
0x239B, 0x8AB,
0x239D, 0x8AC,
0x239E, 0x8AD,
0x23A0, 0x8AE,
0x23A1, 0x8A7,
0x23A3, 0x8A8,
0x23A4, 0x8A9,
0x23A6, 0x8AA,
0x23A8, 0x8AF,
0x23AC, 0x8B0,
0x23B7, 0x8A1,
0x23BA, 0x9EF,
0x23BB, 0x9F0,
0x23BC, 0x9F2,
0x23BD, 0x9F3,
0x2409, 0x9E2,
0x240A, 0x9E5,
0x240B, 0x9E9,
0x240C, 0x9E3,
0x240D, 0x9E4,
0x2424, 0x9E8,
0x2500, 0x9F1,
0x2500, 0x8A3,
0x2502, 0x8A6,
0x2502, 0x9F8,
0x250C, 0x9EC,
0x250C, 0x8A2,
0x2510, 0x9EB,
0x2514, 0x9ED,
0x2518, 0x9EA,
0x251C, 0x9F4,
0x2524, 0x9F5,
0x252C, 0x9F7,
0x2534, 0x9F6,
0x253C, 0x9EE,
0x2592, 0x9E1,
0x25AA, 0xAE7,
0x25AB, 0xAE1,
0x25AC, 0xADB,
0x25AD, 0xAE2,
0x25AE, 0xADF,
0x25AF, 0xACF,
0x25B2, 0xAE8,
0x25B3, 0xAE3,
0x25B6, 0xADD,
0x25B7, 0xACD,
0x25BC, 0xAE9,
0x25BD, 0xAE4,
0x25C0, 0xADC,
0x25C1, 0xACC,
0x25C6, 0x9E0,
0x25CB, 0xBCF,
0x25CB, 0xACE,
0x25CF, 0xADE,
0x25E6, 0xAE0,
0x2606, 0xAE5,
0x260E, 0xAF9,
0x2613, 0xACA,
0x261C, 0xAEA,
0x261E, 0xAEB,
0x2640, 0xAF8,
0x2642, 0xAF7,
0x2663, 0xAEC,
0x2665, 0xAEE,
0x2666, 0xAED,
0x266D, 0xAF6,
0x266F, 0xAF5,
0x2713, 0xAF3,
0x2717, 0xAF4,
0x271D, 0xAD9,
0x2720, 0xAF0,
0x3001, 0x4A4,
0x3002, 0x4A1,
0x300C, 0x4A2,
0x300D, 0x4A3,
0x309B, 0x4DE,
0x309C, 0x4DF,
0x30A1, 0x4A7,
0x30A2, 0x4B1,
0x30A3, 0x4A8,
0x30A4, 0x4B2,
0x30A5, 0x4A9,
0x30A6, 0x4B3,
0x30A7, 0x4AA,
0x30A8, 0x4B4,
0x30A9, 0x4AB,
0x30AA, 0x4B5,
0x30AB, 0x4B6,
0x30AD, 0x4B7,
0x30AF, 0x4B8,
0x30B1, 0x4B9,
0x30B3, 0x4BA,
0x30B5, 0x4BB,
0x30B7, 0x4BC,
0x30B9, 0x4BD,
0x30BB, 0x4BE,
0x30BD, 0x4BF,
0x30BF, 0x4C0,
0x30C1, 0x4C1,
0x30C3, 0x4AF,
0x30C4, 0x4C2,
0x30C6, 0x4C3,
0x30C8, 0x4C4,
0x30CA, 0x4C5,
0x30CB, 0x4C6,
0x30CC, 0x4C7,
0x30CD, 0x4C8,
0x30CE, 0x4C9,
0x30CF, 0x4CA,
0x30D2, 0x4CB,
0x30D5, 0x4CC,
0x30D8, 0x4CD,
0x30DB, 0x4CE,
0x30DE, 0x4CF,
0x30DF, 0x4D0,
0x30E0, 0x4D1,
0x30E1, 0x4D2,
0x30E2, 0x4D3,
0x30E3, 0x4AC,
0x30E4, 0x4D4,
0x30E5, 0x4AD,
0x30E6, 0x4D5,
0x30E7, 0x4AE,
0x30E8, 0x4D6,
0x30E9, 0x4D7,
0x30EA, 0x4D8,
0x30EB, 0x4D9,
0x30EC, 0x4DA,
0x30ED, 0x4DB,
0x30EF, 0x4DC,
0x30F2, 0x4A6,
0x30F3, 0x4DD,
0x30FB, 0x4A5,
0x30FC, 0x4B0,
0x3131, 0xEA1,
0x3132, 0xEA2,
0x3133, 0xEA3,
0x3134, 0xEA4,
0x3135, 0xEA5,
0x3136, 0xEA6,
0x3137, 0xEA7,
0x3138, 0xEA8,
0x3139, 0xEA9,
0x313A, 0xEAA,
0x313B, 0xEAB,
0x313C, 0xEAC,
0x313D, 0xEAD,
0x313E, 0xEAE,
0x313F, 0xEAF,
0x3140, 0xEB0,
0x3141, 0xEB1,
0x3142, 0xEB2,
0x3143, 0xEB3,
0x3144, 0xEB4,
0x3145, 0xEB5,
0x3146, 0xEB6,
0x3147, 0xEB7,
0x3148, 0xEB8,
0x3149, 0xEB9,
0x314A, 0xEBA,
0x314B, 0xEBB,
0x314C, 0xEBC,
0x314D, 0xEBD,
0x314E, 0xEBE,
0x314F, 0xEBF,
0x3150, 0xEC0,
0x3151, 0xEC1,
0x3152, 0xEC2,
0x3153, 0xEC3,
0x3154, 0xEC4,
0x3155, 0xEC5,
0x3156, 0xEC6,
0x3157, 0xEC7,
0x3158, 0xEC8,
0x3159, 0xEC9,
0x315A, 0xECA,
0x315B, 0xECB,
0x315C, 0xECC,
0x315D, 0xECD,
0x315E, 0xECE,
0x315F, 0xECF,
0x3160, 0xED0,
0x3161, 0xED1,
0x3162, 0xED2,
0x3163, 0xED3,
0x316D, 0xEEF,
0x3171, 0xEF0,
0x3178, 0xEF1,
0x317F, 0xEF2,
0x3181, 0xEF3,
0x3184, 0xEF4,
0x3186, 0xEF5,
0x318D, 0xEF6,
0x318E, 0xEF7
)
}

View File

@@ -0,0 +1,573 @@
/*
* Copyright (c) 2023 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.vnc
/**
* From [1]:
* "An XT keycode is an XT make scancode sequence encoded to fit in a single U32 quantity.
* Single byte XT scancodes with a byte value less than 0x7f are encoded as is.
* 2-byte XT scancodes whose first byte is 0xe0 and second byte is less than 0x7f
* are encoded with the high bit of the first byte set."
*
* [1] https://github.com/rfbproto/rfbproto/blob/master/rfbproto.rst#qemu-extended-key-event-message
*/
object XTKeyCode {
/**
* Maps KeyEvent scancodes to XT keycode.
*
* KeyEvent scancodes are simply Linux kernel keycodes. Although Android doesn't
* explicitly states this, it can confirmed by looking through Android source code.
* In any case, all devices tested so far returns Linux keycodes.
*
* Returns 0 if no mapping exist.
*/
fun fromAndroidScancode(scancode: Int) = LinuxToQnum.getOrNull(scancode) ?: 0
/**
* This mapping table is generated using output from the following command:
*
* keymap-gen code-map --lang stdc++ keymaps.csv linux qnum
*
* qnum refers to XT keycode described above.
* Source: https://github.com/qemu/keycodemapdb
*/
private val LinuxToQnum = intArrayOf(
0, /* linux:0 (KEY_RESERVED) -> linux:0 (KEY_RESERVED) -> qnum:None */
0x1, /* linux:1 (KEY_ESC) -> linux:1 (KEY_ESC) -> qnum:1 */
0x2, /* linux:2 (KEY_1) -> linux:2 (KEY_1) -> qnum:2 */
0x3, /* linux:3 (KEY_2) -> linux:3 (KEY_2) -> qnum:3 */
0x4, /* linux:4 (KEY_3) -> linux:4 (KEY_3) -> qnum:4 */
0x5, /* linux:5 (KEY_4) -> linux:5 (KEY_4) -> qnum:5 */
0x6, /* linux:6 (KEY_5) -> linux:6 (KEY_5) -> qnum:6 */
0x7, /* linux:7 (KEY_6) -> linux:7 (KEY_6) -> qnum:7 */
0x8, /* linux:8 (KEY_7) -> linux:8 (KEY_7) -> qnum:8 */
0x9, /* linux:9 (KEY_8) -> linux:9 (KEY_8) -> qnum:9 */
0xa, /* linux:10 (KEY_9) -> linux:10 (KEY_9) -> qnum:10 */
0xb, /* linux:11 (KEY_0) -> linux:11 (KEY_0) -> qnum:11 */
0xc, /* linux:12 (KEY_MINUS) -> linux:12 (KEY_MINUS) -> qnum:12 */
0xd, /* linux:13 (KEY_EQUAL) -> linux:13 (KEY_EQUAL) -> qnum:13 */
0xe, /* linux:14 (KEY_BACKSPACE) -> linux:14 (KEY_BACKSPACE) -> qnum:14 */
0xf, /* linux:15 (KEY_TAB) -> linux:15 (KEY_TAB) -> qnum:15 */
0x10, /* linux:16 (KEY_Q) -> linux:16 (KEY_Q) -> qnum:16 */
0x11, /* linux:17 (KEY_W) -> linux:17 (KEY_W) -> qnum:17 */
0x12, /* linux:18 (KEY_E) -> linux:18 (KEY_E) -> qnum:18 */
0x13, /* linux:19 (KEY_R) -> linux:19 (KEY_R) -> qnum:19 */
0x14, /* linux:20 (KEY_T) -> linux:20 (KEY_T) -> qnum:20 */
0x15, /* linux:21 (KEY_Y) -> linux:21 (KEY_Y) -> qnum:21 */
0x16, /* linux:22 (KEY_U) -> linux:22 (KEY_U) -> qnum:22 */
0x17, /* linux:23 (KEY_I) -> linux:23 (KEY_I) -> qnum:23 */
0x18, /* linux:24 (KEY_O) -> linux:24 (KEY_O) -> qnum:24 */
0x19, /* linux:25 (KEY_P) -> linux:25 (KEY_P) -> qnum:25 */
0x1a, /* linux:26 (KEY_LEFTBRACE) -> linux:26 (KEY_LEFTBRACE) -> qnum:26 */
0x1b, /* linux:27 (KEY_RIGHTBRACE) -> linux:27 (KEY_RIGHTBRACE) -> qnum:27 */
0x1c, /* linux:28 (KEY_ENTER) -> linux:28 (KEY_ENTER) -> qnum:28 */
0x1d, /* linux:29 (KEY_LEFTCTRL) -> linux:29 (KEY_LEFTCTRL) -> qnum:29 */
0x1e, /* linux:30 (KEY_A) -> linux:30 (KEY_A) -> qnum:30 */
0x1f, /* linux:31 (KEY_S) -> linux:31 (KEY_S) -> qnum:31 */
0x20, /* linux:32 (KEY_D) -> linux:32 (KEY_D) -> qnum:32 */
0x21, /* linux:33 (KEY_F) -> linux:33 (KEY_F) -> qnum:33 */
0x22, /* linux:34 (KEY_G) -> linux:34 (KEY_G) -> qnum:34 */
0x23, /* linux:35 (KEY_H) -> linux:35 (KEY_H) -> qnum:35 */
0x24, /* linux:36 (KEY_J) -> linux:36 (KEY_J) -> qnum:36 */
0x25, /* linux:37 (KEY_K) -> linux:37 (KEY_K) -> qnum:37 */
0x26, /* linux:38 (KEY_L) -> linux:38 (KEY_L) -> qnum:38 */
0x27, /* linux:39 (KEY_SEMICOLON) -> linux:39 (KEY_SEMICOLON) -> qnum:39 */
0x28, /* linux:40 (KEY_APOSTROPHE) -> linux:40 (KEY_APOSTROPHE) -> qnum:40 */
0x29, /* linux:41 (KEY_GRAVE) -> linux:41 (KEY_GRAVE) -> qnum:41 */
0x2a, /* linux:42 (KEY_LEFTSHIFT) -> linux:42 (KEY_LEFTSHIFT) -> qnum:42 */
0x2b, /* linux:43 (KEY_BACKSLASH) -> linux:43 (KEY_BACKSLASH) -> qnum:43 */
0x2c, /* linux:44 (KEY_Z) -> linux:44 (KEY_Z) -> qnum:44 */
0x2d, /* linux:45 (KEY_X) -> linux:45 (KEY_X) -> qnum:45 */
0x2e, /* linux:46 (KEY_C) -> linux:46 (KEY_C) -> qnum:46 */
0x2f, /* linux:47 (KEY_V) -> linux:47 (KEY_V) -> qnum:47 */
0x30, /* linux:48 (KEY_B) -> linux:48 (KEY_B) -> qnum:48 */
0x31, /* linux:49 (KEY_N) -> linux:49 (KEY_N) -> qnum:49 */
0x32, /* linux:50 (KEY_M) -> linux:50 (KEY_M) -> qnum:50 */
0x33, /* linux:51 (KEY_COMMA) -> linux:51 (KEY_COMMA) -> qnum:51 */
0x34, /* linux:52 (KEY_DOT) -> linux:52 (KEY_DOT) -> qnum:52 */
0x35, /* linux:53 (KEY_SLASH) -> linux:53 (KEY_SLASH) -> qnum:53 */
0x36, /* linux:54 (KEY_RIGHTSHIFT) -> linux:54 (KEY_RIGHTSHIFT) -> qnum:54 */
0x37, /* linux:55 (KEY_KPASTERISK) -> linux:55 (KEY_KPASTERISK) -> qnum:55 */
0x38, /* linux:56 (KEY_LEFTALT) -> linux:56 (KEY_LEFTALT) -> qnum:56 */
0x39, /* linux:57 (KEY_SPACE) -> linux:57 (KEY_SPACE) -> qnum:57 */
0x3a, /* linux:58 (KEY_CAPSLOCK) -> linux:58 (KEY_CAPSLOCK) -> qnum:58 */
0x3b, /* linux:59 (KEY_F1) -> linux:59 (KEY_F1) -> qnum:59 */
0x3c, /* linux:60 (KEY_F2) -> linux:60 (KEY_F2) -> qnum:60 */
0x3d, /* linux:61 (KEY_F3) -> linux:61 (KEY_F3) -> qnum:61 */
0x3e, /* linux:62 (KEY_F4) -> linux:62 (KEY_F4) -> qnum:62 */
0x3f, /* linux:63 (KEY_F5) -> linux:63 (KEY_F5) -> qnum:63 */
0x40, /* linux:64 (KEY_F6) -> linux:64 (KEY_F6) -> qnum:64 */
0x41, /* linux:65 (KEY_F7) -> linux:65 (KEY_F7) -> qnum:65 */
0x42, /* linux:66 (KEY_F8) -> linux:66 (KEY_F8) -> qnum:66 */
0x43, /* linux:67 (KEY_F9) -> linux:67 (KEY_F9) -> qnum:67 */
0x44, /* linux:68 (KEY_F10) -> linux:68 (KEY_F10) -> qnum:68 */
0x45, /* linux:69 (KEY_NUMLOCK) -> linux:69 (KEY_NUMLOCK) -> qnum:69 */
0x46, /* linux:70 (KEY_SCROLLLOCK) -> linux:70 (KEY_SCROLLLOCK) -> qnum:70 */
0x47, /* linux:71 (KEY_KP7) -> linux:71 (KEY_KP7) -> qnum:71 */
0x48, /* linux:72 (KEY_KP8) -> linux:72 (KEY_KP8) -> qnum:72 */
0x49, /* linux:73 (KEY_KP9) -> linux:73 (KEY_KP9) -> qnum:73 */
0x4a, /* linux:74 (KEY_KPMINUS) -> linux:74 (KEY_KPMINUS) -> qnum:74 */
0x4b, /* linux:75 (KEY_KP4) -> linux:75 (KEY_KP4) -> qnum:75 */
0x4c, /* linux:76 (KEY_KP5) -> linux:76 (KEY_KP5) -> qnum:76 */
0x4d, /* linux:77 (KEY_KP6) -> linux:77 (KEY_KP6) -> qnum:77 */
0x4e, /* linux:78 (KEY_KPPLUS) -> linux:78 (KEY_KPPLUS) -> qnum:78 */
0x4f, /* linux:79 (KEY_KP1) -> linux:79 (KEY_KP1) -> qnum:79 */
0x50, /* linux:80 (KEY_KP2) -> linux:80 (KEY_KP2) -> qnum:80 */
0x51, /* linux:81 (KEY_KP3) -> linux:81 (KEY_KP3) -> qnum:81 */
0x52, /* linux:82 (KEY_KP0) -> linux:82 (KEY_KP0) -> qnum:82 */
0x53, /* linux:83 (KEY_KPDOT) -> linux:83 (KEY_KPDOT) -> qnum:83 */
0x54, /* linux:84 (unnamed) -> linux:84 (unnamed) -> qnum:84 */
0x76, /* linux:85 (KEY_ZENKAKUHANKAKU) -> linux:85 (KEY_ZENKAKUHANKAKU) -> qnum:118 */
0x56, /* linux:86 (KEY_102ND) -> linux:86 (KEY_102ND) -> qnum:86 */
0x57, /* linux:87 (KEY_F11) -> linux:87 (KEY_F11) -> qnum:87 */
0x58, /* linux:88 (KEY_F12) -> linux:88 (KEY_F12) -> qnum:88 */
0x73, /* linux:89 (KEY_RO) -> linux:89 (KEY_RO) -> qnum:115 */
0x78, /* linux:90 (KEY_KATAKANA) -> linux:90 (KEY_KATAKANA) -> qnum:120 */
0x77, /* linux:91 (KEY_HIRAGANA) -> linux:91 (KEY_HIRAGANA) -> qnum:119 */
0x79, /* linux:92 (KEY_HENKAN) -> linux:92 (KEY_HENKAN) -> qnum:121 */
0x70, /* linux:93 (KEY_KATAKANAHIRAGANA) -> linux:93 (KEY_KATAKANAHIRAGANA) -> qnum:112 */
0x7b, /* linux:94 (KEY_MUHENKAN) -> linux:94 (KEY_MUHENKAN) -> qnum:123 */
0x5c, /* linux:95 (KEY_KPJPCOMMA) -> linux:95 (KEY_KPJPCOMMA) -> qnum:92 */
0x9c, /* linux:96 (KEY_KPENTER) -> linux:96 (KEY_KPENTER) -> qnum:156 */
0x9d, /* linux:97 (KEY_RIGHTCTRL) -> linux:97 (KEY_RIGHTCTRL) -> qnum:157 */
0xb5, /* linux:98 (KEY_KPSLASH) -> linux:98 (KEY_KPSLASH) -> qnum:181 */
0x54, /* linux:99 (KEY_SYSRQ) -> linux:99 (KEY_SYSRQ) -> qnum:84 */
0xb8, /* linux:100 (KEY_RIGHTALT) -> linux:100 (KEY_RIGHTALT) -> qnum:184 */
0x5b, /* linux:101 (KEY_LINEFEED) -> linux:101 (KEY_LINEFEED) -> qnum:91 */
0xc7, /* linux:102 (KEY_HOME) -> linux:102 (KEY_HOME) -> qnum:199 */
0xc8, /* linux:103 (KEY_UP) -> linux:103 (KEY_UP) -> qnum:200 */
0xc9, /* linux:104 (KEY_PAGEUP) -> linux:104 (KEY_PAGEUP) -> qnum:201 */
0xcb, /* linux:105 (KEY_LEFT) -> linux:105 (KEY_LEFT) -> qnum:203 */
0xcd, /* linux:106 (KEY_RIGHT) -> linux:106 (KEY_RIGHT) -> qnum:205 */
0xcf, /* linux:107 (KEY_END) -> linux:107 (KEY_END) -> qnum:207 */
0xd0, /* linux:108 (KEY_DOWN) -> linux:108 (KEY_DOWN) -> qnum:208 */
0xd1, /* linux:109 (KEY_PAGEDOWN) -> linux:109 (KEY_PAGEDOWN) -> qnum:209 */
0xd2, /* linux:110 (KEY_INSERT) -> linux:110 (KEY_INSERT) -> qnum:210 */
0xd3, /* linux:111 (KEY_DELETE) -> linux:111 (KEY_DELETE) -> qnum:211 */
0xef, /* linux:112 (KEY_MACRO) -> linux:112 (KEY_MACRO) -> qnum:239 */
0xa0, /* linux:113 (KEY_MUTE) -> linux:113 (KEY_MUTE) -> qnum:160 */
0xae, /* linux:114 (KEY_VOLUMEDOWN) -> linux:114 (KEY_VOLUMEDOWN) -> qnum:174 */
0xb0, /* linux:115 (KEY_VOLUMEUP) -> linux:115 (KEY_VOLUMEUP) -> qnum:176 */
0xde, /* linux:116 (KEY_POWER) -> linux:116 (KEY_POWER) -> qnum:222 */
0x59, /* linux:117 (KEY_KPEQUAL) -> linux:117 (KEY_KPEQUAL) -> qnum:89 */
0xce, /* linux:118 (KEY_KPPLUSMINUS) -> linux:118 (KEY_KPPLUSMINUS) -> qnum:206 */
0xc6, /* linux:119 (KEY_PAUSE) -> linux:119 (KEY_PAUSE) -> qnum:198 */
0x8b, /* linux:120 (KEY_SCALE) -> linux:120 (KEY_SCALE) -> qnum:139 */
0x7e, /* linux:121 (KEY_KPCOMMA) -> linux:121 (KEY_KPCOMMA) -> qnum:126 */
0x72, /* linux:122 (KEY_HANGEUL) -> linux:122 (KEY_HANGEUL) -> qnum:114 */
0x71, /* linux:123 (KEY_HANJA) -> linux:123 (KEY_HANJA) -> qnum:113 */
0x7d, /* linux:124 (KEY_YEN) -> linux:124 (KEY_YEN) -> qnum:125 */
0xdb, /* linux:125 (KEY_LEFTMETA) -> linux:125 (KEY_LEFTMETA) -> qnum:219 */
0xdc, /* linux:126 (KEY_RIGHTMETA) -> linux:126 (KEY_RIGHTMETA) -> qnum:220 */
0xdd, /* linux:127 (KEY_COMPOSE) -> linux:127 (KEY_COMPOSE) -> qnum:221 */
0xe8, /* linux:128 (KEY_STOP) -> linux:128 (KEY_STOP) -> qnum:232 */
0x85, /* linux:129 (KEY_AGAIN) -> linux:129 (KEY_AGAIN) -> qnum:133 */
0x86, /* linux:130 (KEY_PROPS) -> linux:130 (KEY_PROPS) -> qnum:134 */
0x87, /* linux:131 (KEY_UNDO) -> linux:131 (KEY_UNDO) -> qnum:135 */
0x8c, /* linux:132 (KEY_FRONT) -> linux:132 (KEY_FRONT) -> qnum:140 */
0xf8, /* linux:133 (KEY_COPY) -> linux:133 (KEY_COPY) -> qnum:248 */
0x64, /* linux:134 (KEY_OPEN) -> linux:134 (KEY_OPEN) -> qnum:100 */
0x65, /* linux:135 (KEY_PASTE) -> linux:135 (KEY_PASTE) -> qnum:101 */
0xc1, /* linux:136 (KEY_FIND) -> linux:136 (KEY_FIND) -> qnum:193 */
0xbc, /* linux:137 (KEY_CUT) -> linux:137 (KEY_CUT) -> qnum:188 */
0xf5, /* linux:138 (KEY_HELP) -> linux:138 (KEY_HELP) -> qnum:245 */
0x9e, /* linux:139 (KEY_MENU) -> linux:139 (KEY_MENU) -> qnum:158 */
0xa1, /* linux:140 (KEY_CALC) -> linux:140 (KEY_CALC) -> qnum:161 */
0x66, /* linux:141 (KEY_SETUP) -> linux:141 (KEY_SETUP) -> qnum:102 */
0xdf, /* linux:142 (KEY_SLEEP) -> linux:142 (KEY_SLEEP) -> qnum:223 */
0xe3, /* linux:143 (KEY_WAKEUP) -> linux:143 (KEY_WAKEUP) -> qnum:227 */
0x67, /* linux:144 (KEY_FILE) -> linux:144 (KEY_FILE) -> qnum:103 */
0x68, /* linux:145 (KEY_SENDFILE) -> linux:145 (KEY_SENDFILE) -> qnum:104 */
0x69, /* linux:146 (KEY_DELETEFILE) -> linux:146 (KEY_DELETEFILE) -> qnum:105 */
0x93, /* linux:147 (KEY_XFER) -> linux:147 (KEY_XFER) -> qnum:147 */
0x9f, /* linux:148 (KEY_PROG1) -> linux:148 (KEY_PROG1) -> qnum:159 */
0x97, /* linux:149 (KEY_PROG2) -> linux:149 (KEY_PROG2) -> qnum:151 */
0x82, /* linux:150 (KEY_WWW) -> linux:150 (KEY_WWW) -> qnum:130 */
0x6a, /* linux:151 (KEY_MSDOS) -> linux:151 (KEY_MSDOS) -> qnum:106 */
0x92, /* linux:152 (KEY_SCREENLOCK) -> linux:152 (KEY_SCREENLOCK) -> qnum:146 */
0x6b, /* linux:153 (KEY_DIRECTION) -> linux:153 (KEY_DIRECTION) -> qnum:107 */
0xa6, /* linux:154 (KEY_CYCLEWINDOWS) -> linux:154 (KEY_CYCLEWINDOWS) -> qnum:166 */
0xec, /* linux:155 (KEY_MAIL) -> linux:155 (KEY_MAIL) -> qnum:236 */
0xe6, /* linux:156 (KEY_BOOKMARKS) -> linux:156 (KEY_BOOKMARKS) -> qnum:230 */
0xeb, /* linux:157 (KEY_COMPUTER) -> linux:157 (KEY_COMPUTER) -> qnum:235 */
0xea, /* linux:158 (KEY_BACK) -> linux:158 (KEY_BACK) -> qnum:234 */
0xe9, /* linux:159 (KEY_FORWARD) -> linux:159 (KEY_FORWARD) -> qnum:233 */
0xa3, /* linux:160 (KEY_CLOSECD) -> linux:160 (KEY_CLOSECD) -> qnum:163 */
0x6c, /* linux:161 (KEY_EJECTCD) -> linux:161 (KEY_EJECTCD) -> qnum:108 */
0xfd, /* linux:162 (KEY_EJECTCLOSECD) -> linux:162 (KEY_EJECTCLOSECD) -> qnum:253 */
0x99, /* linux:163 (KEY_NEXTSONG) -> linux:163 (KEY_NEXTSONG) -> qnum:153 */
0xa2, /* linux:164 (KEY_PLAYPAUSE) -> linux:164 (KEY_PLAYPAUSE) -> qnum:162 */
0x90, /* linux:165 (KEY_PREVIOUSSONG) -> linux:165 (KEY_PREVIOUSSONG) -> qnum:144 */
0xa4, /* linux:166 (KEY_STOPCD) -> linux:166 (KEY_STOPCD) -> qnum:164 */
0xb1, /* linux:167 (KEY_RECORD) -> linux:167 (KEY_RECORD) -> qnum:177 */
0x98, /* linux:168 (KEY_REWIND) -> linux:168 (KEY_REWIND) -> qnum:152 */
0x63, /* linux:169 (KEY_PHONE) -> linux:169 (KEY_PHONE) -> qnum:99 */
0, /* linux:170 (KEY_ISO) -> linux:170 (KEY_ISO) -> qnum:None */
0x81, /* linux:171 (KEY_CONFIG) -> linux:171 (KEY_CONFIG) -> qnum:129 */
0xb2, /* linux:172 (KEY_HOMEPAGE) -> linux:172 (KEY_HOMEPAGE) -> qnum:178 */
0xe7, /* linux:173 (KEY_REFRESH) -> linux:173 (KEY_REFRESH) -> qnum:231 */
0, /* linux:174 (KEY_EXIT) -> linux:174 (KEY_EXIT) -> qnum:None */
0, /* linux:175 (KEY_MOVE) -> linux:175 (KEY_MOVE) -> qnum:None */
0x88, /* linux:176 (KEY_EDIT) -> linux:176 (KEY_EDIT) -> qnum:136 */
0x75, /* linux:177 (KEY_SCROLLUP) -> linux:177 (KEY_SCROLLUP) -> qnum:117 */
0x8f, /* linux:178 (KEY_SCROLLDOWN) -> linux:178 (KEY_SCROLLDOWN) -> qnum:143 */
0xf6, /* linux:179 (KEY_KPLEFTPAREN) -> linux:179 (KEY_KPLEFTPAREN) -> qnum:246 */
0xfb, /* linux:180 (KEY_KPRIGHTPAREN) -> linux:180 (KEY_KPRIGHTPAREN) -> qnum:251 */
0x89, /* linux:181 (KEY_NEW) -> linux:181 (KEY_NEW) -> qnum:137 */
0x8a, /* linux:182 (KEY_REDO) -> linux:182 (KEY_REDO) -> qnum:138 */
0x5d, /* linux:183 (KEY_F13) -> linux:183 (KEY_F13) -> qnum:93 */
0x5e, /* linux:184 (KEY_F14) -> linux:184 (KEY_F14) -> qnum:94 */
0x5f, /* linux:185 (KEY_F15) -> linux:185 (KEY_F15) -> qnum:95 */
0x55, /* linux:186 (KEY_F16) -> linux:186 (KEY_F16) -> qnum:85 */
0x83, /* linux:187 (KEY_F17) -> linux:187 (KEY_F17) -> qnum:131 */
0xf7, /* linux:188 (KEY_F18) -> linux:188 (KEY_F18) -> qnum:247 */
0x84, /* linux:189 (KEY_F19) -> linux:189 (KEY_F19) -> qnum:132 */
0x5a, /* linux:190 (KEY_F20) -> linux:190 (KEY_F20) -> qnum:90 */
0x74, /* linux:191 (KEY_F21) -> linux:191 (KEY_F21) -> qnum:116 */
0xf9, /* linux:192 (KEY_F22) -> linux:192 (KEY_F22) -> qnum:249 */
0x6d, /* linux:193 (KEY_F23) -> linux:193 (KEY_F23) -> qnum:109 */
0x6f, /* linux:194 (KEY_F24) -> linux:194 (KEY_F24) -> qnum:111 */
0x95, /* linux:195 (unnamed) -> linux:195 (unnamed) -> qnum:149 */
0x96, /* linux:196 (unnamed) -> linux:196 (unnamed) -> qnum:150 */
0x9a, /* linux:197 (unnamed) -> linux:197 (unnamed) -> qnum:154 */
0x9b, /* linux:198 (unnamed) -> linux:198 (unnamed) -> qnum:155 */
0xa7, /* linux:199 (unnamed) -> linux:199 (unnamed) -> qnum:167 */
0xa8, /* linux:200 (KEY_PLAYCD) -> linux:200 (KEY_PLAYCD) -> qnum:168 */
0xa9, /* linux:201 (KEY_PAUSECD) -> linux:201 (KEY_PAUSECD) -> qnum:169 */
0xab, /* linux:202 (KEY_PROG3) -> linux:202 (KEY_PROG3) -> qnum:171 */
0xac, /* linux:203 (KEY_PROG4) -> linux:203 (KEY_PROG4) -> qnum:172 */
0xad, /* linux:204 (KEY_DASHBOARD) -> linux:204 (KEY_DASHBOARD) -> qnum:173 */
0xa5, /* linux:205 (KEY_SUSPEND) -> linux:205 (KEY_SUSPEND) -> qnum:165 */
0xaf, /* linux:206 (KEY_CLOSE) -> linux:206 (KEY_CLOSE) -> qnum:175 */
0xb3, /* linux:207 (KEY_PLAY) -> linux:207 (KEY_PLAY) -> qnum:179 */
0xb4, /* linux:208 (KEY_FASTFORWARD) -> linux:208 (KEY_FASTFORWARD) -> qnum:180 */
0xb6, /* linux:209 (KEY_BASSBOOST) -> linux:209 (KEY_BASSBOOST) -> qnum:182 */
0xb9, /* linux:210 (KEY_PRINT) -> linux:210 (KEY_PRINT) -> qnum:185 */
0xba, /* linux:211 (KEY_HP) -> linux:211 (KEY_HP) -> qnum:186 */
0xbb, /* linux:212 (KEY_CAMERA) -> linux:212 (KEY_CAMERA) -> qnum:187 */
0xbd, /* linux:213 (KEY_SOUND) -> linux:213 (KEY_SOUND) -> qnum:189 */
0xbe, /* linux:214 (KEY_QUESTION) -> linux:214 (KEY_QUESTION) -> qnum:190 */
0xbf, /* linux:215 (KEY_EMAIL) -> linux:215 (KEY_EMAIL) -> qnum:191 */
0xc0, /* linux:216 (KEY_CHAT) -> linux:216 (KEY_CHAT) -> qnum:192 */
0xe5, /* linux:217 (KEY_SEARCH) -> linux:217 (KEY_SEARCH) -> qnum:229 */
0xc2, /* linux:218 (KEY_CONNECT) -> linux:218 (KEY_CONNECT) -> qnum:194 */
0xc3, /* linux:219 (KEY_FINANCE) -> linux:219 (KEY_FINANCE) -> qnum:195 */
0xc4, /* linux:220 (KEY_SPORT) -> linux:220 (KEY_SPORT) -> qnum:196 */
0xc5, /* linux:221 (KEY_SHOP) -> linux:221 (KEY_SHOP) -> qnum:197 */
0x94, /* linux:222 (KEY_ALTERASE) -> linux:222 (KEY_ALTERASE) -> qnum:148 */
0xca, /* linux:223 (KEY_CANCEL) -> linux:223 (KEY_CANCEL) -> qnum:202 */
0xcc, /* linux:224 (KEY_BRIGHTNESSDOWN) -> linux:224 (KEY_BRIGHTNESSDOWN) -> qnum:204 */
0xd4, /* linux:225 (KEY_BRIGHTNESSUP) -> linux:225 (KEY_BRIGHTNESSUP) -> qnum:212 */
0xed, /* linux:226 (KEY_MEDIA) -> linux:226 (KEY_MEDIA) -> qnum:237 */
0xd6, /* linux:227 (KEY_SWITCHVIDEOMODE) -> linux:227 (KEY_SWITCHVIDEOMODE) -> qnum:214 */
0xd7, /* linux:228 (KEY_KBDILLUMTOGGLE) -> linux:228 (KEY_KBDILLUMTOGGLE) -> qnum:215 */
0xd8, /* linux:229 (KEY_KBDILLUMDOWN) -> linux:229 (KEY_KBDILLUMDOWN) -> qnum:216 */
0xd9, /* linux:230 (KEY_KBDILLUMUP) -> linux:230 (KEY_KBDILLUMUP) -> qnum:217 */
0xda, /* linux:231 (KEY_SEND) -> linux:231 (KEY_SEND) -> qnum:218 */
0xe4, /* linux:232 (KEY_REPLY) -> linux:232 (KEY_REPLY) -> qnum:228 */
0x8e, /* linux:233 (KEY_FORWARDMAIL) -> linux:233 (KEY_FORWARDMAIL) -> qnum:142 */
0xd5, /* linux:234 (KEY_SAVE) -> linux:234 (KEY_SAVE) -> qnum:213 */
0xf0, /* linux:235 (KEY_DOCUMENTS) -> linux:235 (KEY_DOCUMENTS) -> qnum:240 */
0xf1, /* linux:236 (KEY_BATTERY) -> linux:236 (KEY_BATTERY) -> qnum:241 */
0xf2, /* linux:237 (KEY_BLUETOOTH) -> linux:237 (KEY_BLUETOOTH) -> qnum:242 */
0xf3, /* linux:238 (KEY_WLAN) -> linux:238 (KEY_WLAN) -> qnum:243 */
0xf4, /* linux:239 (KEY_UWB) -> linux:239 (KEY_UWB) -> qnum:244 */
/* Rest of the keycodes have no mapping so far
0, /* linux:240 (KEY_UNKNOWN) -> linux:240 (KEY_UNKNOWN) -> qnum:None */
0, /* linux:241 (KEY_VIDEO_NEXT) -> linux:241 (KEY_VIDEO_NEXT) -> qnum:None */
0, /* linux:242 (KEY_VIDEO_PREV) -> linux:242 (KEY_VIDEO_PREV) -> qnum:None */
0, /* linux:243 (KEY_BRIGHTNESS_CYCLE) -> linux:243 (KEY_BRIGHTNESS_CYCLE) -> qnum:None */
0, /* linux:244 (KEY_BRIGHTNESS_ZERO) -> linux:244 (KEY_BRIGHTNESS_ZERO) -> qnum:None */
0, /* linux:245 (KEY_DISPLAY_OFF) -> linux:245 (KEY_DISPLAY_OFF) -> qnum:None */
0, /* linux:246 (KEY_WIMAX) -> linux:246 (KEY_WIMAX) -> qnum:None */
0, /* linux:247 (unnamed) -> linux:247 (unnamed) -> qnum:None */
0, /* linux:248 (unnamed) -> linux:248 (unnamed) -> qnum:None */
0, /* linux:249 (unnamed) -> linux:249 (unnamed) -> qnum:None */
0, /* linux:250 (unnamed) -> linux:250 (unnamed) -> qnum:None */
0, /* linux:251 (unnamed) -> linux:251 (unnamed) -> qnum:None */
0, /* linux:252 (unnamed) -> linux:252 (unnamed) -> qnum:None */
0, /* linux:253 (unnamed) -> linux:253 (unnamed) -> qnum:None */
0, /* linux:254 (unnamed) -> linux:254 (unnamed) -> qnum:None */
0, /* linux:255 (unnamed) -> linux:255 (unnamed) -> qnum:None */
0, /* linux:256 (BTN_0) -> linux:256 (BTN_0) -> qnum:None */
0, /* linux:257 (BTN_1) -> linux:257 (BTN_1) -> qnum:None */
0, /* linux:258 (BTN_2) -> linux:258 (BTN_2) -> qnum:None */
0, /* linux:259 (BTN_3) -> linux:259 (BTN_3) -> qnum:None */
0, /* linux:260 (BTN_4) -> linux:260 (BTN_4) -> qnum:None */
0, /* linux:261 (BTN_5) -> linux:261 (BTN_5) -> qnum:None */
0, /* linux:262 (BTN_6) -> linux:262 (BTN_6) -> qnum:None */
0, /* linux:263 (BTN_7) -> linux:263 (BTN_7) -> qnum:None */
0, /* linux:264 (BTN_8) -> linux:264 (BTN_8) -> qnum:None */
0, /* linux:265 (BTN_9) -> linux:265 (BTN_9) -> qnum:None */
0, /* linux:266 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:267 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:268 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:269 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:270 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:271 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:272 (BTN_LEFT) -> linux:272 (BTN_LEFT) -> qnum:None */
0, /* linux:273 (BTN_RIGHT) -> linux:273 (BTN_RIGHT) -> qnum:None */
0, /* linux:274 (BTN_MIDDLE) -> linux:274 (BTN_MIDDLE) -> qnum:None */
0, /* linux:275 (BTN_SIDE) -> linux:275 (BTN_SIDE) -> qnum:None */
0, /* linux:276 (BTN_EXTRA) -> linux:276 (BTN_EXTRA) -> qnum:None */
0, /* linux:277 (BTN_FORWARD) -> linux:277 (BTN_FORWARD) -> qnum:None */
0, /* linux:278 (BTN_BACK) -> linux:278 (BTN_BACK) -> qnum:None */
0, /* linux:279 (BTN_TASK) -> linux:279 (BTN_TASK) -> qnum:None */
0, /* linux:280 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:281 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:282 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:283 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:284 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:285 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:286 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:287 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:288 (BTN_TRIGGER) -> linux:288 (BTN_TRIGGER) -> qnum:None */
0, /* linux:289 (BTN_THUMB) -> linux:289 (BTN_THUMB) -> qnum:None */
0, /* linux:290 (BTN_THUMB2) -> linux:290 (BTN_THUMB2) -> qnum:None */
0, /* linux:291 (BTN_TOP) -> linux:291 (BTN_TOP) -> qnum:None */
0, /* linux:292 (BTN_TOP2) -> linux:292 (BTN_TOP2) -> qnum:None */
0, /* linux:293 (BTN_PINKIE) -> linux:293 (BTN_PINKIE) -> qnum:None */
0, /* linux:294 (BTN_BASE) -> linux:294 (BTN_BASE) -> qnum:None */
0, /* linux:295 (BTN_BASE2) -> linux:295 (BTN_BASE2) -> qnum:None */
0, /* linux:296 (BTN_BASE3) -> linux:296 (BTN_BASE3) -> qnum:None */
0, /* linux:297 (BTN_BASE4) -> linux:297 (BTN_BASE4) -> qnum:None */
0, /* linux:298 (BTN_BASE5) -> linux:298 (BTN_BASE5) -> qnum:None */
0, /* linux:299 (BTN_BASE6) -> linux:299 (BTN_BASE6) -> qnum:None */
0, /* linux:300 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:301 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:302 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:303 (BTN_DEAD) -> linux:303 (BTN_DEAD) -> qnum:None */
0, /* linux:304 (BTN_A) -> linux:304 (BTN_A) -> qnum:None */
0, /* linux:305 (BTN_B) -> linux:305 (BTN_B) -> qnum:None */
0, /* linux:306 (BTN_C) -> linux:306 (BTN_C) -> qnum:None */
0, /* linux:307 (BTN_X) -> linux:307 (BTN_X) -> qnum:None */
0, /* linux:308 (BTN_Y) -> linux:308 (BTN_Y) -> qnum:None */
0, /* linux:309 (BTN_Z) -> linux:309 (BTN_Z) -> qnum:None */
0, /* linux:310 (BTN_TL) -> linux:310 (BTN_TL) -> qnum:None */
0, /* linux:311 (BTN_TR) -> linux:311 (BTN_TR) -> qnum:None */
0, /* linux:312 (BTN_TL2) -> linux:312 (BTN_TL2) -> qnum:None */
0, /* linux:313 (BTN_TR2) -> linux:313 (BTN_TR2) -> qnum:None */
0, /* linux:314 (BTN_SELECT) -> linux:314 (BTN_SELECT) -> qnum:None */
0, /* linux:315 (BTN_START) -> linux:315 (BTN_START) -> qnum:None */
0, /* linux:316 (BTN_MODE) -> linux:316 (BTN_MODE) -> qnum:None */
0, /* linux:317 (BTN_THUMBL) -> linux:317 (BTN_THUMBL) -> qnum:None */
0, /* linux:318 (BTN_THUMBR) -> linux:318 (BTN_THUMBR) -> qnum:None */
0, /* linux:319 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:320 (BTN_TOOL_PEN) -> linux:320 (BTN_TOOL_PEN) -> qnum:None */
0, /* linux:321 (BTN_TOOL_RUBBER) -> linux:321 (BTN_TOOL_RUBBER) -> qnum:None */
0, /* linux:322 (BTN_TOOL_BRUSH) -> linux:322 (BTN_TOOL_BRUSH) -> qnum:None */
0, /* linux:323 (BTN_TOOL_PENCIL) -> linux:323 (BTN_TOOL_PENCIL) -> qnum:None */
0, /* linux:324 (BTN_TOOL_AIRBRUSH) -> linux:324 (BTN_TOOL_AIRBRUSH) -> qnum:None */
0, /* linux:325 (BTN_TOOL_FINGER) -> linux:325 (BTN_TOOL_FINGER) -> qnum:None */
0, /* linux:326 (BTN_TOOL_MOUSE) -> linux:326 (BTN_TOOL_MOUSE) -> qnum:None */
0, /* linux:327 (BTN_TOOL_LENS) -> linux:327 (BTN_TOOL_LENS) -> qnum:None */
0, /* linux:328 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:329 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:330 (BTN_TOUCH) -> linux:330 (BTN_TOUCH) -> qnum:None */
0, /* linux:331 (BTN_STYLUS) -> linux:331 (BTN_STYLUS) -> qnum:None */
0, /* linux:332 (BTN_STYLUS2) -> linux:332 (BTN_STYLUS2) -> qnum:None */
0, /* linux:333 (BTN_TOOL_DOUBLETAP) -> linux:333 (BTN_TOOL_DOUBLETAP) -> qnum:None */
0, /* linux:334 (BTN_TOOL_TRIPLETAP) -> linux:334 (BTN_TOOL_TRIPLETAP) -> qnum:None */
0, /* linux:335 (BTN_TOOL_QUADTAP) -> linux:335 (BTN_TOOL_QUADTAP) -> qnum:None */
0, /* linux:336 (BTN_GEAR_DOWN) -> linux:336 (BTN_GEAR_DOWN) -> qnum:None */
0, /* linux:337 (BTN_GEAR_UP) -> linux:337 (BTN_GEAR_UP) -> qnum:None */
0, /* linux:338 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:339 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:340 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:341 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:342 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:343 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:344 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:345 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:346 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:347 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:348 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:349 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:350 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:351 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:352 (KEY_OK) -> linux:352 (KEY_OK) -> qnum:None */
0, /* linux:353 (KEY_SELECT) -> linux:353 (KEY_SELECT) -> qnum:None */
0, /* linux:354 (KEY_GOTO) -> linux:354 (KEY_GOTO) -> qnum:None */
0, /* linux:355 (KEY_CLEAR) -> linux:355 (KEY_CLEAR) -> qnum:None */
0, /* linux:356 (KEY_POWER2) -> linux:356 (KEY_POWER2) -> qnum:None */
0, /* linux:357 (KEY_OPTION) -> linux:357 (KEY_OPTION) -> qnum:None */
0, /* linux:358 (KEY_INFO) -> linux:358 (KEY_INFO) -> qnum:None */
0, /* linux:359 (KEY_TIME) -> linux:359 (KEY_TIME) -> qnum:None */
0, /* linux:360 (KEY_VENDOR) -> linux:360 (KEY_VENDOR) -> qnum:None */
0, /* linux:361 (KEY_ARCHIVE) -> linux:361 (KEY_ARCHIVE) -> qnum:None */
0, /* linux:362 (KEY_PROGRAM) -> linux:362 (KEY_PROGRAM) -> qnum:None */
0, /* linux:363 (KEY_CHANNEL) -> linux:363 (KEY_CHANNEL) -> qnum:None */
0, /* linux:364 (KEY_FAVORITES) -> linux:364 (KEY_FAVORITES) -> qnum:None */
0, /* linux:365 (KEY_EPG) -> linux:365 (KEY_EPG) -> qnum:None */
0, /* linux:366 (KEY_PVR) -> linux:366 (KEY_PVR) -> qnum:None */
0, /* linux:367 (KEY_MHP) -> linux:367 (KEY_MHP) -> qnum:None */
0, /* linux:368 (KEY_LANGUAGE) -> linux:368 (KEY_LANGUAGE) -> qnum:None */
0, /* linux:369 (KEY_TITLE) -> linux:369 (KEY_TITLE) -> qnum:None */
0, /* linux:370 (KEY_SUBTITLE) -> linux:370 (KEY_SUBTITLE) -> qnum:None */
0, /* linux:371 (KEY_ANGLE) -> linux:371 (KEY_ANGLE) -> qnum:None */
0, /* linux:372 (KEY_ZOOM) -> linux:372 (KEY_ZOOM) -> qnum:None */
0, /* linux:373 (KEY_MODE) -> linux:373 (KEY_MODE) -> qnum:None */
0, /* linux:374 (KEY_KEYBOARD) -> linux:374 (KEY_KEYBOARD) -> qnum:None */
0, /* linux:375 (KEY_SCREEN) -> linux:375 (KEY_SCREEN) -> qnum:None */
0, /* linux:376 (KEY_PC) -> linux:376 (KEY_PC) -> qnum:None */
0, /* linux:377 (KEY_TV) -> linux:377 (KEY_TV) -> qnum:None */
0, /* linux:378 (KEY_TV2) -> linux:378 (KEY_TV2) -> qnum:None */
0, /* linux:379 (KEY_VCR) -> linux:379 (KEY_VCR) -> qnum:None */
0, /* linux:380 (KEY_VCR2) -> linux:380 (KEY_VCR2) -> qnum:None */
0, /* linux:381 (KEY_SAT) -> linux:381 (KEY_SAT) -> qnum:None */
0, /* linux:382 (KEY_SAT2) -> linux:382 (KEY_SAT2) -> qnum:None */
0, /* linux:383 (KEY_CD) -> linux:383 (KEY_CD) -> qnum:None */
0, /* linux:384 (KEY_TAPE) -> linux:384 (KEY_TAPE) -> qnum:None */
0, /* linux:385 (KEY_RADIO) -> linux:385 (KEY_RADIO) -> qnum:None */
0, /* linux:386 (KEY_TUNER) -> linux:386 (KEY_TUNER) -> qnum:None */
0, /* linux:387 (KEY_PLAYER) -> linux:387 (KEY_PLAYER) -> qnum:None */
0, /* linux:388 (KEY_TEXT) -> linux:388 (KEY_TEXT) -> qnum:None */
0, /* linux:389 (KEY_DVD) -> linux:389 (KEY_DVD) -> qnum:None */
0, /* linux:390 (KEY_AUX) -> linux:390 (KEY_AUX) -> qnum:None */
0, /* linux:391 (KEY_MP3) -> linux:391 (KEY_MP3) -> qnum:None */
0, /* linux:392 (KEY_AUDIO) -> linux:392 (KEY_AUDIO) -> qnum:None */
0, /* linux:393 (KEY_VIDEO) -> linux:393 (KEY_VIDEO) -> qnum:None */
0, /* linux:394 (KEY_DIRECTORY) -> linux:394 (KEY_DIRECTORY) -> qnum:None */
0, /* linux:395 (KEY_LIST) -> linux:395 (KEY_LIST) -> qnum:None */
0, /* linux:396 (KEY_MEMO) -> linux:396 (KEY_MEMO) -> qnum:None */
0, /* linux:397 (KEY_CALENDAR) -> linux:397 (KEY_CALENDAR) -> qnum:None */
0, /* linux:398 (KEY_RED) -> linux:398 (KEY_RED) -> qnum:None */
0, /* linux:399 (KEY_GREEN) -> linux:399 (KEY_GREEN) -> qnum:None */
0, /* linux:400 (KEY_YELLOW) -> linux:400 (KEY_YELLOW) -> qnum:None */
0, /* linux:401 (KEY_BLUE) -> linux:401 (KEY_BLUE) -> qnum:None */
0, /* linux:402 (KEY_CHANNELUP) -> linux:402 (KEY_CHANNELUP) -> qnum:None */
0, /* linux:403 (KEY_CHANNELDOWN) -> linux:403 (KEY_CHANNELDOWN) -> qnum:None */
0, /* linux:404 (KEY_FIRST) -> linux:404 (KEY_FIRST) -> qnum:None */
0, /* linux:405 (KEY_LAST) -> linux:405 (KEY_LAST) -> qnum:None */
0, /* linux:406 (KEY_AB) -> linux:406 (KEY_AB) -> qnum:None */
0, /* linux:407 (KEY_NEXT) -> linux:407 (KEY_NEXT) -> qnum:None */
0, /* linux:408 (KEY_RESTART) -> linux:408 (KEY_RESTART) -> qnum:None */
0, /* linux:409 (KEY_SLOW) -> linux:409 (KEY_SLOW) -> qnum:None */
0, /* linux:410 (KEY_SHUFFLE) -> linux:410 (KEY_SHUFFLE) -> qnum:None */
0, /* linux:411 (KEY_BREAK) -> linux:411 (KEY_BREAK) -> qnum:None */
0, /* linux:412 (KEY_PREVIOUS) -> linux:412 (KEY_PREVIOUS) -> qnum:None */
0, /* linux:413 (KEY_DIGITS) -> linux:413 (KEY_DIGITS) -> qnum:None */
0, /* linux:414 (KEY_TEEN) -> linux:414 (KEY_TEEN) -> qnum:None */
0, /* linux:415 (KEY_TWEN) -> linux:415 (KEY_TWEN) -> qnum:None */
0, /* linux:416 (KEY_VIDEOPHONE) -> linux:416 (KEY_VIDEOPHONE) -> qnum:None */
0, /* linux:417 (KEY_GAMES) -> linux:417 (KEY_GAMES) -> qnum:None */
0, /* linux:418 (KEY_ZOOMIN) -> linux:418 (KEY_ZOOMIN) -> qnum:None */
0, /* linux:419 (KEY_ZOOMOUT) -> linux:419 (KEY_ZOOMOUT) -> qnum:None */
0, /* linux:420 (KEY_ZOOMRESET) -> linux:420 (KEY_ZOOMRESET) -> qnum:None */
0, /* linux:421 (KEY_WORDPROCESSOR) -> linux:421 (KEY_WORDPROCESSOR) -> qnum:None */
0, /* linux:422 (KEY_EDITOR) -> linux:422 (KEY_EDITOR) -> qnum:None */
0, /* linux:423 (KEY_SPREADSHEET) -> linux:423 (KEY_SPREADSHEET) -> qnum:None */
0, /* linux:424 (KEY_GRAPHICSEDITOR) -> linux:424 (KEY_GRAPHICSEDITOR) -> qnum:None */
0, /* linux:425 (KEY_PRESENTATION) -> linux:425 (KEY_PRESENTATION) -> qnum:None */
0, /* linux:426 (KEY_DATABASE) -> linux:426 (KEY_DATABASE) -> qnum:None */
0, /* linux:427 (KEY_NEWS) -> linux:427 (KEY_NEWS) -> qnum:None */
0, /* linux:428 (KEY_VOICEMAIL) -> linux:428 (KEY_VOICEMAIL) -> qnum:None */
0, /* linux:429 (KEY_ADDRESSBOOK) -> linux:429 (KEY_ADDRESSBOOK) -> qnum:None */
0, /* linux:430 (KEY_MESSENGER) -> linux:430 (KEY_MESSENGER) -> qnum:None */
0, /* linux:431 (KEY_DISPLAYTOGGLE) -> linux:431 (KEY_DISPLAYTOGGLE) -> qnum:None */
0, /* linux:432 (KEY_SPELLCHECK) -> linux:432 (KEY_SPELLCHECK) -> qnum:None */
0, /* linux:433 (KEY_LOGOFF) -> linux:433 (KEY_LOGOFF) -> qnum:None */
0, /* linux:434 (KEY_DOLLAR) -> linux:434 (KEY_DOLLAR) -> qnum:None */
0, /* linux:435 (KEY_EURO) -> linux:435 (KEY_EURO) -> qnum:None */
0, /* linux:436 (KEY_FRAMEBACK) -> linux:436 (KEY_FRAMEBACK) -> qnum:None */
0, /* linux:437 (KEY_FRAMEFORWARD) -> linux:437 (KEY_FRAMEFORWARD) -> qnum:None */
0, /* linux:438 (KEY_CONTEXT_MENU) -> linux:438 (KEY_CONTEXT_MENU) -> qnum:None */
0, /* linux:439 (KEY_MEDIA_REPEAT) -> linux:439 (KEY_MEDIA_REPEAT) -> qnum:None */
0, /* linux:440 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:441 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:442 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:443 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:444 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:445 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:446 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:447 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:448 (KEY_DEL_EOL) -> linux:448 (KEY_DEL_EOL) -> qnum:None */
0, /* linux:449 (KEY_DEL_EOS) -> linux:449 (KEY_DEL_EOS) -> qnum:None */
0, /* linux:450 (KEY_INS_LINE) -> linux:450 (KEY_INS_LINE) -> qnum:None */
0, /* linux:451 (KEY_DEL_LINE) -> linux:451 (KEY_DEL_LINE) -> qnum:None */
0, /* linux:452 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:453 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:454 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:455 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:456 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:457 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:458 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:459 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:460 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:461 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:462 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:463 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:464 (KEY_FN) -> linux:464 (KEY_FN) -> qnum:None */
0, /* linux:465 (KEY_FN_ESC) -> linux:465 (KEY_FN_ESC) -> qnum:None */
0, /* linux:466 (KEY_FN_F1) -> linux:466 (KEY_FN_F1) -> qnum:None */
0, /* linux:467 (KEY_FN_F2) -> linux:467 (KEY_FN_F2) -> qnum:None */
0, /* linux:468 (KEY_FN_F3) -> linux:468 (KEY_FN_F3) -> qnum:None */
0, /* linux:469 (KEY_FN_F4) -> linux:469 (KEY_FN_F4) -> qnum:None */
0, /* linux:470 (KEY_FN_F5) -> linux:470 (KEY_FN_F5) -> qnum:None */
0, /* linux:471 (KEY_FN_F6) -> linux:471 (KEY_FN_F6) -> qnum:None */
0, /* linux:472 (KEY_FN_F7) -> linux:472 (KEY_FN_F7) -> qnum:None */
0, /* linux:473 (KEY_FN_F8) -> linux:473 (KEY_FN_F8) -> qnum:None */
0, /* linux:474 (KEY_FN_F9) -> linux:474 (KEY_FN_F9) -> qnum:None */
0, /* linux:475 (KEY_FN_F10) -> linux:475 (KEY_FN_F10) -> qnum:None */
0, /* linux:476 (KEY_FN_F11) -> linux:476 (KEY_FN_F11) -> qnum:None */
0, /* linux:477 (KEY_FN_F12) -> linux:477 (KEY_FN_F12) -> qnum:None */
0, /* linux:478 (KEY_FN_1) -> linux:478 (KEY_FN_1) -> qnum:None */
0, /* linux:479 (KEY_FN_2) -> linux:479 (KEY_FN_2) -> qnum:None */
0, /* linux:480 (KEY_FN_D) -> linux:480 (KEY_FN_D) -> qnum:None */
0, /* linux:481 (KEY_FN_E) -> linux:481 (KEY_FN_E) -> qnum:None */
0, /* linux:482 (KEY_FN_F) -> linux:482 (KEY_FN_F) -> qnum:None */
0, /* linux:483 (KEY_FN_S) -> linux:483 (KEY_FN_S) -> qnum:None */
0, /* linux:484 (KEY_FN_B) -> linux:484 (KEY_FN_B) -> qnum:None */
0, /* linux:485 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:486 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:487 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:488 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:489 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:490 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:491 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:492 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:493 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:494 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:495 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:496 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:497 (KEY_BRL_DOT1) -> linux:497 (KEY_BRL_DOT1) -> qnum:None */
0, /* linux:498 (KEY_BRL_DOT2) -> linux:498 (KEY_BRL_DOT2) -> qnum:None */
0, /* linux:499 (KEY_BRL_DOT3) -> linux:499 (KEY_BRL_DOT3) -> qnum:None */
0, /* linux:500 (KEY_BRL_DOT4) -> linux:500 (KEY_BRL_DOT4) -> qnum:None */
0, /* linux:501 (KEY_BRL_DOT5) -> linux:501 (KEY_BRL_DOT5) -> qnum:None */
0, /* linux:502 (KEY_BRL_DOT6) -> linux:502 (KEY_BRL_DOT6) -> qnum:None */
0, /* linux:503 (KEY_BRL_DOT7) -> linux:503 (KEY_BRL_DOT7) -> qnum:None */
0, /* linux:504 (KEY_BRL_DOT8) -> linux:504 (KEY_BRL_DOT8) -> qnum:None */
0, /* linux:505 (KEY_BRL_DOT9) -> linux:505 (KEY_BRL_DOT9) -> qnum:None */
0, /* linux:506 (KEY_BRL_DOT10) -> linux:506 (KEY_BRL_DOT10) -> qnum:None */
0, /* linux:507 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:508 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:509 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:510 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:511 (unnamed) -> linux:None (unnamed) -> qnum:None */
0, /* linux:512 (KEY_NUMERIC_0) -> linux:512 (KEY_NUMERIC_0) -> qnum:None */
0, /* linux:513 (KEY_NUMERIC_1) -> linux:513 (KEY_NUMERIC_1) -> qnum:None */
0, /* linux:514 (KEY_NUMERIC_2) -> linux:514 (KEY_NUMERIC_2) -> qnum:None */
0, /* linux:515 (KEY_NUMERIC_3) -> linux:515 (KEY_NUMERIC_3) -> qnum:None */
0, /* linux:516 (KEY_NUMERIC_4) -> linux:516 (KEY_NUMERIC_4) -> qnum:None */
0, /* linux:517 (KEY_NUMERIC_5) -> linux:517 (KEY_NUMERIC_5) -> qnum:None */
0, /* linux:518 (KEY_NUMERIC_6) -> linux:518 (KEY_NUMERIC_6) -> qnum:None */
0, /* linux:519 (KEY_NUMERIC_7) -> linux:519 (KEY_NUMERIC_7) -> qnum:None */
0, /* linux:520 (KEY_NUMERIC_8) -> linux:520 (KEY_NUMERIC_8) -> qnum:None */
0, /* linux:521 (KEY_NUMERIC_9) -> linux:521 (KEY_NUMERIC_9) -> qnum:None */
0, /* linux:522 (KEY_NUMERIC_STAR) -> linux:522 (KEY_NUMERIC_STAR) -> qnum:None */
0, /* linux:523 (KEY_NUMERIC_POUND) -> linux:523 (KEY_NUMERIC_POUND) -> qnum:None */
0, /* linux:524 (KEY_RFKILL) -> linux:524 (KEY_RFKILL) -> qnum:None */
*/
)
}

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2021 Gaurav Ujjwal.
~
~ SPDX-License-Identifier: GPL-3.0-or-later
~
~ See COPYING.txt for more details.
-->
<!--
This color can be used for the icon of controls (e.g. EditText).
It is only valid for API >= 23
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="?android:disabledAlpha" android:color="?colorControlNormal" android:state_enabled="false" />
<item android:color="?colorControlNormal" android:state_focused="false" android:state_pressed="false" />
<item android:color="?colorControlActivated" />
</selector>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2020 Gaurav Ujjwal.
~
~ SPDX-License-Identifier: GPL-3.0-or-later
~
~ See COPYING.txt for more details.
-->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="?colorControlHighlight">
<item>
<shape android:shape="oval">
<solid android:color="?attr/colorSurface" />
<size
android:width="@dimen/action_btn_size"
android:height="@dimen/action_btn_size" />
<stroke
android:width="1dp"
android:color="@color/colorBorder" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2020 Gaurav Ujjwal.
~
~ SPDX-License-Identifier: GPL-3.0-or-later
~
~ See COPYING.txt for more details.
-->
<!-- Custom background for FrameView. Used to avoid white overlay when not in touch-mode-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/transparent" android:state_focused="true" />
<item android:drawable="@android:color/transparent" android:state_window_focused="true" />
<item android:drawable="@android:color/transparent" />
</selector>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2020 Gaurav Ujjwal.
~
~ SPDX-License-Identifier: GPL-3.0-or-later
~
~ See COPYING.txt for more details.
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="?attr/colorSurface" />
<corners android:radius="5dp" />
<stroke
android:width="1dp"
android:color="@color/colorBorder" />
</shape>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2020 Gaurav Ujjwal.
~
~ SPDX-License-Identifier: GPL-3.0-or-later
~
~ See COPYING.txt for more details.
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true">
<color android:color="?colorControlHighlight" />
</item>
</selector>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2020 Gaurav Ujjwal.
~
~ SPDX-License-Identifier: GPL-3.0-or-later
~
~ See COPYING.txt for more details.
-->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="?colorControlHighlight">
<item>
<shape android:shape="rectangle">
<corners android:radius="@dimen/urlbar_bg_corner_radius" />
<solid android:color="@color/colorUrlBarBackground" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?colorOnSurface"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?colorOnSurface"
android:pathData="M17,3H7c-1.1,0 -1.99,0.9 -1.99,2L5,21l7,-3 7,3V5c0,-1.1 -0.9,-2 -2,-2z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?colorOnSurface"
android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5c-0.49,0 -0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8L4,8v2h2.09c-0.05,0.33 -0.09,0.66 -0.09,1v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10L20,8zM14,16h-4v-2h4v2zM14,12h-4v-2h4v2z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?colorOnSurface"
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?colorOnSurface"
android:pathData="M20,18c1.1,0 1.99,-0.9 1.99,-2L22,6c0,-1.1 -0.9,-2 -2,-2H4c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2H0v2h24v-2h-4zM4,6h16v10H4V6z" />
</vector>

View File

@@ -0,0 +1,22 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="oval">
<solid android:color="#f5f5f5" />
</shape>
</item>
<item
android:bottom="16dp"
android:left="16dp"
android:right="16dp"
android:top="16dp">
<vector
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#2128ab"
android:pathData="M20,18c1.1,0 1.99,-0.9 1.99,-2L22,6c0,-1.1 -0.9,-2 -2,-2H4c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2H0v2h24v-2h-4zM4,6h16v10H4V6z" />
</vector>
</item>
</layer-list>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?colorOnSurface"
android:pathData="M19,9h-4V3H9v6H5l7,7 7,-7zM5,18v2h14v-2H5z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?colorOnSurface"
android:pathData="M19.8,18.4L14,10.67V6.5l1.35,-1.69C15.61,4.48 15.38,4 14.96,4H9.04C8.62,4 8.39,4.48 8.65,4.81L10,6.5v4.17L4.2,18.4C3.71,19.06 4.18,20 5,20h14C19.82,20 20.29,19.06 19.8,18.4z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?colorOnSurface"
android:pathData="M6,2c-1.1,0 -1.99,0.9 -1.99,2L4,20c0,1.1 0.89,2 1.99,2L18,22c1.1,0 2,-0.9 2,-2L20,8l-6,-6L6,2zM13,9L13,3.5L18.5,9L13,9z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?colorOnSurface"
android:pathData="M4.59,6.89c0.7,-0.71 1.4,-1.35 1.71,-1.22 0.5,0.2 0,1.03 -0.3,1.52 -0.25,0.42 -2.86,3.89 -2.86,6.31 0,1.28 0.48,2.34 1.34,2.98 0.75,0.56 1.74,0.73 2.64,0.46 1.07,-0.31 1.95,-1.4 3.06,-2.77 1.21,-1.49 2.83,-3.44 4.08,-3.44 1.63,0 1.65,1.01 1.76,1.79 -3.78,0.64 -5.38,3.67 -5.38,5.37 0,1.7 1.44,3.09 3.21,3.09 1.63,0 4.29,-1.33 4.69,-6.1L21,14.88v-2.5h-2.47c-0.15,-1.65 -1.09,-4.2 -4.03,-4.2 -2.25,0 -4.18,1.91 -4.94,2.84 -0.58,0.73 -2.06,2.48 -2.29,2.72 -0.25,0.3 -0.68,0.84 -1.11,0.84 -0.45,0 -0.72,-0.83 -0.36,-1.92 0.35,-1.09 1.4,-2.86 1.85,-3.52 0.78,-1.14 1.3,-1.92 1.3,-3.28C8.95,3.69 7.31,3 6.44,3 5.12,3 3.97,4 3.72,4.25c-0.36,0.36 -0.66,0.66 -0.88,0.93l1.75,1.71zM13.88,18.55c-0.31,0 -0.74,-0.26 -0.74,-0.72 0,-0.6 0.73,-2.2 2.87,-2.76 -0.3,2.69 -1.43,3.48 -2.13,3.48z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?colorOnSurface"
android:pathData="m11.999,1c-6.0742,-0 -10.999,5.0495 -10.999,11.2787 0,4.983 3.1515,9.2099 7.5226,10.702 0.5504,0.1032 0.7509,-0.2451 0.7509,-0.5442 0,-0.2673 -0.0095,-0.9769 -0.0149,-1.9179 -3.0597,0.6813 -3.7053,-1.5121 -3.7053,-1.5121 -0.5004,-1.3024 -1.2216,-1.6492 -1.2216,-1.6492 -0.9987,-0.7 0.0756,-0.6861 0.0756,-0.6861 1.1041,0.0803 1.6848,1.1625 1.6848,1.1625 0.9812,1.7233 2.5748,1.2255 3.2015,0.9375 0.0999,-0.7291 0.3836,-1.2262 0.6982,-1.508 -2.4425,-0.2846 -5.0106,-1.2525 -5.0106,-5.5743 0,-1.231 0.4288,-2.2377 1.1324,-3.0264 -0.1134,-0.2853 -0.4909,-1.4318 0.1074,-2.9848 0,-0 0.9238,-0.3033 3.0253,1.1563 0.8772,-0.2499 1.8185,-0.3753 2.7538,-0.3794 0.9339,0.0042 1.8753,0.1295 2.7538,0.3794 2.1001,-1.4595 3.0219,-1.1563 3.0219,-1.1563 0.6003,1.553 0.2228,2.6996 0.1094,2.9848 0.705,0.7886 1.1311,1.7953 1.1311,3.0264 0,4.3329 -2.5721,5.2863 -5.0227,5.5653 0.395,0.3483 0.7469,1.0365 0.7469,2.0889 0,1.5073 -0.0135,2.7238 -0.0135,3.0935 0,0.3019 0.1979,0.6529 0.7563,0.5428 4.3677,-1.4948 7.5166,-5.719 7.5166,-10.7006C23,6.0495 18.0745,1 11.999,1" />
</vector>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?colorOnSurface"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,19h-2v-2h2v2zM15.07,11.25l-0.9,0.92C13.45,12.9 13,13.5 13,15h-2v-0.5c0,-1.1 0.45,-2.1 1.17,-2.83l1.24,-1.26c0.37,-0.36 0.59,-0.86 0.59,-1.41 0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2L8,9c0,-2.21 1.79,-4 4,-4s4,1.79 4,4c0,0.88 -0.36,1.68 -0.93,2.25z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?colorOnSurface"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-6h2v6zM13,9h-2L11,7h2v2z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?colorOnSurface"
android:pathData="M12.65,10C11.83,7.67 9.61,6 7,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6c2.61,0 4.83,-1.67 5.65,-4H17v4h4v-4h2v-4H12.65zM7,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?colorOnSurface"
android:pathData="M20,5L4,5c-1.1,0 -1.99,0.9 -1.99,2L2,17c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,7c0,-1.1 -0.9,-2 -2,-2zM11,8h2v2h-2L11,8zM11,11h2v2h-2v-2zM8,8h2v2L8,10L8,8zM8,11h2v2L8,13v-2zM7,13L5,13v-2h2v2zM7,10L5,10L5,8h2v2zM16,17L8,17v-2h8v2zM16,13h-2v-2h2v2zM16,10h-2L14,8h2v2zM19,13h-2v-2h2v2zM19,10h-2L17,8h2v2z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?colorOnSurface"
android:pathData="M7.41,8.59L12,13.17l4.59,-4.58L18,10l-6,6 -6,-6 1.41,-1.41z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?colorOnSurface"
android:pathData="M15.41,16.59L10.83,12l4.58,-4.59L14,6l-6,6 6,6 1.41,-1.41z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?colorOnSurface"
android:pathData="M8.59,16.59L13.17,12 8.59,7.41 10,6l6,6 -6,6 -1.41,-1.41z" />
</vector>

Some files were not shown because too many files have changed in this diff Show More