Compare commits

..

No commits in common. "cepa-upstream" and "master" have entirely different histories.

237 changed files with 2034 additions and 10545 deletions

2
.github/FUNDING.yml vendored
View file

@ -1,2 +1,2 @@
github: gujjwal00
liberapay: gujjwal00
custom: ["https://paypal.me/gujjwal00"]

View file

@ -1,4 +1,4 @@
name: Main
name: Android CI
on: push
@ -9,14 +9,13 @@ jobs:
steps:
- name: Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@v2
with:
submodules: true
- name: set up JDK
uses: actions/setup-java@v3
- name: set up JDK 1.8
uses: actions/setup-java@v1
with:
distribution: 'temurin'
java-version: '11'
java-version: 1.8
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build with Gradle
@ -29,10 +28,10 @@ jobs:
working-directory: ./app/build/outputs/apk/ci/
run: |
echo $CI_SIGNING_KS | base64 --decode > keystore.jks
$ANDROID_HOME/build-tools/33.0.0/apksigner sign --in app-ci-unsigned.apk --out app-ci.apk --ks keystore.jks --ks-pass 'env:CI_SIGNING_KS_PASSWORD'
$ANDROID_HOME/build-tools/30.0.3/apksigner sign --in app-ci-unsigned.apk --out app-ci.apk --ks keystore.jks --ks-pass 'env:CI_SIGNING_KS_PASSWORD'
- name: Upload APK
uses: actions/upload-artifact@v3.1.1
uses: actions/upload-artifact@v2
with:
name: avnc-ci
path: app/build/outputs/apk/ci/app-ci.apk

View file

@ -1,4 +1,4 @@
name: Pull Requests
name: Android CI
on:
pull_request:
@ -11,20 +11,19 @@ jobs:
steps:
- name: Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@v2
with:
submodules: true
- name: set up JDK
uses: actions/setup-java@v3
- name: set up JDK 1.8
uses: actions/setup-java@v1
with:
distribution: 'temurin'
java-version: '11'
java-version: 1.8
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build with Gradle
run: ./gradlew assembleDebug
- name: Upload APK
uses: actions/upload-artifact@v3.1.1
uses: actions/upload-artifact@v2
with:
name: avnc-debug
path: app/build/outputs/apk/debug/*.apk

7
.gitignore vendored
View file

@ -39,10 +39,3 @@ gastest.o
#Private data
private/
# Release APK
app/release/
# Proguard generated files
app/seeds.txt
app/usage.txt

View file

@ -4,9 +4,6 @@
<JavaCodeStyleSettings>
<option name="JD_P_AT_EMPTY_LINES" value="false" />
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="ObjectiveC">
<option name="RIGHT_MARGIN" value="120" />
<option name="SOFT_MARGINS" value="80,100" />
@ -124,9 +121,9 @@
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
<option name="RIGHT_MARGIN" value="120" />
<option name="ALIGN_MULTILINE_PARAMETERS_IN_CALLS" value="true" />
<option name="ALIGN_MULTILINE_BINARY_OPERATION" value="true" />
<option name="CLASS_ANNOTATION_WRAP" value="1" />
<option name="WRAP_ON_TYPING" value="0" />
</codeStyleSettings>

View file

@ -21,8 +21,9 @@
- VNC Repeater support
- Clipboard Sync with server
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/com.gaurav.avnc/)
[<img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Play Store" height="80">](https://play.google.com/store/apps/details?id=com.gaurav.avnc)
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/com.gaurav.avnc/)
### Screenshots
@ -49,7 +50,7 @@ Tools required:
To get started, simply clone the repo and initialize submodules:
```bash
git clone https://github.com/gujjwal00/avnc.git
git clone git@github.com:gujjwal00/avnc.git
cd avnc
git submodule update --init --depth 1
```
@ -60,7 +61,5 @@ Read [Architecture.kt](app/src/main/java/com/gaurav/avnc/Architecture.kt) (prefe
Android Studio) to know more about the code.
##
### Translation
You can translate AVNC on [Weblate](https://hosted.weblate.org/engage/avnc/).
[<img src="https://hosted.weblate.org/widgets/avnc/-/open-graph.png" alt="Translation status" height="200" />](https://hosted.weblate.org/engage/avnc/)

View file

@ -77,11 +77,10 @@ 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)
include_directories(${AVNC_LIBVNC_SRC_DIR} ${AVNC_LIBVNC_BUILD_DIR})
###############################################################################

View file

@ -1,21 +1,18 @@
plugins {
id 'com.android.application' version '7.2.2'
id 'org.jetbrains.kotlin.android' version '1.8.0'
id 'org.jetbrains.kotlin.kapt' version '1.8.0'
id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.0'
id "org.jetbrains.kotlin.plugin.parcelize" version "1.8.0"
}
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlinx-serialization'
apply plugin: 'kotlin-parcelize'
android {
compileSdkVersion 33
compileSdkVersion 30
defaultConfig {
applicationId "com.ldc.kiosk"
minSdkVersion 30
targetSdkVersion 33
applicationId "com.gaurav.avnc"
minSdkVersion 21
targetSdkVersion 30
versionCode 14
//versionName "2.2.3"
versionName "1.0.1"
versionName "1.9.2"
javaCompileOptions {
annotationProcessorOptions {
@ -34,10 +31,6 @@ android {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/roomSchema".toString())
}
buildTypes {
debug {
@ -60,35 +53,16 @@ android {
buildFeatures {
dataBinding true
}
signingConfigs {
buildTypes {
debug {
buildConfigField "java.util.Date", "BUILD_TIME", "new java.util.Date(" + System.currentTimeMillis() + "L)"
}
release {
buildConfigField "java.util.Date", "BUILD_TIME", "new java.util.Date(" + System.currentTimeMillis() + "L)"
}
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
packagingOptions {
exclude 'META-INF/ASL-2.0.txt'
exclude 'META-INF/LGPL-3.0.txt'
}
externalNativeBuild {
cmake {
version '3.22.1'
path file('CMakeLists.txt')
}
}
@ -104,51 +78,34 @@ android {
}
dependencies {
implementation "androidx.core:core-ktx:1.9.0"
implementation 'androidx.activity:activity-ktx:1.6.1'
implementation "androidx.fragment:fragment-ktx:1.5.5"
implementation "androidx.appcompat:appcompat:1.6.0"
implementation "androidx.core:core-ktx:1.5.0"
implementation "androidx.activity:activity-ktx:1.2.3"
implementation "androidx.fragment:fragment-ktx:1.3.4"
implementation "androidx.appcompat:appcompat:1.3.0"
implementation "androidx.dynamicanimation:dynamicanimation:1.0.0"
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
implementation "androidx.preference:preference:1.2.0"
implementation "org.greenrobot:eventbus:3.3.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1"
implementation "androidx.lifecycle:lifecycle-process:2.3.1"
implementation "androidx.lifecycle:lifecycle-common:2.3.1"
implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha04"
implementation "androidx.preference:preference:1.1.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1"
implementation "androidx.recyclerview:recyclerview:1.2.0"
implementation "androidx.constraintlayout:constraintlayout:2.0.4"
implementation "androidx.drawerlayout:drawerlayout:1.1.1"
implementation "androidx.room:room-runtime:2.4.3"
implementation "androidx.room:room-ktx:2.4.3"
implementation files('libs/com.serialIntCaster-1.1.0.jar')
implementation "androidx.room:room-runtime:2.3.0"
implementation "androidx.room:room-ktx:2.3.0"
kapt "androidx.room:room-compiler:2.3.0"
// Life-cycle
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0"
implementation "androidx.lifecycle:lifecycle-process:2.4.0"
implementation "androidx.lifecycle:lifecycle-common:2.4.0"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.4.0"
implementation "com.google.android.material:material:1.3.0"
// Json
implementation "com.networknt:json-schema-validator:1.0.70"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.1.0"
kapt "androidx.room:room-compiler:2.4.3"
implementation "com.google.guava:guava:30.0-android"
implementation "org.connectbot:sshlib:2.2.19"
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.21"
testImplementation "junit:junit:4.13.2"
androidTestImplementation "androidx.test:runner:1.5.2"
androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1"
androidTestImplementation "androidx.test.espresso:espresso-contrib:3.5.1"
androidTestImplementation "androidx.test.espresso:espresso-intents:3.5.1"
androidTestImplementation "androidx.test:rules:1.5.0"
androidTestImplementation "androidx.test.ext:junit-ktx:1.1.5"
androidTestImplementation "androidx.room:room-testing:2.4.3"
testImplementation "junit:junit:4.13"
androidTestImplementation "androidx.test:runner:1.4.0"
androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0"
androidTestImplementation "androidx.test.espresso:espresso-contrib:3.4.0"
androidTestImplementation "androidx.test.espresso:espresso-intents:3.4.0"
androidTestImplementation "androidx.test:rules:1.4.0"
androidTestImplementation "androidx.test.ext:junit-ktx:1.1.3"
androidTestImplementation "io.mockk:mockk-android:1.12.0"
implementation("com.squareup.okhttp3:okhttp:4.10.0")
implementation("org.apache.commons:commons-csv:1.5")
}

View file

@ -1,2 +1,24 @@
-dontobfuscate
-keepattributes SourceFile,LineNumberTable
##########################################################################
# Rules for keeping JSON Serialization code
# Ref: https://github.com/Kotlin/kotlinx.serialization#android
##########################################################################
-keepattributes *Annotation*, InnerClasses
-keepclassmembers class kotlinx.serialization.json.** {
*** Companion;
}
-keepclasseswithmembers class kotlinx.serialization.json.** {
kotlinx.serialization.KSerializer serializer(...);
}
-keep,includedescriptorclasses class com.gaurav.avnc.**$$serializer { *; }
-keepclassmembers class com.gaurav.avnc.** {
*** Companion;
}
-keepclasseswithmembers class com.gaurav.avnc.** {
kotlinx.serialization.KSerializer serializer(...);
}

View file

@ -1,188 +0,0 @@
{
"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

@ -1,209 +0,0 @@
{
"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

@ -1,216 +0,0 @@
{
"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

@ -9,7 +9,6 @@
package com.gaurav.avnc
import com.gaurav.avnc.model.db.MainDb
import kotlinx.coroutines.runBlocking
import org.junit.rules.ExternalResource
/**
@ -21,6 +20,6 @@ class DatabaseRule : ExternalResource() {
val db by lazy { MainDb.getInstance(targetContext) }
override fun before() {
runBlocking { db.serverProfileDao.deleteAll() }
db.serverProfileDao.deleteAll()
}
}

View file

@ -8,40 +8,22 @@
package com.gaurav.avnc
import android.app.Activity
import android.app.Instrumentation
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.SystemClock
import android.view.View
import android.widget.ProgressBar
import androidx.core.net.toUri
import androidx.preference.PreferenceManager
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.NoMatchingViewException
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.ViewAssertion
import androidx.test.espresso.ViewInteraction
import androidx.test.espresso.action.ViewActions.*
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers
import androidx.test.espresso.matcher.RootMatchers
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.platform.app.InstrumentationRegistry
import junit.framework.AssertionFailedError
import com.gaurav.avnc.viewmodel.BaseViewModel
import org.hamcrest.core.IsNot.not
import org.junit.Assert
import java.io.BufferedWriter
import java.io.File
/**
* Global accessors
*/
val instrumentation; get() = InstrumentationRegistry.getInstrumentation()!!
val targetContext; get() = instrumentation.targetContext!!
val targetPrefs by lazy { PreferenceManager.getDefaultSharedPreferences(targetContext)!! }
/**
* Shorthands
@ -51,8 +33,7 @@ fun ViewInteraction.checkWillBeDisplayed() = checkWithTimeout(matches(isDisplaye
fun ViewInteraction.checkIsNotDisplayed() = check(matches(not(isDisplayed())))!!
fun ViewInteraction.doClick() = perform(click())!!
fun ViewInteraction.doLongClick() = perform(longClick())!!
fun ViewInteraction.doTypeText(text: String) = perform(typeText(text)).perform(closeSoftKeyboard())!!
fun ViewInteraction.inDialog() = inRoot(RootMatchers.isDialog())!!
fun ViewInteraction.doTypeText(text: String) = perform(typeText(text))!!
/**
@ -78,66 +59,11 @@ fun ViewInteraction.checkWithTimeout(assertion: ViewAssertion, timeout: Int = 50
throw Exception("Assertion did not become valid within timeout", t)
}
/**
* Runs given [block] in context of scenario's activity.
* It simplifies the pattern of getting some value using activity.
*/
fun <A : Activity, R> ActivityScenario<A>.withActivity(block: A.() -> R): R {
var r: R? = null
onActivity { r = it.block() }
return r!!
}
/**
* Runs given block synchronously on main thread.
*/
fun <R> runOnMainSync(block: () -> R): R {
var r: R? = null
instrumentation.runOnMainSync { r = block() }
return r!!
}
fun getClipboardText(): String? {
var text: String? = null
instrumentation.runOnMainSync {
(targetContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).let {
text = it.primaryClip?.getItemAt(0)?.text?.toString()
}
// Reusing BaseViewModel to access clipboard
text = BaseViewModel(ApplicationProvider.getApplicationContext<App>()).getClipboardText()
}
return text
}
fun setClipboardText(text: String) {
instrumentation.runOnMainSync {
(targetContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager)
.setPrimaryClip(ClipData.newPlainText(null, text))
}
}
/**
* Asserts that given [test] passes against current progress of a [ProgressBar].
*/
class ProgressAssertion(private val test: (Int) -> Boolean) : ViewAssertion {
override fun check(view: View?, noViewFoundException: NoMatchingViewException?) {
noViewFoundException?.let { throw it }
if (view !is ProgressBar) throw AssertionFailedError("View is not a ProgressBar")
Assert.assertTrue("Progress test failed for '${view.progress}'", test(view.progress))
}
}
/**
* Helper for testing file selection by user.
*
* It creates a temporary file, populated by invoking [fileWriter]
* Then Intent response is hooked up to return that file.
*
* Note: [Intents.init] must have been called before using this function
*/
fun setupFileOpenIntent(fileWriter: BufferedWriter.() -> Unit) {
val file = File.createTempFile("avnc", "test")
file.bufferedWriter().use { it.fileWriter() }
Intents.intending(IntentMatchers.hasAction(Intent.ACTION_OPEN_DOCUMENT))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent().setData(file.toUri())))
}
fun setupFileOpenIntent(fileContent: String) = setupFileOpenIntent { write(fileContent) }
}

View file

@ -8,6 +8,7 @@
package com.gaurav.avnc
import kotlinx.coroutines.*
import java.io.InputStream
import java.net.ServerSocket
import java.nio.ByteBuffer
@ -38,7 +39,7 @@ class TestServer(name: String = "Friends") {
//Server config
private val ss = ServerSocket(0)
private val cutTextQueue = LinkedTransferQueue<String>()
private val serverJob = Thread { theServer() }
private lateinit var serverJob: Job
val host = ss.inetAddress.hostAddress!!
val port = ss.localPort
var receivedKeySyms = arrayListOf<Int>()
@ -46,11 +47,11 @@ class TestServer(name: String = "Friends") {
fun start() {
serverJob.start()
serverJob = GlobalScope.launch(Dispatchers.IO) { theServer() }
}
fun awaitStop() {
serverJob.join(30_000)
runBlocking { serverJob.join() }
}
fun sendCutText(str: String) {

View file

@ -1,36 +0,0 @@
/*
* Copyright (c) 2023 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.model
import androidx.room.testing.MigrationTestHelper
import com.gaurav.avnc.instrumentation
import com.gaurav.avnc.model.db.MainDb
import org.junit.Rule
import org.junit.Test
class DatabaseTest {
private val dbName = "Bond. James Bond."
private val minVersion = 1
private val maxVersion = 4
@get:Rule
val helper = MigrationTestHelper(instrumentation, MainDb::class.java)
@Test
fun migrations() {
for (i in minVersion until maxVersion)
for (j in i + 1..maxVersion)
runCatching {
helper.createDatabase(dbName, i)
helper.runMigrationsAndValidate(dbName, j, false)
}.onFailure {
throw Exception("Failed to migrate MainDb from [$i] to [$j]", it)
}
}
}

View file

@ -10,8 +10,8 @@ package com.gaurav.avnc.ui.home
import android.app.Activity
import android.app.Instrumentation
import androidx.core.content.edit
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.swipeLeft
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
@ -23,24 +23,16 @@ import com.gaurav.avnc.*
import com.gaurav.avnc.model.ServerProfile
import com.gaurav.avnc.ui.vnc.VncActivity
import org.hamcrest.core.AllOf.allOf
import org.junit.*
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class DiscoveryListTest {
companion object {
@BeforeClass
@JvmStatic
fun beforeTests() = targetPrefs.edit { putBoolean("discovery_autorun", false) }
@AfterClass
@JvmStatic
fun afterTests() = targetPrefs.edit { putBoolean("discovery_autorun", true) }
}
@Rule
@JvmField
val activityRule = ActivityScenarioRule(HomeActivity::class.java)
@ -61,8 +53,13 @@ class DiscoveryListTest {
@Before
fun before() {
onView(withContentDescription(R.string.desc_discovered_servers_tab)).doClick()
onView(withContentDescription(R.string.desc_discovery_btn)).checkWithTimeout(matches(isCompletelyDisplayed()))
// Stop discovery, otherwise tests will hang due to progressbar animation
// We can't even use the stop button
activityRule.scenario.onActivity { it.viewModel.stopDiscovery() }
onView(withId(R.id.pager)).perform(swipeLeft())
onView(withId(R.id.discovered_rv)).checkWithTimeout(matches(isCompletelyDisplayed()))
onView(withContentDescription(R.string.desc_discovery_btn)).checkWillBeDisplayed()
}
@Test

View file

@ -8,11 +8,8 @@
package com.gaurav.avnc.ui.home
import androidx.core.content.edit
import androidx.test.espresso.Espresso.closeSoftKeyboard
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ScrollToAction
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.ext.junit.rules.ActivityScenarioRule
@ -20,14 +17,12 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.gaurav.avnc.*
import com.gaurav.avnc.model.ServerProfile
import org.hamcrest.core.AllOf.allOf
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class BasicEditorTest {
class ProfileEditorTest {
private val testProfile = ServerProfile(name = "foo", host = "bar")
@ -73,70 +68,6 @@ class BasicEditorTest {
hasDescendant(withText(testProfile.host)))
).checkWillBeDisplayed()
}
}
@RunWith(AndroidJUnit4::class)
class PrivateKeyTest {
private val SAMPLE_KEY = """
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIOlzJCBaTq2RpkLatMn+PMwsQ/ncijqK5M6y5kPns+BAoAoGCCqGSM49
AwEHoUQDQgAE5vfo9Is5+pnk+6dC3NlXA9rKNy0X6PdifyeI2OKKTGVA4cdhEOBh
MXTgJKarp26FiFnsFuWEGOO61Q/Plmv8Wg==
-----END EC PRIVATE KEY-----
""".trimIndent()
private val SAMPLE_ENCRYPTED_KEY = """
-----BEGIN EC PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-128-CBC,D18B845F2B9D4B9FEB3472809F270AD2
fNNjQEFap5uf3+gaQNI+A2ait7WZDjxPO6sT6MfOTQ34/HRxm7El4FWhhYMfIiGl
5j/A30Je2OMbOEqTpiKZKYxKxWIZ27te8HpxsQkC9xvU9rxPPsd0Vt/7na5rbx1S
Iqfy2ows7NJY3f3s/GlMY2ZmaAwk6TJ3DAJ+YK0KMrc=
-----END EC PRIVATE KEY-----
""".trimIndent()
@Rule
@JvmField
val activityRule = ActivityScenarioRule(HomeActivity::class.java)
@Before
fun before() {
Intents.init()
targetPrefs.edit { putBoolean("prefer_advanced_editor", true) }
onView(withContentDescription(R.string.desc_add_new_server_btn)).doClick()
onView(withId(R.id.use_ssh_tunnel)).perform(ScrollToAction())
onView(withId(R.id.use_ssh_tunnel)).doClick()
onView(withId(R.id.key_import_btn)).perform(ScrollToAction())
}
@After
fun after() {
targetPrefs.edit { putBoolean("prefer_advanced_editor", false) }
Intents.release()
}
@Test
fun importValidPK() {
setupFileOpenIntent(SAMPLE_KEY)
onView(withId(R.id.key_import_btn)).doClick()
onView(withText(R.string.msg_imported)).checkWillBeDisplayed()
onView(withId(R.id.ssh_key_password)).checkIsNotDisplayed()
}
@Test
fun importEncryptedPK() {
setupFileOpenIntent(SAMPLE_ENCRYPTED_KEY)
onView(withId(R.id.key_import_btn)).doClick()
onView(withText(R.string.msg_imported)).checkWillBeDisplayed()
onView(withId(R.id.ssh_key_password)).checkWillBeDisplayed()
}
@Test
fun importInvalidFile() {
setupFileOpenIntent("This is not a Private Key, right?")
onView(withId(R.id.key_import_btn)).doClick()
onView(withText(R.string.msg_invalid_key_file)).checkWillBeDisplayed()
}
//TODO add more tests
}

View file

@ -10,7 +10,7 @@ package com.gaurav.avnc.ui.home
import android.app.Activity
import android.app.Instrumentation
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.PositionAssertions.isCompletelyAbove
import androidx.test.espresso.assertion.ViewAssertions.matches
@ -23,7 +23,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.gaurav.avnc.*
import com.gaurav.avnc.model.ServerProfile
import com.gaurav.avnc.ui.vnc.VncActivity
import kotlinx.coroutines.runBlocking
import org.hamcrest.core.AllOf.allOf
import org.junit.Assert.assertEquals
import org.junit.Before
@ -57,7 +56,7 @@ class ServerListTest {
@Before
fun before() {
runBlocking { dbRule.db.serverProfileDao.insert(testProfile) }
dbRule.db.serverProfileDao.insert(testProfile)
}
@Test
@ -111,21 +110,21 @@ class ServerListTest {
@Test
fun sortServers() {
val prefEditor = PreferenceManager.getDefaultSharedPreferences(targetContext).edit()
with(dbRule.db.serverProfileDao) {
runBlocking {
deleteAll()
insert(ServerProfile(name = "pqr"))
insert(ServerProfile(name = "abc"))
}
deleteAll()
insert(ServerProfile(name = "pqr"))
insert(ServerProfile(name = "abc"))
}
//Without sorting, "pqr" should be above "abc", as it was inserted first
targetPrefs.edit { putBoolean("sort_server_list", false) }
prefEditor.putBoolean("sort_server_list", false).apply()
onView(withText("pqr")).checkWithTimeout(isCompletelyAbove(withText("abc")))
//With sorting, "abc" should be above "pqr"
targetPrefs.edit { putBoolean("sort_server_list", true) }
prefEditor.putBoolean("sort_server_list", true).commit()
onView(withText("abc")).checkWithTimeout(isCompletelyAbove(withText("pqr")))
}
}

View file

@ -1,99 +0,0 @@
/*
* Copyright (c) 2022 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.pref
import android.app.Activity
import android.app.Instrumentation
import android.content.Intent
import androidx.core.net.toUri
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers
import androidx.test.espresso.matcher.ViewMatchers.withSubstring
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import com.gaurav.avnc.*
import com.gaurav.avnc.model.ServerProfile
import com.gaurav.avnc.model.db.MainDb
import com.gaurav.avnc.ui.prefs.PrefsActivity
import com.gaurav.avnc.util.DeviceAuthPrompt
import kotlinx.coroutines.runBlocking
import org.junit.*
import java.io.File
class ImportExportTest {
@Rule
@JvmField
val activityRule = ActivityScenarioRule(PrefsActivity::class.java)
@Before
fun openImportExportScreen() {
onView(withText(R.string.pref_tools)).doClick()
onView(withText(R.string.pref_import_export)).doClick()
onView(withText(R.string.title_import)).checkIsDisplayed()
onView(withText(R.string.title_export)).checkIsDisplayed()
}
@Before
fun initIntents() = Intents.init()
@After
fun releaseIntents() = Intents.release()
@Test
fun exportTest() {
// This test requires an unlocked device
activityRule.scenario.onActivity { Assume.assumeFalse(DeviceAuthPrompt(it).canLaunch()) }
// Insert sample data
val sampleName = "Days of our Lives"
runBlocking { MainDb.getInstance(targetContext).serverProfileDao.insert(ServerProfile(name = sampleName)) }
// Setup export file
val file = File.createTempFile("avnc", "test")
Intents.intending(IntentMatchers.hasAction(Intent.ACTION_CREATE_DOCUMENT))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent().setData(file.toUri())))
// Export
onView(withText(R.string.title_export)).doClick()
onView(withText(R.string.msg_exported)).checkWillBeDisplayed()
onView(withSubstring("Error")).checkIsNotDisplayed()
// Verify exported data
instrumentation.waitForIdleSync()
val data = file.bufferedReader().readText()
Assert.assertTrue("Exported data: `$data` contains `$sampleName`", data.contains(sampleName))
}
@Test
fun importTest() {
val sampleName = "Joey Tribbiani"
val sampleJson = """{ "profiles": [{ "name": "$sampleName" }]}"""
// Setup import file
val file = File.createTempFile("avnc", "test")
file.bufferedWriter().use { it.write(sampleJson) }
Intents.intending(IntentMatchers.hasAction(Intent.ACTION_OPEN_DOCUMENT))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent().setData(file.toUri())))
// Import
onView(withText(R.string.title_delete_servers_before_import)).doClick()
onView(withText(R.string.title_import)).doClick()
onView(withText(R.string.msg_imported)).checkWillBeDisplayed()
onView(withSubstring("Error")).checkIsNotDisplayed()
// Verify imported data
instrumentation.waitForIdleSync()
val profiles = runBlocking { MainDb.getInstance(targetContext).serverProfileDao.getList() }
Assert.assertEquals(1, profiles.size)
Assert.assertEquals(sampleName, profiles.first().name)
}
}

View file

@ -1,70 +0,0 @@
/*
* Copyright (c) 2022 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.pref
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import com.gaurav.avnc.R.string.*
import com.gaurav.avnc.checkIsDisplayed
import com.gaurav.avnc.checkWillBeDisplayed
import com.gaurav.avnc.doClick
import com.gaurav.avnc.ui.prefs.PrefsActivity
import org.junit.Rule
import org.junit.Test
/**
* Simple tests to make sure [PrefsActivity] is not completely broken.
*/
class PrefsActivityTest {
@Rule
@JvmField
val prefActivity = ActivityScenarioRule(PrefsActivity::class.java)
@Test
fun uiTest() {
openSection(pref_ui, pref_ui_summary, pref_theme)
}
@Test
fun viewerTest() {
openSection(pref_viewer, pref_viewer_summary, pref_fullscreen)
}
@Test
fun inputTest() {
openSection(pref_input, pref_input_summary, pref_gesture_style)
}
@Test
fun serverTest() {
openSection(pref_servers, pref_server_summary, pref_discovery)
}
@Test
fun toolsTest() {
openSection(pref_tools, pref_tools_summary, pref_import_export)
}
/**
* Validates section can be opened.
*/
private fun openSection(sectionNameId: Int, sectionSummaryId: Int, childPrefNameId: Int) {
onView(withText(sectionNameId)).checkIsDisplayed()
onView(withText(sectionSummaryId)).checkIsDisplayed()
onView(withText(sectionNameId)).doClick()
onView(withText(childPrefNameId)).checkWillBeDisplayed()
onView(withText(sectionNameId)).checkIsDisplayed()
onView(withText(sectionSummaryId)).check(doesNotExist())
}
}

View file

@ -1,126 +0,0 @@
/*
* Copyright (c) 2023 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.content.Intent
import android.net.Uri
import android.view.View
import android.view.WindowManager.LayoutParams.TYPE_TOAST
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.*
import androidx.test.espresso.Root
import androidx.test.espresso.ViewInteraction
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.filters.SdkSuppress
import com.gaurav.avnc.*
import com.gaurav.avnc.model.ServerProfile
import kotlinx.coroutines.runBlocking
import org.hamcrest.Description
import org.hamcrest.Matcher
import org.hamcrest.TypeSafeMatcher
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
/**
* Tests connection launch from external intents (e.g. via vnc:// URIs)
*/
class IntentReceiverTest {
@Rule
@JvmField
val dbRule = DatabaseRule()
/**
* Matching toast doesn't work on API 30+
* So all tests which checks toasts will only work on lower APIs.
*/
private fun onToast(matcher: Matcher<View>): ViewInteraction {
class ToastRootMatcher : TypeSafeMatcher<Root>() {
override fun describeTo(description: Description) {
description.appendText("is toast")
}
@Suppress("DEPRECATION")
override fun matchesSafely(root: Root) = root.windowLayoutParams.get().type == TYPE_TOAST
}
return onView(matcher).noActivity().inRoot(ToastRootMatcher())
}
/******************************** VNC URIs ***************************************/
private fun newUriIntent(uri: String): Intent {
return Intent(Intent.ACTION_VIEW, Uri.parse(uri)).apply {
setPackage(targetContext.packageName) // Set package name to avoid activity chooser
}
}
@Test
fun simpleVncUri() {
val server = TestServer().apply { start() }
ActivityScenario.launch<Activity>(newUriIntent("vnc://localhost:${server.port}")).use {
onView(withId(R.id.frame_view)).checkWillBeDisplayed()
assertEquals(Lifecycle.State.DESTROYED, it.state)
}
}
@Test
@SdkSuppress(maxSdkVersion = 29)
fun invalidVncUri() {
ActivityScenario.launch<Activity>(newUriIntent("vnc://:5900"))
onToast(withText(R.string.msg_invalid_vnc_uri)).checkWillBeDisplayed()
}
@Test
fun uriForSavedConnection() {
val server = TestServer().apply { start() }
runBlocking {
dbRule.db.serverProfileDao.insert(ServerProfile(name = "Example", host = "localhost", port = server.port))
}
ActivityScenario.launch<Activity>(newUriIntent("vnc://?ConnectionName=Example")).use {
onView(withId(R.id.frame_view)).checkWillBeDisplayed()
assertEquals(Lifecycle.State.DESTROYED, it.state)
}
}
@Test
@SdkSuppress(maxSdkVersion = 29)
fun uriWithUnknownConnectionName() {
ActivityScenario.launch<Activity>(newUriIntent("vnc://?ConnectionName=NoSuchServer"))
onToast(withSubstring("No server found with name")).checkWillBeDisplayed()
}
/******************************** Shortcuts **************************************/
private fun newShortcutIntent(profile: ServerProfile) = IntentReceiverActivity.createShortcutIntent(targetContext, profile.ID)
@Test
fun simpleShortcut() {
val server = TestServer().apply { start() }
val profile = ServerProfile(host = "localhost", port = server.port)
runBlocking { profile.ID = dbRule.db.serverProfileDao.insert(profile) }
ActivityScenario.launch<Activity>(newShortcutIntent(profile)).use {
onView(withId(R.id.frame_view)).checkWillBeDisplayed()
assertEquals(Lifecycle.State.DESTROYED, it.state)
}
}
@Test
@SdkSuppress(maxSdkVersion = 29)
fun invalidShortcut() {
ActivityScenario.launch<Activity>(newShortcutIntent(ServerProfile(ID = 123456)))
onToast(withText(R.string.msg_shortcut_server_deleted)).checkWillBeDisplayed()
}
}

View file

@ -35,8 +35,6 @@ class KeyHandlerTest {
private lateinit var mockDispatcher: Dispatcher
private lateinit var dispatchedKeyDowns: ArrayList<Int>
private lateinit var dispatchedKeyUps: ArrayList<Int>
private lateinit var dispatchedXTDowns: ArrayList<Int>
private lateinit var dispatchedXTUps: ArrayList<Int>
@Before
fun before() {
@ -44,19 +42,10 @@ class KeyHandlerTest {
dispatchedKeyDowns = arrayListOf()
dispatchedKeyUps = arrayListOf()
dispatchedXTDowns = arrayListOf()
dispatchedXTUps = arrayListOf()
mockDispatcher = mockk()
every { mockDispatcher.onXKey(any(), any(), true) } answers {
dispatchedKeyDowns.add(firstArg())
dispatchedXTDowns.add(secondArg())
true
}
every { mockDispatcher.onXKey(any(), any(), false) } answers {
dispatchedKeyUps.add(firstArg())
dispatchedXTUps.add(secondArg())
true
}
every { mockDispatcher.onXKeySym(any(), true) } answers { dispatchedKeyDowns.add(firstArg()); true }
every { mockDispatcher.onXKeySym(any(), false) } answers { dispatchedKeyUps.add(firstArg()); true }
keyHandler = KeyHandler(mockDispatcher, true, prefs)
}
@ -88,7 +77,7 @@ class KeyHandlerTest {
}
private fun sendKey(keyCode: Int, uChar: Char) {
sendKey(keyCode, uChar.code)
sendKey(keyCode, uChar.toInt())
}
private fun sendAccent(accent: Int) {
@ -102,73 +91,60 @@ class KeyHandlerTest {
keyHandler.onKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_UP, keyCode, 0, metaState))
}
private fun sendKeyWithScancode(keyCode: Int, scancode: Int) {
keyHandler.onKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_DOWN, keyCode, 0, 0, 0, scancode))
keyHandler.onKeyEvent(KeyEvent(0, 0, KeyEvent.ACTION_UP, keyCode, 0, 0, 0, scancode))
}
/**************************************************************************/
@Test
fun simpleChar() {
keyHandler.onKey(KeyEvent.KEYCODE_A)
assertEquals('a'.code, dispatchedKeyDowns.firstOrNull())
assertEquals('a'.toInt(), dispatchedKeyDowns.firstOrNull())
}
@Test
fun charWithShift() {
sendKeyWithMeta(KeyEvent.KEYCODE_A, KeyEvent.META_SHIFT_ON)
assertEquals('A'.code, dispatchedKeyDowns.firstOrNull())
assertEquals('A'.toInt(), dispatchedKeyDowns.firstOrNull())
}
@Test
fun charWithShiftCtrl() {
sendKeyWithMeta(KeyEvent.KEYCODE_A, KeyEvent.META_SHIFT_ON or KeyEvent.META_CTRL_ON)
assertEquals('A'.code, dispatchedKeyDowns.firstOrNull())
assertEquals('A'.toInt(), dispatchedKeyDowns.firstOrNull())
}
@Test
fun charWithShiftCtrlAlt() {
sendKeyWithMeta(KeyEvent.KEYCODE_A, KeyEvent.META_SHIFT_ON or KeyEvent.META_CTRL_ON or KeyEvent.META_ALT_ON)
assertEquals('A'.code, dispatchedKeyDowns.firstOrNull())
assertEquals('A'.toInt(), dispatchedKeyDowns.firstOrNull())
}
@Test
fun charWithCapslock() {
sendKeyWithMeta(KeyEvent.KEYCODE_A, KeyEvent.META_CAPS_LOCK_ON)
assertEquals('A'.code, dispatchedKeyDowns.firstOrNull())
assertEquals('A'.toInt(), dispatchedKeyDowns.firstOrNull())
}
/*@Test // Android itself is broken on CapsLock + Shift
fun charWithCapslockShift() {
sendKeyWithMeta(KeyEvent.KEYCODE_A, KeyEvent.META_CAPS_LOCK_ON or KeyEvent.META_SHIFT_ON)
assertEquals('a'.code, dispatchedKeys.firstOrNull())
assertEquals('a'.toInt(), dispatchedKeys.firstOrNull())
}*/
@Test
fun charWithCapslockCtrl() {
sendKeyWithMeta(KeyEvent.KEYCODE_A, KeyEvent.META_CAPS_LOCK_ON or KeyEvent.META_CTRL_ON)
assertEquals('A'.code, dispatchedKeyDowns.firstOrNull())
assertEquals('A'.toInt(), dispatchedKeyDowns.firstOrNull())
}
@Test
fun charWithCapslockAlt() {
sendKeyWithMeta(KeyEvent.KEYCODE_A, KeyEvent.META_CAPS_LOCK_ON or KeyEvent.META_ALT_ON)
assertEquals('A'.code, dispatchedKeyDowns.firstOrNull())
}
@Test
fun charWithSuper() {
sendKeyWithMeta(KeyEvent.KEYCODE_META_LEFT, KeyEvent.META_META_ON)
sendKeyWithMeta(KeyEvent.KEYCODE_A, KeyEvent.META_META_ON)
assertEquals(XKeySym.XK_Super_L, dispatchedKeyDowns[0])
assertEquals('a'.code, dispatchedKeyDowns[1])
assertEquals('A'.toInt(), dispatchedKeyDowns.firstOrNull())
}
@Test
fun numpadWithNumlock() {
sendKeyWithMeta(KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.META_NUM_LOCK_ON)
assertEquals('1'.code, dispatchedKeyDowns.firstOrNull())
assertEquals('1'.toInt(), dispatchedKeyDowns.firstOrNull())
}
@Test
@ -183,23 +159,6 @@ class KeyHandlerTest {
assertTrue(dispatchedKeyDowns.isEmpty())
}
@Test
fun rawKeyLeft() {
val scLeft = 105
val xtLeft = 203
sendKeyWithScancode(KeyEvent.KEYCODE_DPAD_LEFT, scLeft)
assertEquals(xtLeft, dispatchedXTDowns.first())
assertEquals(xtLeft, dispatchedXTUps.first())
}
@Test
fun rawKeySuper() {
val scLeft = 125
val xtLeft = 219
sendKeyWithScancode(KeyEvent.KEYCODE_META_LEFT, scLeft)
assertEquals(xtLeft, dispatchedXTDowns.first())
assertEquals(xtLeft, dispatchedXTUps.first())
}
/**************************************************************************/
private val ACCENT_TILDE = 0x02DC
@ -229,7 +188,7 @@ class KeyHandlerTest {
fun diacriticTest_charAfterAccent() {
sendAccent(ACCENT_TILDE)
sendKey(KeyEvent.KEYCODE_A, 'a')
assertEquals('ã'.code, dispatchedKeyDowns.firstOrNull())
assertEquals('ã'.toInt(), dispatchedKeyDowns.firstOrNull())
}
@Test
@ -237,7 +196,7 @@ class KeyHandlerTest {
sendDown(0, ACCENT_TILDE or KeyCharacterMap.COMBINING_ACCENT)
sendKey(KeyEvent.KEYCODE_A, 'a')
sendUp(0, ACCENT_TILDE or KeyCharacterMap.COMBINING_ACCENT)
assertEquals('ã'.code, dispatchedKeyDowns.firstOrNull())
assertEquals('ã'.toInt(), dispatchedKeyDowns.firstOrNull())
}
@Test
@ -245,30 +204,30 @@ class KeyHandlerTest {
// Here we test key up/down events for accents and characters mixed in
// non-sequential order. This case can happen when you type quickly.
sendDown(KeyEvent.KEYCODE_A, 'a'.code)
sendDown(KeyEvent.KEYCODE_A, 'a'.toInt())
sendDown(0, ACCENT_TILDE or KeyCharacterMap.COMBINING_ACCENT)
sendUp(KeyEvent.KEYCODE_A, 'a'.code)
sendUp(KeyEvent.KEYCODE_A, 'a'.toInt())
sendUp(0, ACCENT_TILDE or KeyCharacterMap.COMBINING_ACCENT)
sendDown(KeyEvent.KEYCODE_A, 'a'.code)
sendUp(KeyEvent.KEYCODE_A, 'a'.code)
sendDown(KeyEvent.KEYCODE_A, 'a'.toInt())
sendUp(KeyEvent.KEYCODE_A, 'a'.toInt())
assertEquals('a'.code, dispatchedKeyDowns[0]) // First should be normal, as accent was pressed later
assertEquals('ã'.code, dispatchedKeyDowns[1]) // Second should be accented
assertEquals('a'.toInt(), dispatchedKeyDowns[0]) // First should be normal, as accent was pressed later
assertEquals('ã'.toInt(), dispatchedKeyDowns[1]) // Second should be accented
}
@Test
fun diacriticTest_interMixedUpDowns2() {
// Another variation of intermixed up/downs
sendDown(KeyEvent.KEYCODE_A, 'a'.code)
sendDown(KeyEvent.KEYCODE_A, 'a'.toInt())
sendDown(0, ACCENT_TILDE or KeyCharacterMap.COMBINING_ACCENT)
sendUp(KeyEvent.KEYCODE_A, 'a'.code)
sendDown(KeyEvent.KEYCODE_A, 'a'.code)
sendUp(KeyEvent.KEYCODE_A, 'a'.code)
sendUp(KeyEvent.KEYCODE_A, 'a'.toInt())
sendDown(KeyEvent.KEYCODE_A, 'a'.toInt())
sendUp(KeyEvent.KEYCODE_A, 'a'.toInt())
sendUp(0, ACCENT_TILDE or KeyCharacterMap.COMBINING_ACCENT)
assertEquals('a'.code, dispatchedKeyDowns[0])
assertEquals('ã'.code, dispatchedKeyDowns[1])
assertEquals('a'.toInt(), dispatchedKeyDowns[0])
assertEquals('ã'.toInt(), dispatchedKeyDowns[1])
}
@Test
@ -276,15 +235,15 @@ class KeyHandlerTest {
sendAccent(ACCENT_TILDE)
sendKey(KeyEvent.KEYCODE_A, 'a') // First one should be accented,
sendKey(KeyEvent.KEYCODE_A, 'a') // next one should be normal
assertEquals('ã'.code, dispatchedKeyDowns[0])
assertEquals('a'.code, dispatchedKeyDowns[1])
assertEquals('ã'.toInt(), dispatchedKeyDowns[0])
assertEquals('a'.toInt(), dispatchedKeyDowns[1])
}
@Test
fun diacriticTest_capitalCharAfterAccent() {
sendAccent(ACCENT_TILDE)
sendKey(KeyEvent.KEYCODE_A, 'A')
assertEquals('Ã'.code, dispatchedKeyDowns.firstOrNull())
assertEquals('Ã'.toInt(), dispatchedKeyDowns.firstOrNull())
}
@Test
@ -292,7 +251,7 @@ class KeyHandlerTest {
sendAccent(ACCENT_CIRCUMFLEX)
sendAccent(ACCENT_TILDE)
sendKey(KeyEvent.KEYCODE_A, 'a')
assertEquals('ẫ'.code + 0x1000000, dispatchedKeyDowns.firstOrNull())
assertEquals('ẫ'.toInt() + 0x1000000, dispatchedKeyDowns.firstOrNull())
}
@Test
@ -303,7 +262,7 @@ class KeyHandlerTest {
sendUp(0, ACCENT_TILDE or KeyCharacterMap.COMBINING_ACCENT)
sendKey(KeyEvent.KEYCODE_A, 'a')
assertEquals('ẫ'.code + 0x1000000, dispatchedKeyDowns.firstOrNull())
assertEquals('ẫ'.toInt() + 0x1000000, dispatchedKeyDowns.firstOrNull())
}
@Test
@ -314,7 +273,7 @@ class KeyHandlerTest {
sendUp(0, ACCENT_CIRCUMFLEX or KeyCharacterMap.COMBINING_ACCENT)
sendKey(KeyEvent.KEYCODE_A, 'a')
assertEquals('ẫ'.code + 0x1000000, dispatchedKeyDowns.firstOrNull())
assertEquals('ẫ'.toInt() + 0x1000000, dispatchedKeyDowns.firstOrNull())
}
@Test
@ -324,7 +283,7 @@ class KeyHandlerTest {
sendKey(KeyEvent.KEYCODE_A, 'a')
sendUp(0, ACCENT_CIRCUMFLEX or KeyCharacterMap.COMBINING_ACCENT)
sendUp(0, ACCENT_TILDE or KeyCharacterMap.COMBINING_ACCENT)
assertEquals('ẫ'.code + 0x1000000, dispatchedKeyDowns.firstOrNull())
assertEquals('ẫ'.toInt() + 0x1000000, dispatchedKeyDowns.firstOrNull())
}
@Test
@ -347,7 +306,7 @@ class KeyHandlerTest {
sendKey(KeyEvent.KEYCODE_B, 'b') // This will stop composition,
sendKey(KeyEvent.KEYCODE_B, 'b') // next char will be passed through
assertEquals(1, dispatchedKeyDowns.size)
assertEquals('b'.code, dispatchedKeyDowns.first())
assertEquals('b'.toInt(), dispatchedKeyDowns.first())
}
@Test

View file

@ -1,161 +0,0 @@
/*
* Copyright (c) 2023 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
/*
* Copyright (c) 2023 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.vnc
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onIdle
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import com.gaurav.avnc.*
import com.gaurav.avnc.model.LoginInfo
import com.gaurav.avnc.model.ServerProfile
import com.gaurav.avnc.model.db.MainDb
import com.gaurav.avnc.viewmodel.VncViewModel
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
import java.io.Closeable
import kotlin.concurrent.thread
private const val SAMPLE_USERNAME = "Chandler"
private const val SAMPLE_PASSWORD = "Bing"
class LoginFragmentTest {
private class Scenario(private val profileTemplate: ServerProfile? = null) : Closeable {
private val dao = MainDb.getInstance(targetContext).serverProfileDao
private val profile = setupProfile()
private var loginInfo = LoginInfo()
private val activityScenario = ActivityScenario.launch<VncActivity>(createVncIntent(targetContext, profile))
private val viewModel = activityScenario.withActivity { viewModel }
private var loginInfoThread: Thread? = null
private fun setupProfile() = runBlocking {
dao.deleteAll()
(profileTemplate?.copy() ?: ServerProfile()).apply { ID = dao.insert(this) }
}
fun triggerLoginInfoRequest(type: LoginInfo.Type) {
loginInfoThread = thread { loginInfo = viewModel.getLoginInfo(type) }
}
fun waitForLoginInfo(): LoginInfo {
loginInfoThread!!.join(5000)
return loginInfo
}
fun triggerLoginSave(): ServerProfile {
viewModel.state.postValue(VncViewModel.State.Connected)
onIdle()
return viewModel.profile
}
override fun close() {
activityScenario.close()
}
}
private fun passwordLogin(type: LoginInfo.Type) = Scenario().use { scenario ->
scenario.triggerLoginInfoRequest(type)
onView(withId(R.id.password)).inDialog().checkWillBeDisplayed().doTypeText(SAMPLE_PASSWORD)
onView(withId(R.id.username)).inDialog().checkIsNotDisplayed()
onView(withText(android.R.string.ok)).inDialog().checkIsDisplayed().doClick()
assertEquals(SAMPLE_PASSWORD, scenario.waitForLoginInfo().password)
}
@Test
fun vncPasswordLogin() = passwordLogin(LoginInfo.Type.VNC_PASSWORD)
@Test
fun sshPasswordLogin() = passwordLogin(LoginInfo.Type.SSH_PASSWORD)
@Test
fun sshKeyPasswordLogin() = passwordLogin(LoginInfo.Type.SSH_KEY_PASSWORD)
@Test
fun vncCredentialLoginWithRememberChecked() = Scenario().use { scenario ->
scenario.triggerLoginInfoRequest(LoginInfo.Type.VNC_CREDENTIAL)
onView(withId(R.id.username)).inDialog().checkWillBeDisplayed().doTypeText(SAMPLE_USERNAME)
onView(withId(R.id.password)).inDialog().checkWillBeDisplayed().doTypeText(SAMPLE_PASSWORD)
onView(withId(R.id.remember)).inDialog().checkIsDisplayed().doClick()
onView(withText(android.R.string.ok)).inDialog().checkIsDisplayed().doClick()
val l = scenario.waitForLoginInfo()
val p = scenario.triggerLoginSave()
assertEquals(SAMPLE_USERNAME, l.username)
assertEquals(SAMPLE_PASSWORD, l.password)
assertEquals(SAMPLE_USERNAME, p.username)
assertEquals(SAMPLE_PASSWORD, p.password)
}
@Test
fun vncCredentialLoginWhenPasswordIsAvailable() = Scenario(ServerProfile(password = SAMPLE_PASSWORD)).use { scenario ->
scenario.triggerLoginInfoRequest(LoginInfo.Type.VNC_CREDENTIAL)
onView(withId(R.id.username)).inDialog().checkWillBeDisplayed().doTypeText(SAMPLE_USERNAME)
onView(withId(R.id.password)).inDialog().checkIsNotDisplayed()
onView(withText(android.R.string.ok)).inDialog().checkIsDisplayed().doClick()
val l = scenario.waitForLoginInfo()
assertEquals(SAMPLE_USERNAME, l.username)
assertEquals(SAMPLE_PASSWORD, l.password)
}
@Test
fun sshPasswordLoginWithRememberChecked() = Scenario().use { scenario ->
scenario.triggerLoginInfoRequest(LoginInfo.Type.SSH_PASSWORD)
onView(withId(R.id.password)).inDialog().checkWillBeDisplayed().doTypeText(SAMPLE_PASSWORD)
onView(withId(R.id.remember)).inDialog().checkIsDisplayed().doClick()
onView(withText(android.R.string.ok)).inDialog().checkIsDisplayed().doClick()
val l = scenario.waitForLoginInfo()
val p = scenario.triggerLoginSave()
assertEquals(SAMPLE_PASSWORD, l.password)
assertEquals(SAMPLE_PASSWORD, p.sshPassword)
}
@Test
fun sshKeyPasswordLoginWithRememberChecked() = Scenario().use { scenario ->
scenario.triggerLoginInfoRequest(LoginInfo.Type.SSH_KEY_PASSWORD)
onView(withId(R.id.password)).inDialog().checkWillBeDisplayed().doTypeText(SAMPLE_PASSWORD)
onView(withId(R.id.remember)).inDialog().checkIsDisplayed().doClick()
onView(withText(android.R.string.ok)).inDialog().checkIsDisplayed().doClick()
val l = scenario.waitForLoginInfo()
val p = scenario.triggerLoginSave()
assertEquals(SAMPLE_PASSWORD, l.password)
assertEquals(SAMPLE_PASSWORD, p.sshPrivateKeyPassword)
}
/**
* If login information is already available in profile,
* [VncViewModel] should provide it without triggering login dialog.
*/
@Test(timeout = 5000)
fun savedLoginTest() {
val profile = ServerProfile(username = "AB", password = "BC", sshPassword = "CD", sshPrivateKeyPassword = "DE")
val viewModel = runOnMainSync { VncViewModel(profile, ApplicationProvider.getApplicationContext()) }
assertEquals("AB", viewModel.getLoginInfo(LoginInfo.Type.VNC_CREDENTIAL).username)
assertEquals("BC", viewModel.getLoginInfo(LoginInfo.Type.VNC_CREDENTIAL).password)
assertEquals("BC", viewModel.getLoginInfo(LoginInfo.Type.VNC_PASSWORD).password)
assertEquals("CD", viewModel.getLoginInfo(LoginInfo.Type.SSH_PASSWORD).password)
assertEquals("DE", viewModel.getLoginInfo(LoginInfo.Type.SSH_KEY_PASSWORD).password)
}
}

View file

@ -8,7 +8,6 @@
package com.gaurav.avnc.ui.vnc
import android.graphics.Point
import android.graphics.PointF
import android.os.SystemClock
import android.view.InputDevice
@ -16,31 +15,20 @@ import android.view.MotionEvent
import android.view.MotionEvent.PointerCoords
import android.view.MotionEvent.PointerProperties
import android.view.ViewConfiguration
import androidx.core.content.edit
import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.SdkSuppress
import com.gaurav.avnc.instrumentation
import com.gaurav.avnc.model.ServerProfile
import com.gaurav.avnc.targetPrefs
import com.gaurav.avnc.viewmodel.VncViewModel
import com.gaurav.avnc.vnc.PointerButton
import io.mockk.mockk
import io.mockk.verify
import io.mockk.verifyOrder
import io.mockk.*
import org.junit.Before
import org.junit.Test
/**
* Tests for [TouchHandler]
*
* Although actual tests are very simple here, event creation & injection is quite complex.
* But given the importance of gestures in AVNC and the complexity of [TouchHandler],
* these tests are very valuable.
*/
@SdkSuppress(minSdkVersion = 28)
class TouchHandlerTest {
/************************* Setup ************************************************************/
private lateinit var touchHandler: TouchHandler
private lateinit var mockDispatcher: Dispatcher
private val testPoint = PointF(10f, 10f)
@ -49,31 +37,79 @@ class TouchHandlerTest {
fun setup() {
instrumentation.runOnMainSync {
mockDispatcher = mockk(relaxed = true)
touchHandler = TouchHandler(VncViewModel(ServerProfile(), ApplicationProvider.getApplicationContext()), mockDispatcher)
touchHandler = TouchHandler(VncViewModel(ApplicationProvider.getApplicationContext()), mockDispatcher)
}
// Internally, mocks seems to be lazily initialized, and the initialization can take some time.
// This is problematic here because gesture detection is very sensitive to timing of events.
// So we eagerly trigger the initialization, to avoid messing with timings in actual tests.
mockDispatcher.onXKey(0, 0, false)
}
private fun setupWithPref(mousePassthrough: Boolean = false, dragEnabled: Boolean = false) {
targetPrefs.edit {
putBoolean("mouse_passthrough", mousePassthrough)
putString("gesture_drag", if (dragEnabled) "remote-scroll" else "none")
instrumentation.runOnMainSync {
val viewModel = spyk(VncViewModel(ApplicationProvider.getApplicationContext()))
// All of these needs to be mocked
every { viewModel.pref.input.mousePassthrough } returns mousePassthrough
every { viewModel.pref.input.gesture.dragEnabled } returns dragEnabled
every { viewModel.pref.input.gesture.swipeSensitivity } returns 1f
touchHandler = TouchHandler(viewModel, mockDispatcher)
}
setup()
}
/************************* Finger Gestures *******************************************************/
/************************ Event Generation ***************************************************/
private fun now() = SystemClock.uptimeMillis()
private fun createEvent(action: Int, p: PointF): MotionEvent {
return MotionEvent.obtain(now(), now(), action, p.x, p.y, 0)
}
private fun createEvent2(action: Int, p1: PointF, p2: PointF): MotionEvent {
val props = arrayOf(PointerProperties().apply { id = 0 }, PointerProperties().apply { id = 1 })
val coord = arrayOf(PointerCoords().apply { x = p1.x; y = p1.y }, PointerCoords().apply { x = p2.x; y = p2.y })
return MotionEvent.obtain(now(), now(), action, 2, props, coord, 0, 0, 1f, 1f, 0, 0, 0, 0)
}
private fun createMouseEvent(action: Int, p: PointF, button: Int? = null): MotionEvent {
val event = spyk(createEvent(action, p))
event.source = InputDevice.SOURCE_MOUSE
event.action = action
event.setLocation(p.x, p.y)
every { event.getToolType(any()) } returns MotionEvent.TOOL_TYPE_MOUSE
button?.let { every { event.actionButton } returns it }
return event
}
private fun createStylusEvent(action: Int, p: PointF): MotionEvent {
val event = createMouseEvent(action, p)
event.source = InputDevice.SOURCE_STYLUS
every { event.getToolType(any()) } returns MotionEvent.TOOL_TYPE_STYLUS
return event
}
private fun sendEvent(event: MotionEvent) = instrumentation.runOnMainSync {
touchHandler.onTouchEvent(event)
}
private fun sendGenericEvent(event: MotionEvent) = instrumentation.runOnMainSync {
touchHandler.onGenericMotionEvent(event)
}
private fun sendHoverEvent(event: MotionEvent) = instrumentation.runOnMainSync {
touchHandler.onHoverEvent(event)
}
/************************* Gesture Tests *******************************************************/
private val timeoutGrace = 100L
private fun sendDown(p: PointF = testPoint) = sendEvent(createEvent(MotionEvent.ACTION_DOWN, p))
private fun sendUp(p: PointF = testPoint) = sendEvent(createEvent(MotionEvent.ACTION_UP, p))
private fun sendMove(p: PointF) = sendEvent(createEvent(MotionEvent.ACTION_MOVE, p))
@Test
fun singleTap() {
sendDown()
sendUp()
Thread.sleep(Delay.SINGLE_TAP_CONFIRM)
Thread.sleep(ViewConfiguration.getDoubleTapTimeout() + timeoutGrace)
verify { mockDispatcher.onTap1(testPoint) }
}
@ -81,7 +117,7 @@ class TouchHandlerTest {
fun doubleTap() {
sendDown()
sendUp()
Thread.sleep(Delay.BETWEEN_DOUBLE_TAPS)
Thread.sleep(50) //Required due to the minimum time limit in GestureDetector
sendDown()
sendUp()
verify { mockDispatcher.onDoubleTap(testPoint) }
@ -90,8 +126,8 @@ class TouchHandlerTest {
@Test
fun twoFingerTap() {
sendDown()
sendEvent(Factory.obtainPointerDownEvent(downEvent, testPoint, PointF(50f, 50f)))
sendEvent(Factory.obtainPointerUpEvent(downEvent, testPoint, PointF(50f, 50f)))
sendEvent(createEvent2(MotionEvent.ACTION_POINTER_DOWN, testPoint, PointF(50f, 50f)))
sendEvent(createEvent2(MotionEvent.ACTION_POINTER_UP, testPoint, PointF(50f, 50f)))
sendUp()
verify { mockDispatcher.onTap2(testPoint) }
}
@ -100,7 +136,7 @@ class TouchHandlerTest {
fun longPress() {
setupWithPref(dragEnabled = false)
sendDown()
Thread.sleep(Delay.LONG_PRESS_CONFIRM)
Thread.sleep(ViewConfiguration.getLongPressTimeout() + timeoutGrace)
verify { mockDispatcher.onLongPress(testPoint) }
}
@ -121,56 +157,38 @@ class TouchHandlerTest {
val b2 = PointF(400f, 400f)
sendDown(a1)
sendEvent(Factory.obtainPointerDownEvent(downEvent, a1, b1))
sendEvent(Factory.obtainMoveEvent(downEvent, a2, b2))
sendEvent(createEvent2(MotionEvent.ACTION_POINTER_DOWN, a1, b1))
sendEvent(createEvent2(MotionEvent.ACTION_MOVE, a2, b2))
verify { mockDispatcher.onSwipe2(a1, a2, 100f, 100f) }
}
@Test
fun scale() {
val a1 = PointF(300f, 300f)
val b1 = PointF(400f, 400f)
val a1 = PointF(100f, 100f)
val a2 = PointF(10f, 10f)
val b1 = PointF(200f, 200f)
val b2 = PointF(300f, 300f)
sendDown(a1)
sendEvent(Factory.obtainPointerDownEvent(downEvent, a1, b1))
sendEvent(Factory.obtainMoveEvent(downEvent, PointF(200f, 200f), PointF(500f, 500f)))
sendEvent(Factory.obtainMoveEvent(downEvent, PointF(100f, 100f), PointF(600f, 600f)))
sendEvent(Factory.obtainMoveEvent(downEvent, PointF(10f, 10f), PointF(690f, 690f)))
sendEvent(createEvent2(MotionEvent.ACTION_POINTER_DOWN, a1, b1))
sendEvent(createEvent2(MotionEvent.ACTION_MOVE, a2, b2))
verify { mockDispatcher.onScale(any(), any(), any()) }
}
@Test
fun longPressSwipe() {
fun drag() {
setupWithPref(dragEnabled = true)
val a1 = PointF(100f, 100f)
val a2 = PointF(150f, 150f)
sendDown(a1)
Thread.sleep(Delay.LONG_PRESS_CONFIRM)
Thread.sleep(ViewConfiguration.getLongPressTimeout() + timeoutGrace)
sendMove(a2)
sendUp(a2)
verifyOrder {
mockDispatcher.onLongPressSwipe(a1, a2, 50f, 50f)
mockDispatcher.onGestureStop(a2)
}
}
@Test
fun doubleTapSwipe() {
val a1 = PointF(100f, 100f)
val a2 = PointF(150f, 150f)
sendDown(a1)
sendUp(a1)
Thread.sleep(Delay.BETWEEN_DOUBLE_TAPS)
sendDown(a1)
sendMove(a2)
sendUp(a2)
verifyOrder {
mockDispatcher.onDoubleTapSwipe(a1, a2, 50f, 50f)
mockDispatcher.onDrag(a1, a2, 50f, 50f)
mockDispatcher.onGestureStop(a2)
}
}
@ -188,7 +206,7 @@ class TouchHandlerTest {
@Test
fun gestureStartStopOnCancel() {
sendDown()
sendEvent(Factory.obtainCancelEvent(downEvent, testPoint))
sendEvent(createEvent(MotionEvent.ACTION_CANCEL, testPoint))
verifyOrder {
mockDispatcher.onGestureStart()
mockDispatcher.onGestureStop(testPoint)
@ -200,8 +218,8 @@ class TouchHandlerTest {
@Test
fun mouseLeftClick() {
setupWithPref(mousePassthrough = true)
sendMouseButtonPress(MotionEvent.BUTTON_PRIMARY)
sendMouseButtonRelease()
sendGenericEvent(createMouseEvent(MotionEvent.ACTION_BUTTON_PRESS, testPoint, MotionEvent.BUTTON_PRIMARY))
sendGenericEvent(createMouseEvent(MotionEvent.ACTION_BUTTON_RELEASE, testPoint, MotionEvent.BUTTON_PRIMARY))
verifyOrder {
mockDispatcher.onMouseButtonDown(PointerButton.Left, testPoint)
mockDispatcher.onMouseButtonUp(PointerButton.Left, testPoint)
@ -211,8 +229,8 @@ class TouchHandlerTest {
@Test
fun mouseRightClick() {
setupWithPref(mousePassthrough = true)
sendMouseButtonPress(MotionEvent.BUTTON_SECONDARY)
sendMouseButtonRelease()
sendGenericEvent(createMouseEvent(MotionEvent.ACTION_BUTTON_PRESS, testPoint, MotionEvent.BUTTON_SECONDARY))
sendGenericEvent(createMouseEvent(MotionEvent.ACTION_BUTTON_RELEASE, testPoint, MotionEvent.BUTTON_SECONDARY))
verifyOrder {
mockDispatcher.onMouseButtonDown(PointerButton.Right, testPoint)
mockDispatcher.onMouseButtonUp(PointerButton.Right, testPoint)
@ -222,23 +240,21 @@ class TouchHandlerTest {
@Test
fun mouseMove() {
setupWithPref(mousePassthrough = true)
sendMouseButtonPress(MotionEvent.BUTTON_PRIMARY)
sendMouseButtonMove(testPoint)
sendMouseButtonRelease()
sendEvent(createMouseEvent(MotionEvent.ACTION_MOVE, testPoint))
verify { mockDispatcher.onMouseMove(testPoint) }
}
@Test
fun mouseScroll() {
setupWithPref(mousePassthrough = true)
sendGenericEvent(Factory.obtainScrollEvent(testPoint))
sendGenericEvent(createMouseEvent(MotionEvent.ACTION_SCROLL, testPoint))
verify { mockDispatcher.onMouseScroll(testPoint, any(), any()) }
}
@Test
fun mouseHover1() {
setupWithPref(mousePassthrough = true)
sendHoverEvent(Factory.obtainHoverEvent(testPoint, InputDevice.SOURCE_MOUSE))
sendHoverEvent(createMouseEvent(MotionEvent.ACTION_HOVER_MOVE, testPoint))
verify { mockDispatcher.onMouseMove(testPoint) }
}
@ -246,18 +262,22 @@ class TouchHandlerTest {
@Test
fun mouseHover2() {
setupWithPref(mousePassthrough = true)
sendGenericEvent(Factory.obtainHoverEvent(testPoint, InputDevice.SOURCE_MOUSE))
sendGenericEvent(createMouseEvent(MotionEvent.ACTION_HOVER_MOVE, testPoint))
verify { mockDispatcher.onMouseMove(testPoint) }
}
/************************* Stylus Tests ******************************************************/
private fun sendStylusDown(p: PointF = testPoint) = sendEvent(createStylusEvent(MotionEvent.ACTION_DOWN, p))
private fun sendStylusUp(p: PointF = testPoint) = sendEvent(createStylusEvent(MotionEvent.ACTION_UP, p))
private fun sendStylusMove(p: PointF) = sendEvent(createStylusEvent(MotionEvent.ACTION_MOVE, p))
@Test
fun stylusTap() {
sendStylusDown()
sendStylusUp()
Thread.sleep(Delay.SINGLE_TAP_CONFIRM)
Thread.sleep(ViewConfiguration.getDoubleTapTimeout() + timeoutGrace)
verify { mockDispatcher.onStylusTap(testPoint) }
}
@ -265,7 +285,7 @@ class TouchHandlerTest {
fun stylusDoubleTap() {
sendStylusDown()
sendStylusUp()
Thread.sleep(Delay.BETWEEN_DOUBLE_TAPS)
Thread.sleep(50) //Required due to the minimum time limit in GestureDetector
sendStylusDown()
sendStylusUp()
verify { mockDispatcher.onStylusDoubleTap(testPoint) }
@ -274,7 +294,7 @@ class TouchHandlerTest {
@Test
fun stylusLongPress() {
sendStylusDown()
Thread.sleep(Delay.LONG_PRESS_CONFIRM)
Thread.sleep(ViewConfiguration.getLongPressTimeout() + timeoutGrace)
verify { mockDispatcher.onStylusLongPress(testPoint) }
}
@ -289,199 +309,13 @@ class TouchHandlerTest {
@Test
fun stylusHover1() {
sendHoverEvent(Factory.obtainHoverEvent(testPoint, InputDevice.SOURCE_STYLUS))
sendHoverEvent(createStylusEvent(MotionEvent.ACTION_HOVER_MOVE, testPoint))
verify { mockDispatcher.onMouseMove(testPoint) }
}
@Test
fun stylusHover2() {
sendGenericEvent(Factory.obtainHoverEvent(testPoint, InputDevice.SOURCE_STYLUS))
sendGenericEvent(createStylusEvent(MotionEvent.ACTION_HOVER_MOVE, testPoint))
verify { mockDispatcher.onMouseMove(testPoint) }
}
/********************* Swipe vs Scale detection *************************/
private val EXPECT_NONE = 0
private val EXPECT_SCALE = 1
private val EXPECT_SWIPE = 2
/**
* [p1Step] Step-size for pointer 1
* [p2Step] Step-size for pointer 2
* [expect] Expected gesture to be detected
*/
private fun runSwipeVsScaleTest(p1Step: Point, p2Step: Point, expect: Int) {
// SwipeVsScale is ony used when 2-finger swipe is set to 'remote-scroll'
targetPrefs.edit { putString("gesture_swipe2", "remote-scroll") }
setup()
val p1 = PointF(500f, 500f)
val p2 = PointF(1000f, 1000f)
sendDown(p1)
sendEvent(Factory.obtainPointerDownEvent(downEvent, p1, p2))
repeat(30) {
p1.offset(p1Step.x.toFloat(), p1Step.y.toFloat())
p2.offset(p2Step.x.toFloat(), p2Step.y.toFloat())
sendEvent(Factory.obtainMoveEvent(downEvent, p1, p2))
}
if (expect == EXPECT_SWIPE) verify { mockDispatcher.onSwipe2(any(), any(), any(), any()) }
else verify(exactly = 0) { mockDispatcher.onSwipe2(any(), any(), any(), any()) }
if (expect == EXPECT_SCALE) verify { mockDispatcher.onScale(any(), any(), any()) }
else verify(exactly = 0) { mockDispatcher.onScale(any(), any(), any()) }
}
@Test
fun swipeVsScale() {
runSwipeVsScaleTest(Point(-10, -10), Point(10, 10), EXPECT_SCALE) // Pinch-out
runSwipeVsScaleTest(Point(10, 10), Point(-10, -10), EXPECT_SCALE) // Pinch-in
runSwipeVsScaleTest(Point(0, -10), Point(0, 10), EXPECT_SCALE) // 1st up, 2nd down
runSwipeVsScaleTest(Point(0, 10), Point(0, -10), EXPECT_SCALE) // 1st down, 2nd up
runSwipeVsScaleTest(Point(-6, 10), Point(6, 10), EXPECT_SCALE) // >45 degree
runSwipeVsScaleTest(Point(0, 10), Point(0, 10), EXPECT_SWIPE) // Parallel down
runSwipeVsScaleTest(Point(0, -10), Point(0, -10), EXPECT_SWIPE) // Parallel up
runSwipeVsScaleTest(Point(10, 0), Point(10, 0), EXPECT_SWIPE) // Parallel right
runSwipeVsScaleTest(Point(-10, 0), Point(-10, 0), EXPECT_SWIPE) // Parallel left
runSwipeVsScaleTest(Point(-2, 10), Point(2, 10), EXPECT_SWIPE) // <30 degree
runSwipeVsScaleTest(Point(-4, 10), Point(4, 10), EXPECT_NONE) // 30-45 degree
runSwipeVsScaleTest(Point(-3, 10), Point(3, 10), EXPECT_NONE) // 30-45 degree
}
/************************ Event Generation ***************************************************/
object Factory {
private val setActionButtonMethod = MotionEvent::class.java.getDeclaredMethod("setActionButton", Int::class.java)
private fun now() = SystemClock.uptimeMillis()
private fun createPointerProperties(count: Int, source: Int): Array<PointerProperties> {
val pointerToolType = when (source) {
InputDevice.SOURCE_TOUCHSCREEN -> MotionEvent.TOOL_TYPE_FINGER
InputDevice.SOURCE_MOUSE -> MotionEvent.TOOL_TYPE_MOUSE
InputDevice.SOURCE_STYLUS -> MotionEvent.TOOL_TYPE_STYLUS
else -> MotionEvent.TOOL_TYPE_UNKNOWN
}
return Array(count) {
PointerProperties().apply {
id = it + 5 // Use larger ids to detect accidental misuse of pointer-id as pointer-index
toolType = pointerToolType
}
}
}
private fun createPointerCoords(coords: List<PointF>): Array<PointerCoords> {
return Array(coords.size) {
val coord = coords[it]
PointerCoords().apply {
clear()
x = coord.x
y = coord.y
}
}
}
private fun obtainMotionEvent(downTime: Long, eventTime: Long, action: Int, actionButton: Int, coords: List<PointF>, source: Int): MotionEvent {
val pointerProperties = createPointerProperties(coords.size, source)
val pointerCoords = createPointerCoords(coords)
val buttonState = actionButton
val event = MotionEvent.obtain(downTime, eventTime, action, coords.size, pointerProperties, pointerCoords, 0, buttonState, 1f, 1f, 0, 0, source, 0)
setActionButtonMethod.invoke(event, actionButton)
return event
}
fun obtainDownEvent(coord: PointF, source: Int): MotionEvent {
val time = now()
return obtainMotionEvent(time, time, MotionEvent.ACTION_DOWN, 0, listOf(coord), source)
}
fun obtainPointerDownEvent(downEvent: MotionEvent, vararg coords: PointF): MotionEvent {
check(coords.size >= 2)
return obtainMotionEvent(downEvent.downTime, now(), MotionEvent.ACTION_POINTER_DOWN, 0, coords.toList(), downEvent.source)
}
fun obtainMoveEvent(downEvent: MotionEvent, vararg coords: PointF): MotionEvent {
return obtainMotionEvent(downEvent.downTime, now(), MotionEvent.ACTION_MOVE, 0, coords.toList(), downEvent.source)
}
fun obtainPointerUpEvent(downEvent: MotionEvent, vararg coords: PointF): MotionEvent {
check(coords.size >= 2)
return obtainMotionEvent(downEvent.downTime, now(), MotionEvent.ACTION_POINTER_UP, 0, coords.toList(), downEvent.source)
}
fun obtainUpEvent(downEvent: MotionEvent, coord: PointF): MotionEvent {
return obtainMotionEvent(downEvent.downTime, now(), MotionEvent.ACTION_UP, 0, listOf(coord), downEvent.source)
}
fun obtainButtonPressEvent(button: Int, coord: PointF, source: Int): MotionEvent {
val time = now()
return obtainMotionEvent(time, time, MotionEvent.ACTION_BUTTON_PRESS, button, listOf(coord), source)
}
fun obtainButtonReleaseEvent(pressEvent: MotionEvent, coord: PointF): MotionEvent {
return obtainMotionEvent(pressEvent.downTime, now(), MotionEvent.ACTION_BUTTON_RELEASE, pressEvent.actionButton, listOf(coord), pressEvent.source)
}
fun obtainScrollEvent(coord: PointF): MotionEvent {
val time = now()
return obtainMotionEvent(time, time, MotionEvent.ACTION_SCROLL, 0, listOf(coord), InputDevice.SOURCE_MOUSE)
}
fun obtainHoverEvent(coord: PointF, source: Int): MotionEvent {
val time = now()
return obtainMotionEvent(time, time, MotionEvent.ACTION_HOVER_MOVE, 0, listOf(coord), source)
}
fun obtainCancelEvent(downEvent: MotionEvent, coord: PointF): MotionEvent {
return obtainMotionEvent(downEvent.downTime, now(), MotionEvent.ACTION_CANCEL, 0, listOf(coord), downEvent.source)
}
}
/************************* Event Injection *******************************************************/
// Delays required by some tests
object Delay {
val BETWEEN_DOUBLE_TAPS = (ViewConfiguration.getDoubleTapTimeout() + doubleTapMinTime()) / 2L
val SINGLE_TAP_CONFIRM = ViewConfiguration.getDoubleTapTimeout() + 200L
val LONG_PRESS_CONFIRM = ViewConfiguration.getLongPressTimeout() + 200L
private fun doubleTapMinTime(): Int {
return ViewConfiguration::class.java.getDeclaredMethod("getDoubleTapMinTime").invoke(null) as Int
}
}
private lateinit var downEvent: MotionEvent
private fun sendEvent(event: MotionEvent) = instrumentation.runOnMainSync { touchHandler.onTouchEvent(event) }
private fun sendGenericEvent(event: MotionEvent) = instrumentation.runOnMainSync { touchHandler.onGenericMotionEvent(event) }
private fun sendHoverEvent(event: MotionEvent) = instrumentation.runOnMainSync { touchHandler.onHoverEvent(event) }
private fun sendDown(p: PointF = testPoint) {
downEvent = Factory.obtainDownEvent(p, InputDevice.SOURCE_TOUCHSCREEN)
sendEvent(downEvent)
}
private fun sendUp(p: PointF = testPoint) = sendEvent(Factory.obtainUpEvent(downEvent, p))
private fun sendMove(p: PointF) = sendEvent(Factory.obtainMoveEvent(downEvent, p))
private fun sendMouseButtonPress(button: Int) {
downEvent = Factory.obtainButtonPressEvent(button, testPoint, InputDevice.SOURCE_MOUSE)
sendGenericEvent(downEvent)
}
private fun sendMouseButtonRelease() = sendGenericEvent(Factory.obtainButtonReleaseEvent(downEvent, testPoint))
private fun sendMouseButtonMove(point: PointF) = sendEvent(Factory.obtainMoveEvent(downEvent, point))
private fun sendStylusDown(p: PointF = testPoint) {
downEvent = Factory.obtainDownEvent(p, InputDevice.SOURCE_STYLUS)
sendEvent(downEvent)
}
private fun sendStylusUp(p: PointF = testPoint) = sendEvent(Factory.obtainUpEvent(downEvent, p))
private fun sendStylusMove(p: PointF) = sendEvent(Factory.obtainMoveEvent(downEvent, p))
}

View file

@ -10,12 +10,12 @@ package com.gaurav.avnc.ui.vnc
import android.content.Context
import android.view.inputmethod.InputMethodManager
import androidx.core.content.edit
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onIdle
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.contrib.DrawerActions
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.gaurav.avnc.*
import com.gaurav.avnc.model.ServerProfile
@ -78,21 +78,6 @@ class VncActivityTest {
Assert.assertEquals(arrayListOf(XKeySym.XK_Tab), testServer.receivedKeySyms)
}
@Test
fun autoOpenVirtualKeys() {
testWrapper {
targetPrefs.edit { putBoolean("vk_open_with_keyboard", true) }
onView(withId(R.id.drawer_layout)).perform(DrawerActions.open())
onView(withId(R.id.keyboard_btn)).doClick()
onIdle()
// Should be visible
onView(withText("Ctrl")).checkIsDisplayed()
onView(withText("Alt")).checkIsDisplayed()
onView(withText("Tab")).checkIsDisplayed()
}
}
@Test
fun textInput() {
val text = "abcxyzABCXYZ1234567890{}[]()`~@#$%^&*_+-=/*"
@ -101,46 +86,9 @@ class VncActivityTest {
onView(withId(R.id.frame_view)).doTypeText(text)
}
val sentByClient = text.toCharArray().map { it.code }.toList()
val sentByClient = text.toCharArray().map { it.toInt() }.toList()
val receivedOnServer = testServer.receivedKeySyms.filter { it != XKeySym.XK_Shift_L }.toList()
Assert.assertEquals(sentByClient, receivedOnServer)
}
@Test
fun autoReconnectEnabled() {
targetPrefs.edit { putBoolean("auto_reconnect", true) }
try {
val intent = createVncIntent(targetContext, ServerProfile(host = "CentralPerk.test"))
ActivityScenario.launch<VncActivity>(intent).use {
onView(withId(R.id.auto_reconnect_progress)).checkWillBeDisplayed()
Thread.sleep(1500)
onView(withId(R.id.auto_reconnect_progress)).check(ProgressAssertion { it > 0 })
}
} finally {
targetPrefs.edit { putBoolean("auto_reconnect", false) }
}
}
@Test
fun autoReconnectDisabled() {
targetPrefs.edit { putBoolean("auto_reconnect", false) }
val intent = createVncIntent(targetContext, ServerProfile(host = "CentralPerk.test"))
ActivityScenario.launch<VncActivity>(intent).use {
onView(withId(R.id.auto_reconnect_progress)).checkIsNotDisplayed()
Thread.sleep(1500)
onView(withId(R.id.auto_reconnect_progress)).check(ProgressAssertion { it == 0 })
}
}
@Test
fun clientToServerClipboard() {
val sample = "Pivot! Pivot! Pivot! Pivot!!!"
setClipboardText(sample)
testWrapper {
Thread.sleep(2000) // Wait for clipboard sync
}
Assert.assertEquals(sample, testServer.receivedCutText)
}
}

View file

@ -1,34 +0,0 @@
/*
* 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 androidx.core.content.edit
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.gaurav.avnc.targetContext
import com.gaurav.avnc.targetPrefs
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AppPreferenceTest {
// If user had turned off Direct Touch, new Gesture Style pref
// should be set to 'touchpad'
@Test
fun gestureStyleMigrationTest() {
targetPrefs.edit {
remove("gesture_style")
putBoolean("gesture_direct_touch", false)
}
val appPrefs = AppPreferences(targetContext)
assertEquals(appPrefs.input.gesture.style, "touchpad")
}
}

View file

@ -84,10 +84,4 @@ class VncUriTest {
val uri = VncUri("[fe80:2a10:b62b]")
assertEquals("", uri.host)
}
@Test
fun opaqueUri() {
// Correct format is 'vnc://host'
assertEquals("", VncUri("vnc:host").host)
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View file

@ -1,15 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<?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.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.gaurav.avnc">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.REBOOT" />
<application
android:name=".App"
@ -17,52 +18,39 @@
android:fullBackupContent="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:name=".ui.ldc.KioskPingActivity"
android:exported="false" />
<activity
android:name=".ui.ldc.KioskDiagnosticActivity"
android:exported="false" />
<activity
android:name=".ui.ldc.KioskParameterActivity"
android:exported="false" />
<activity
android:name=".ui.ldc.KioskParameterPasswordActivity"
android:exported="false" />
<activity
android:name=".ui.ldc.KioskActivity"
android:exported="true">
<meta-data
android:name="android.app.lib_name"
android:value="" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.home.SplashActivity"
android:theme="@style/SplashTheme" />
<activity-alias
android:name=".StartupActivity"
android:exported="true"
android:targetActivity=".ui.home.SplashActivity">
<!-- <intent-filter> -->
<!-- <action android:name="android.intent.action.MAIN" /> -->
<!-- <category android:name="android.intent.category.LAUNCHER" /> -->
<!-- </intent-filter> -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".UriReceiverActivity"
android:exported="true"
android:targetActivity=".ui.vnc.UriReceiverActivity">
<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=".ui.home.SplashActivity"
android:theme="@style/SplashTheme" />
<activity android:name=".ui.home.HomeActivity" />
<activity
@ -77,25 +65,10 @@
android:supportsPictureInPicture="true"
tools:ignore="UnusedAttribute" />
<activity
android:name=".ui.vnc.IntentReceiverActivity"
android:theme="@style/SplashTheme" />
<activity-alias
android:name=".IntentReceiverActivity"
android:exported="true"
android:targetActivity=".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=".ui.vnc.UriReceiverActivity" />
<activity android:name=".ui.prefs.PrefsActivity" />
<activity android:name=".ui.about.AboutActivity" />
</application>

View file

@ -1,14 +0,0 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Configuration",
"description": "Kiosk app configuration",
"type": "object",
"additionalProperties": false,
"properties": {
"password": {
"type": "string",
"description": "Kiosk config password"
}
},
"required": ["password"]
}

View file

@ -126,23 +126,15 @@ static void onBell(rfbClient *client) {
env->CallVoidMethod(obj, mid);
}
static void onGotXCutText(rfbClient *client, const char *text, int len, bool is_utf8) {
static void onGotXCutText(rfbClient *client, const char *text, int len) {
auto obj = getManagedClient(client);
auto env = context.getEnv();
auto cls = context.managedCls;
jmethodID mid = env->GetMethodID(cls, "cbGotXCutText", "([BZ)V");
jmethodID mid = env->GetMethodID(cls, "cbGotXCutText", "([B)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);
env->CallVoidMethod(obj, mid, bytes);
}
static rfbBool onHandleCursorPos(rfbClient *client, int x, int y) {
@ -239,8 +231,7 @@ static void setCallbacks(rfbClient *client) {
client->GetPassword = onGetPassword;
client->GetCredential = onGetCredential;
client->Bell = onBell;
client->GotXCutText = onGotXCutTextLatin1;
client->GotXCutTextUTF8 = onGotXCutTextUTF8;
client->GotXCutText = onGotXCutText;
client->HandleCursorPos = onHandleCursorPos;
client->FinishedFrameBufferUpdate = onFinishedFrameBufferUpdate;
client->MallocFrameBuffer = onMallocFrameBuffer;
@ -275,8 +266,7 @@ Java_com_gaurav_avnc_vnc_VncClient_nativeClientCreate(JNIEnv *env, jobject thiz)
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) {
jint securityType, jboolean use_local_cursor) {
auto client = (rfbClient *) client_ptr;
// 0 means all auth types
@ -289,10 +279,6 @@ Java_com_gaurav_avnc_vnc_VncClient_nativeConfigure(JNIEnv *env, jobject thiz, jl
client->appData.useRemoteCursor = TRUE;
getClientExtension(client)->cursor = newCursor();
}
client->appData.qualityLevel = image_quality;
if (use_raw_encoding)
client->appData.encodingsString = "raw";
}
extern "C"
@ -320,12 +306,6 @@ Java_com_gaurav_avnc_vnc_VncClient_nativeInit(JNIEnv *env, jobject thiz, jlong c
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
@ -373,14 +353,8 @@ Java_com_gaurav_avnc_vnc_VncClient_nativeGetLastErrorStr(JNIEnv *env, jobject th
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);
jlong key, jboolean is_down) {
return (jboolean) SendKeyEvent((rfbClient *) client_ptr, (uint32_t) key, is_down ? TRUE : FALSE);
}
extern "C"
@ -392,42 +366,22 @@ Java_com_gaurav_avnc_vnc_VncClient_nativeSendPointerEvent(JNIEnv *env, jobject t
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;
Java_com_gaurav_avnc_vnc_VncClient_nativeSendCutText(JNIEnv *env, jobject thiz, jlong client_ptr, jbyteArray bytes) {
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);
rfbBool result = SendClientCutText((rfbClient *) client_ptr, textChars, textLen);
env->ReleaseByteArrayElements(bytes, textBuffer, JNI_ABORT);
return (jboolean) result;
}
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);
return (jboolean) SendFramebufferUpdateRequest(client, 0, 0, client->width, client->height, FALSE);
}
extern "C"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

View file

@ -9,13 +9,11 @@
package com.gaurav.avnc
import android.app.Application
import androidx.annotation.Keep
import androidx.appcompat.app.AppCompatDelegate
import com.gaurav.avnc.util.AppPreferences
class App : Application() {
@Keep
lateinit var prefs: AppPreferences
override fun onCreate() {
@ -26,13 +24,11 @@ class App : Application() {
}
private fun updateNightMode(theme: String) {
var nightMode = when (theme) {
val nightMode = when (theme) {
"light" -> AppCompatDelegate.MODE_NIGHT_NO
"dark" -> AppCompatDelegate.MODE_NIGHT_YES
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
// * Force Light mode
nightMode = AppCompatDelegate.MODE_NIGHT_NO;
AppCompatDelegate.setDefaultNightMode(nightMode)
}
}

View file

@ -95,7 +95,7 @@ import com.gaurav.avnc.vnc.VncClient
* - Connection to VNC server is managed by [VncViewModel], using [VncClient].
* - [VncClient] is a wrapper around native `rfbClient` from LibVNCClient.
*
* - [LoginFragment] is used to ask username & password from user.
* - [CredentialFragment] is used to ask username & password from user.
* - [SshTunnel] is used to create a SSH tunnel, which can be used for connection.
* - [HostKeyFragment] is used to verify unknown SSH hosts.
* -

View file

@ -9,19 +9,11 @@
package com.gaurav.avnc.model
/**
* Generic wrapper for login information.
* This can be used to hold different [Type]s of credentials.
* Simple model used by login auto-completion.
*/
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
}
}
val name: String, //Profile name
val host: String,
val username: String,
val password: String,
)

View file

@ -9,7 +9,6 @@
package com.gaurav.avnc.model
import android.os.Parcelable
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.parcelize.Parcelize
@ -77,27 +76,7 @@ data class ServerProfile(
* 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,
var imageQuality: Int = 0,
/**
* Specifies whether 'View Only' mode should be used.
@ -108,43 +87,15 @@ data class ServerProfile(
/**
* 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]
* See [com.gaurav.avnc.viewmodel.VncViewModel.configureClient]
*/
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.
* Compatibility mode for key events.
* If enabled, we will try to emit legacy X KeySym events.
*/
@ColumnInfo(defaultValue = "")
var serverTypeHint: String = "",
/**
* Compatibility flags.
* These flags toggle various workarounds.
*/
var compatFlags: Int = CF_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",
/**
* Shortcut rank.
* Can be used to reorder or hide app shortcuts.
*/
@ColumnInfo(defaultValue = "0")
var shortcutRank: Int = 0,
var keyCompatMode: Boolean = true,
/**
* Whether UltraVNC Repeater is used for connections.
@ -158,12 +109,6 @@ data class ServerProfile(
*/
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
*/
@ -185,18 +130,5 @@ data class ServerProfile(
// SSH auth types
const val SSH_AUTH_KEY = 1
const val SSH_AUTH_PASSWORD = 2
// Compatibility flags
const val CF_LEGACY_KEYSYM = 0x01 // Try to emit legacy X KeySym events.
const val CF_BUTTON_UP_DELAY = 0x02 // Insert artificial delay before UP event of a button.
}
// Accessors for compat flags
private fun getCompatFlag(flag: Int) = (compatFlags and flag) != 0
private fun setCompatFlag(flag: Int, value: Boolean) {
compatFlags = if (value) compatFlags or flag else compatFlags and flag.inv()
}
var cfLegacyKeySym get() = getCompatFlag(CF_LEGACY_KEYSYM); set(v) = setCompatFlag(CF_LEGACY_KEYSYM, v)
var cfButtonUpDelay get() = getCompatFlag(CF_BUTTON_UP_DELAY); set(v) = setCompatFlag(CF_BUTTON_UP_DELAY, v)
}

View file

@ -9,20 +9,19 @@
package com.gaurav.avnc.model.db
import android.content.Context
import androidx.room.*
import androidx.room.migration.AutoMigrationSpec
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.gaurav.avnc.model.ServerProfile
@Database(entities = [ServerProfile::class], version = 4, 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.3.0
])
@Database(entities = [ServerProfile::class], version = 1, exportSchema = true)
abstract class MainDb : RoomDatabase() {
abstract val serverProfileDao: ServerProfileDao
companion object {
/**
* Database singleton.
*/
private var instance: MainDb? = null
/**
@ -37,16 +36,4 @@ abstract class MainDb : RoomDatabase() {
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
}

View file

@ -10,6 +10,7 @@ package com.gaurav.avnc.model.db
import androidx.lifecycle.LiveData
import androidx.room.*
import com.gaurav.avnc.model.LoginInfo
import com.gaurav.avnc.model.ServerProfile
@Dao
@ -23,26 +24,23 @@ interface ServerProfileDao {
//Synchronous version
@Query("SELECT * FROM profiles")
suspend fun getList(): List<ServerProfile>
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 name, host, username, password FROM profiles")
fun getCredentials(): LiveData<List<LoginInfo>>
@Insert
suspend fun insert(profile: ServerProfile): Long
fun insert(profile: ServerProfile): Long
@Insert
suspend fun insert(profiles: List<ServerProfile>)
fun insert(profiles: List<ServerProfile>)
@Update
suspend fun update(profile: ServerProfile)
fun update(profile: ServerProfile)
@Delete
suspend fun delete(profile: ServerProfile)
fun delete(profile: ServerProfile)
@Query("DELETE FROM profiles")
suspend fun deleteAll()
fun deleteAll()
}

View file

@ -37,7 +37,7 @@ class AboutActivity : AppCompatActivity() {
}
override fun onSupportNavigateUp(): Boolean {
onBackPressedDispatcher.onBackPressed()
onBackPressed()
return true
}
}

View file

@ -12,29 +12,19 @@ import android.app.ActivityOptions
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.Window
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.lifecycleScope
import com.gaurav.avnc.R
import com.gaurav.avnc.databinding.ActivityHomeBinding
import com.gaurav.avnc.model.ServerProfile
import com.gaurav.avnc.ui.about.AboutActivity
import com.gaurav.avnc.ui.prefs.PrefsActivity
import com.gaurav.avnc.ui.vnc.IntentReceiverActivity
import com.gaurav.avnc.ui.vnc.startVncActivity
import com.gaurav.avnc.util.Debugging
import com.gaurav.avnc.util.MsgDialog
import com.gaurav.avnc.viewmodel.HomeViewModel
import com.gaurav.avnc.vnc.VncClient
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Primary activity of the app.
@ -64,12 +54,9 @@ class HomeActivity : AppCompatActivity() {
//Observers
viewModel.editProfileEvent.observe(this) { showProfileEditor() }
viewModel.profileInsertedEvent.observe(this) { onProfileInserted(it) }
viewModel.profileDeletedEvent.observe(this) { onProfileDeleted(it) }
viewModel.newConnectionEvent.observe(this) { startNewConnection(it) }
viewModel.profileDeletedEvent.observe(this) { showProfileDeletedMsg(it) }
viewModel.newConnectionEvent.observe(this) { startVncActivity(this, it) }
viewModel.discovery.servers.observe(this) { updateDiscoveryBadge(it) }
viewModel.serverProfiles.observe(this) { updateShortcuts(it) }
showWelcomeMsg()
}
override fun onStart() {
@ -97,11 +84,6 @@ class HomeActivity : AppCompatActivity() {
return true
}
private fun startNewConnection(profile: ServerProfile) {
if (checkNativeLib())
startVncActivity(this, profile)
}
/**
* Launches Settings activity
*/
@ -145,9 +127,9 @@ class HomeActivity : AppCompatActivity() {
}
/**
* Shows delete confirmation snackbar, allowing the user to Undo deletion.
* Shows delete confirmation snackbar.
*/
private fun onProfileDeleted(profile: ServerProfile) {
private fun showProfileDeletedMsg(profile: ServerProfile) {
Snackbar.make(binding.root, R.string.msg_server_profile_deleted, Snackbar.LENGTH_LONG)
.setAction(getString(R.string.title_undo)) { viewModel.insertProfile(profile) }
.show()
@ -156,79 +138,4 @@ class HomeActivity : AppCompatActivity() {
private fun updateDiscoveryBadge(list: List<ServerProfile>) {
tabs.updateDiscoveryBadge(list.size)
}
private fun showWelcomeMsg() {
/*if (!viewModel.pref.runInfo.hasShownV2WelcomeMsg) {
viewModel.pref.runInfo.hasShownV2WelcomeMsg = true
@Suppress("DEPRECATION")
packageManager.getPackageInfo(packageName, 0).let {
if (it.lastUpdateTime > it.firstInstallTime)
WelcomeFragment().show(supportFragmentManager, "WelcomeV2")
}
}*/
}
/**
* Warns about missing native library.
* This can happen if AVNC is installed by copying APK from a device with different architecture.
*/
private fun checkNativeLib(): Boolean {
return runCatching {
VncClient.loadLibrary()
}.onFailure {
val msg = "You may have installed AVNC using an incorrect APK. " +
"Please install correct version from F-Droid or Google Play."
MsgDialog.show(supportFragmentManager, "Native library is missing!", msg)
}.isSuccess
}
/************************************************************************************
* Shortcuts
************************************************************************************/
private fun createShortcutId(profile: ServerProfile) = "shortcut:pid:${profile.ID}"
private fun updateShortcuts(profiles: List<ServerProfile>) {
lifecycleScope.launch(Dispatchers.IO) {
runCatching {
updateShortcutState(profiles)
updateDynamicShortcuts(profiles)
}.onFailure {
Log.e("Shortcuts", "Unable to update shortcuts", it)
}
}
}
/**
* Enable/Disable shortcuts based on availability in [profiles]
*/
private fun updateShortcutState(profiles: List<ServerProfile>) {
val pinnedShortcuts = ShortcutManagerCompat.getShortcuts(this, ShortcutManagerCompat.FLAG_MATCH_PINNED)
val disabledMessage = getString(R.string.msg_shortcut_server_deleted)
val possibleIds = profiles.map { createShortcutId(it) }
val pinnedIds = pinnedShortcuts.map { it.id }
val enabledIds = pinnedIds.intersect(possibleIds).toList()
val enabledShortcuts = pinnedShortcuts.filter { it.id in enabledIds }
val disabledIds = pinnedIds.subtract(enabledIds).toList()
ShortcutManagerCompat.enableShortcuts(this, enabledShortcuts)
ShortcutManagerCompat.disableShortcuts(this, disabledIds, disabledMessage)
}
/**
* Updates dynamic shortcut list
*/
private fun updateDynamicShortcuts(profiles: List<ServerProfile>) {
val maxShortcuts = ShortcutManagerCompat.getMaxShortcutCountPerActivity(this)
val shortcuts = profiles.take(maxShortcuts).map { p ->
ShortcutInfoCompat.Builder(this, createShortcutId(p))
.setIcon(IconCompat.createWithResource(this, R.drawable.ic_computer_shortcut))
.setShortLabel(p.name.ifBlank { p.host })
.setLongLabel(p.name.ifBlank { p.host })
.setIntent(IntentReceiverActivity.createShortcutIntent(this, p.ID))
.build()
}
ShortcutManagerCompat.setDynamicShortcuts(this, shortcuts)
}
}

View file

@ -11,13 +11,10 @@ package com.gaurav.avnc.ui.home
import android.app.Dialog
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.EditText
import android.widget.SimpleAdapter
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
@ -33,9 +30,9 @@ import com.gaurav.avnc.model.ServerProfile.Companion.SSH_AUTH_PASSWORD
import com.gaurav.avnc.util.MsgDialog
import com.gaurav.avnc.util.OpenableDocument
import com.gaurav.avnc.viewmodel.HomeViewModel
import com.gaurav.avnc.viewmodel.isPrivateKeyEncrypted
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.trilead.ssh2.crypto.PEMDecoder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -59,7 +56,6 @@ class ProfileEditorFragment : DialogFragment() {
private var profile = ServerProfile()
private fun loadProfile(savedInstanceState: Bundle?) {
@Suppress("DEPRECATION")
profile = savedInstanceState?.getParcelable("EditorProfile")
?: viewModel.editProfileEvent.value
?: profile
@ -168,11 +164,6 @@ class ProfileEditorFragment : DialogFragment() {
getString(R.string.title_key_compat_mode),
getString(R.string.msg_key_compat_mode_help))
}
buttonUpDelayHelpBtn.setOnClickListener {
MsgDialog.show(parentFragmentManager,
getString(R.string.title_button_up_delay),
getString(R.string.msg_button_up_delay_help))
}
// Setup initial values of some CheckBox views.
// We can't use Data Binding to initialize these because it breaks
@ -181,54 +172,19 @@ class ProfileEditorFragment : DialogFragment() {
useSshTunnel.isChecked = (p.channelType == CHANNEL_SSH_TUNNEL)
sshAuthTypePassword.isChecked = (p.sshAuthType == SSH_AUTH_PASSWORD)
sshAuthTypeKey.isChecked = (p.sshAuthType == SSH_AUTH_KEY)
isPrivateKeyEncrypted = p.sshPrivateKey.isNotBlank() && isPrivateKeyEncrypted(p.sshPrivateKey)
isPrivateKeyEncrypted = isKeyEncrypted(p.sshPrivateKey)
// TODO Move it to proper place
val securityTypes = mapOf(
getString(R.string.title_automatic) to 0,
getString(R.string.title_none) to 1,
getString(R.string.title_default_security) to 0,
getString(R.string.pref_gesture_action_none) to 1,
"VncAuth" to 2,
"AnonTLS" to 18,
"VeNCrypt" to 19
)
security.setEntries(securityTypes, p.securityType) { p.securityType = it }
//Setup Gesture Style
val gestureStyleItems = listOf(
mapOf("name" to getString(R.string.pref_gesture_style_auto),
"description" to getString(R.string.pref_gesture_style_auto_summary),
"value" to "auto"),
mapOf("name" to getString(R.string.pref_gesture_style_touchscreen),
"description" to getString(R.string.pref_gesture_style_touchscreen_summary),
"value" to "touchscreen"),
mapOf("name" to getString(R.string.pref_gesture_style_touchpad),
"description" to getString(R.string.pref_gesture_style_touchpad_summary),
"value" to "touchpad"),
)
val adapter = SimpleAdapter(requireContext(), gestureStyleItems, android.R.layout.simple_list_item_1,
arrayOf("name", "description"), intArrayOf(android.R.id.text1, android.R.id.text2))
adapter.setDropDownViewResource(android.R.layout.simple_list_item_2)
gestureStyle.adapter = adapter
gestureStyle.setSelection(gestureStyleItems.indexOfFirst { it["value"] == p.gestureStyle })
gestureStyle.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
p.gestureStyle = gestureStyleItems[position]["value"]!!
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
// Screen orientation spinner
val orientations = mapOf(
getString(R.string.pref_orientation_option_auto) to "auto",
getString(R.string.pref_orientation_option_portrait) to "portrait",
getString(R.string.pref_orientation_option_landscape) to "landscape"
)
screenOrientation.setEntries(orientations.keys.toTypedArray(), orientations.values.toTypedArray(), p.screenOrientation) { p.screenOrientation = it }
}
return binding.root
@ -262,6 +218,7 @@ class ProfileEditorFragment : DialogFragment() {
result = result and
validateNotEmpty(binding.sshHost) and
validateNotEmpty(binding.sshUsername) and
validateNotEmpty(binding.sshPassword, binding.sshAuthTypePassword.isChecked) and
validatePrivateKey()
}
@ -285,6 +242,11 @@ class ProfileEditorFragment : DialogFragment() {
binding.keyImportBtn.error = "Required"
return false
}
if (binding.isPrivateKeyEncrypted && binding.sshKeyPassword.length() == 0) {
binding.sshKeyPassword.error = "Password is required for encrypted Private Key"
return false
}
}
return true
}
@ -299,30 +261,29 @@ class ProfileEditorFragment : DialogFragment() {
return
lifecycleScope.launch(Dispatchers.IO) {
var key = ""
var encrypted = false
val result = runCatching {
requireContext().contentResolver.openAssetFileDescriptor(uri, "r")!!.use {
// Valid key files are only few KBs. So if selected file is too big,
// user has accidentally selected something else.
check(it.length < 200 * 1024) { "File is too big [${it.length}]" }
key = it.createInputStream().use { s -> s.reader().use { r -> r.readText() } }
}
encrypted = isPrivateKeyEncrypted(key)
}
val stream = requireContext().contentResolver.openInputStream(uri)
val text = stream?.bufferedReader()?.readText() ?: ""
val pem = runCatching { PEMDecoder.parsePEM(text.toCharArray()) }
val encrypted = runCatching { PEMDecoder.isPEMEncrypted(pem.getOrNull()) }.getOrNull() ?: false
lifecycleScope.launchWhenCreated {
result.onSuccess {
profile.sshPrivateKey = key
if (pem.isSuccess) {
profile.sshPrivateKey = text
binding.keyImportBtn.setText(R.string.title_change)
binding.keyImportBtn.error = null
binding.isPrivateKeyEncrypted = encrypted
Snackbar.make(requireView(), R.string.msg_imported, Snackbar.LENGTH_SHORT).show()
}.onFailure {
} else {
Snackbar.make(requireView(), R.string.msg_invalid_key_file, Snackbar.LENGTH_LONG).show()
Log.e("ProfileEditor", "Error importing Private Key", it)
}
}
}
}
private fun isKeyEncrypted(key: String): Boolean {
return runCatching {
PEMDecoder.isPEMEncrypted(PEMDecoder.parsePEM(key.toCharArray()))
}.getOrNull() ?: false
}
}

View file

@ -58,10 +58,6 @@ class ServerTabs(val activity: HomeActivity) {
discoveredServersTab = tabLayout.getTabAt(1)!!
discoveredServersTab.setIcon(R.drawable.ic_search)
discoveredServersTab.setContentDescription(R.string.desc_discovered_servers_tab)
//ViewPager2 uses a RecyclerView internally, and sets it's DescendantFocusability
//to 'FOCUS_BEFORE_DESCENDANTS', effectively breaking navigation via D-pad or arrow keys.
pager.forEach { (it as ViewGroup).descendantFocusability = ViewGroup.FOCUS_AFTER_DESCENDANTS }
}
fun showSavedServers() {

View file

@ -35,15 +35,13 @@ class UrlBarActivity : AppCompatActivity() {
val binding = DataBindingUtil.setContentView<ActivityUrlBinding>(this, R.layout.activity_url)
binding.url.setOnEditorActionListener { _, _, _ -> go(binding.url.text.toString()) }
binding.backBtn.setOnClickListener { onBackPressedDispatcher.onBackPressed() }
binding.backBtn.setOnClickListener { onBackPressed() }
binding.clearBtn.setOnClickListener {
if (binding.url.text.isEmpty())
finish()
else
binding.url.setText("")
}
binding.url.requestFocus()
}
private fun go(url: String): Boolean {

View file

@ -1,62 +0,0 @@
/*
* Copyright (c) 2022 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.home
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import com.gaurav.avnc.databinding.FragmentWelcomeBinding
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
/**
* Simple fragment to show welcome message after V2 update
*/
class WelcomeFragment : BottomSheetDialogFragment() {
private val GITHUB_RELEASE_URL = "https://github.com/gujjwal00/avnc/releases"
private val PLAY_APP_URL = "market://details?id=com.gaurav.avnc"
private val PLAY_WEB_URL = "https://play.google.com/store/apps/details?id=com.gaurav.avnc"
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val binding = FragmentWelcomeBinding.inflate(inflater, container, false)
binding.changelogBtn.setOnClickListener { openChangelog() }
binding.rateBtn.setOnClickListener { openAppStore() }
binding.closeBtn.setOnClickListener { dismiss() }
with(requireDialog() as BottomSheetDialog) {
setCanceledOnTouchOutside(false)
behavior.apply {
state = BottomSheetBehavior.STATE_EXPANDED
skipCollapsed = true
isFitToContents = true
}
}
return binding.root
}
private fun openChangelog() {
runCatching { startActivity(Intent.parseUri(GITHUB_RELEASE_URL, 0)) }.onFailure { warnMissingBrowser() }
}
private fun openAppStore() {
runCatching { startActivity(Intent.parseUri(PLAY_APP_URL, 0)) }
.onFailure {
runCatching { startActivity(Intent.parseUri(PLAY_WEB_URL, 0)) }
.onFailure { warnMissingBrowser() }
}
}
private fun warnMissingBrowser() {
Toast.makeText(requireContext(), "Web Browser is missing", Toast.LENGTH_SHORT).show()
}
}

View file

@ -1,196 +0,0 @@
package com.gaurav.avnc.ui.ldc
import android.content.pm.PackageManager
import android.os.Bundle
import android.util.Log
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.Observer
import androidx.lifecycle.OnLifecycleEvent
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.asLiveData
import androidx.preference.PreferenceManager.getDefaultSharedPreferences
import androidx.viewpager2.widget.ViewPager2
import com.gaurav.avnc.R
import com.gaurav.avnc.ui.ldc.events.AppSwitchedToForegroundEvent
import com.gaurav.avnc.ui.ldc.events.NetworkStateChangedEvent
import com.gaurav.avnc.ui.ldc.lib.ApplicationInfo
import com.gaurav.avnc.ui.ldc.lib.KioskViewPagerAdapter
import com.gaurav.avnc.ui.ldc.lib.NetworkObserver
import com.google.android.material.tabs.TabLayout
import okhttp3.*
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
class KioskActivity : AppCompatActivity(), LifecycleObserver {
// * Network
private val networkObserver = NetworkObserver(this, lifecycle)
init { lifecycle.addObserver(networkObserver) }
// * Tab Elements
private lateinit var tabLayout: TabLayout
private lateinit var viewPager: ViewPager2
private lateinit var kioskViewPagerAdapter: KioskViewPagerAdapter
// * AppInfo
lateinit var appInfo: ApplicationInfo
private set
// * Permissions
private lateinit var permissionLauncher: ActivityResultLauncher<Array<String>>
private var isInternetPermissionGranted = false
private var isNetworkStatePermissionGranted = false
private var isWifiStatePermissionGranted = false
/**
* On Create
*/
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_kiosk)
// * Setup AppInfo
appInfo = ApplicationInfo(getDefaultSharedPreferences(applicationContext))
// * Request Permissions
permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
isInternetPermissionGranted = permissions[android.Manifest.permission.INTERNET] ?: isInternetPermissionGranted
isNetworkStatePermissionGranted = permissions[android.Manifest.permission.ACCESS_NETWORK_STATE] ?: isNetworkStatePermissionGranted
isWifiStatePermissionGranted = permissions[android.Manifest.permission.ACCESS_WIFI_STATE] ?: isWifiStatePermissionGranted
}
requestPermission()
// * Init tabs
tabLayout = findViewById(R.id.kiosk_tab_layout)
kioskViewPagerAdapter = KioskViewPagerAdapter(this)
viewPager = findViewById(R.id.kiosk_view_pager)
viewPager.adapter = kioskViewPagerAdapter
// * Tab Selection
tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
viewPager.currentItem = tab.position
}
override fun onTabReselected(tab: TabLayout.Tab) {
viewPager.currentItem = tab.position
}
override fun onTabUnselected(tab: TabLayout.Tab) {}
})
viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
viewPager.currentItem = position
tabLayout.selectTab(tabLayout.getTabAt(position))
}
})
// * Register event bus
EventBus.getDefault().register(this)
// Check NetWork Connection
listenToNetworkConnectionChanges()
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
}
/**
* Event on Activity Resume
*/
override fun onResume() {
super.onResume()
// * AppInfo Refresh
appInfo.refreshConfigProperties()
appInfo.retrieveDeviceNetworkInfo(applicationContext)
}
/**
* Event on App Foreground
*/
@OnLifecycleEvent(Lifecycle.Event.ON_START)
fun onForegroundStart() {
EventBus.getDefault().post(AppSwitchedToForegroundEvent())
}
/**
* Event on App Kill
*/
override fun onDestroy() {
super.onDestroy()
// * Register event bus
EventBus.getDefault().unregister(this)
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
}
/**
* On network message received
*/
@Subscribe(threadMode = ThreadMode.MAIN)
fun onMessageEvent(event: NetworkStateChangedEvent?) {
appInfo.networkConnected = networkObserver.isConnected
appInfo.retrieveDeviceNetworkInfo(applicationContext)
Log.d("network", "network state changed on kiosk activity")
// runOnUiThread {
// Toast.makeText(applicationContext, "Network changed", Toast.LENGTH_SHORT).show();
// }
}
/**
* Observe Network connection
*/
private fun listenToNetworkConnectionChanges() {
networkObserver.networkAvailableStateFlow.asLiveData().observe(this, Observer {
appInfo.networkConnected = networkObserver.isConnected
appInfo.retrieveDeviceNetworkInfo(applicationContext)
Log.d("network", "network state changed on kiosk activity")
// runOnUiThread {
// Toast.makeText(applicationContext, "Network changed", Toast.LENGTH_SHORT).show();
// }
EventBus.getDefault().post(NetworkStateChangedEvent())
})
}
/**
* Request application permissions
*/
private fun requestPermission() {
isInternetPermissionGranted = ContextCompat.checkSelfPermission(
this,
android.Manifest.permission.INTERNET
) == PackageManager.PERMISSION_GRANTED
isNetworkStatePermissionGranted = ContextCompat.checkSelfPermission(
this,
android.Manifest.permission.ACCESS_NETWORK_STATE
) == PackageManager.PERMISSION_GRANTED
isWifiStatePermissionGranted = ContextCompat.checkSelfPermission(
this,
android.Manifest.permission.ACCESS_WIFI_STATE
) == PackageManager.PERMISSION_GRANTED
val permissionRequest: MutableList<String> = ArrayList()
if (!isInternetPermissionGranted) {
permissionRequest.add(android.Manifest.permission.INTERNET)
}
if (!isNetworkStatePermissionGranted) {
permissionRequest.add(android.Manifest.permission.ACCESS_NETWORK_STATE)
}
if (!isWifiStatePermissionGranted) {
permissionRequest.add(android.Manifest.permission.ACCESS_WIFI_STATE)
}
if (permissionRequest.isNotEmpty()){
permissionLauncher.launch(permissionRequest.toTypedArray())
}
}
}

View file

@ -1,453 +0,0 @@
package com.gaurav.avnc.ui.ldc
import android.Manifest
import android.app.Activity
import android.app.AlertDialog
import android.content.BroadcastReceiver
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.graphics.Color
import android.graphics.Typeface
import android.location.LocationManager
import android.net.wifi.WifiManager
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TableLayout
import android.widget.TableRow
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
import com.gaurav.avnc.R
import com.gaurav.avnc.ui.ldc.lib.ApplicationInfo
import com.gaurav.avnc.ui.ldc.lib.WifiFrequencies
import kotlin.math.max
import kotlin.math.min
class KioskDiagnosticActivity : AppCompatActivity(), View.OnClickListener {
// * Permissions
private lateinit var permissionLauncher: ActivityResultLauncher<Array<String>>
private var isInternetPermissionGranted = false
private var isNetworkStatePermissionGranted = false
private var isWifiStatePermissionGranted = false
private var isWifiStateChangePermissionGranted = false
private var isAccessFineLocationGranted = false
private var isAccessCoarseLocationGranted = false
// * View Elements
private lateinit var btnPing: Button
private lateinit var btnReboot: Button
private lateinit var btnRefresh: Button
private lateinit var btnGoBack: Button
private lateinit var tableLayoutWifiRows: TableLayout
private lateinit var progressBarWifiStrengthPercent: ProgressBar
private lateinit var textViewWifiStrengthPercent: TextView
// * AppInfo
private lateinit var appInfo: ApplicationInfo
// * WifiManager
private var wifiManagerListening = false
private lateinit var mWifiManager: WifiManager
private lateinit var mWifiScanReceiver: BroadcastReceiver
/**
* On Create
*/
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_kiosk_diagnostic)
// * Setup AppInfo
appInfo = ApplicationInfo(PreferenceManager.getDefaultSharedPreferences(applicationContext))
// * Request Permissions
permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
isInternetPermissionGranted = permissions[Manifest.permission.INTERNET] ?: isInternetPermissionGranted
isNetworkStatePermissionGranted = permissions[Manifest.permission.ACCESS_NETWORK_STATE] ?: isNetworkStatePermissionGranted
isWifiStatePermissionGranted = permissions[Manifest.permission.ACCESS_WIFI_STATE] ?: isWifiStatePermissionGranted
isWifiStateChangePermissionGranted = permissions[Manifest.permission.CHANGE_WIFI_STATE] ?: isWifiStateChangePermissionGranted
isAccessFineLocationGranted = permissions[Manifest.permission.ACCESS_FINE_LOCATION] ?: isAccessFineLocationGranted
isAccessCoarseLocationGranted = permissions[Manifest.permission.ACCESS_COARSE_LOCATION] ?: isAccessCoarseLocationGranted
}
btnPing = findViewById<Button>(R.id.btnPing)
btnReboot = findViewById<Button>(R.id.btnReboot)
btnRefresh = findViewById<Button>(R.id.btnRefresh)
btnGoBack = findViewById<Button>(R.id.btnGoBack)
tableLayoutWifiRows = findViewById<TableLayout>(R.id.tableLayoutWifiRows)
progressBarWifiStrengthPercent = findViewById<ProgressBar>(R.id.progressBarWifiStrengthPercent)
textViewWifiStrengthPercent = findViewById<TextView>(R.id.textViewWifiStrengthPercent)
// * EVENTS
btnPing.setOnClickListener(this)
btnReboot.setOnClickListener(this)
btnRefresh.setOnClickListener(this)
btnGoBack.setOnClickListener(this)
// * Wont be able to => Hide
btnReboot.visibility = View.INVISIBLE
resetWifiTableView()
requestPermission()
}
/**
* Event on Activity Resume
*/
override fun onResume() {
super.onResume()
// * AppInfo Refresh
appInfo.refreshConfigProperties()
appInfo.retrieveDeviceNetworkInfo(applicationContext)
}
/**
* Event On Request permission result
*/
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
permissions.forEachIndexed { index, permission ->
when(permission) {
Manifest.permission.INTERNET -> isInternetPermissionGranted = grantResults[index] == PackageManager.PERMISSION_GRANTED
Manifest.permission.ACCESS_NETWORK_STATE -> isNetworkStatePermissionGranted = grantResults[index] == PackageManager.PERMISSION_GRANTED
Manifest.permission.ACCESS_WIFI_STATE -> isWifiStatePermissionGranted = grantResults[index] == PackageManager.PERMISSION_GRANTED
Manifest.permission.CHANGE_WIFI_STATE -> isWifiStateChangePermissionGranted = grantResults[index] == PackageManager.PERMISSION_GRANTED
Manifest.permission.ACCESS_FINE_LOCATION -> isAccessFineLocationGranted = grantResults[index] == PackageManager.PERMISSION_GRANTED
Manifest.permission.ACCESS_COARSE_LOCATION -> isAccessCoarseLocationGranted = grantResults[index] == PackageManager.PERMISSION_GRANTED
}
}
}
// * Action UI events
override fun onClick(v: View) {
when (v.id) {
R.id.btnPing -> {
startActivity(Intent(this, KioskPingActivity::class.java))
return
}
R.id.btnReboot -> {
startReboot()
return
}
R.id.btnRefresh -> {
requestPermission()
mWifiManager = getSystemService(Context.WIFI_SERVICE) as WifiManager
if(mWifiManager.wifiState === WifiManager.WIFI_STATE_DISABLED) {
resetWifiTableView()
toastMessage(this, "Wifi désactivé")
return
}
if (!checkPermissionsAreGranted()) {
toastMessage(this, "Permissions manquantes")
return
}
if (!canGetLocation()) {
showSettingsAlert()
return
}
initWifiScanner()
if(wifiManagerListening) {
toastMessage(this, "Rafraichissement des réseaux WiFi")
mWifiManager.startScan()
}
return
}
R.id.btnGoBack -> {
finish()
return
}
}
}
/**
* Initialize Wifi Scanner
*/
private fun initWifiScanner() {
val activity = this;
if(!checkPermissionsAreGranted()) {
toastMessage(activity, "Permissions manquantes pour scanner les réseaux WiFI")
return
}
if (wifiManagerListening) {
return
}
// * WIFI scan init
mWifiManager = getSystemService(Context.WIFI_SERVICE) as WifiManager
mWifiScanReceiver = object : BroadcastReceiver () {
override fun onReceive(c:Context, intent: Intent) {
// * Scan changed
resetWifiTableView()
val actualNetworkName =
if(mWifiManager.connectionInfo.ssid.length === 0)
mWifiManager.connectionInfo.bssid.replace('"', ' ').trim()
else mWifiManager.connectionInfo.ssid.replace('"', ' ').trim()
val actualNetworkFreq = getWifiType(mWifiManager.connectionInfo.frequency)
Log.d("ACTUAL NETWORK", actualNetworkName)
mWifiManager.scanResults.forEach{
/** From -100 to 0
* https://stackoverflow.com/questions/15797920/how-to-convert-wifi-signal-strength-from-quality-percent-to-rssi-dbm/15798024#15798024
*/
var precisePercent = if (it.level <= -100) 0 else if(it.level >= -50) 100 else 2 * (it.level + 100);
val row = LayoutInflater.from(this@KioskDiagnosticActivity)
.inflate(R.layout.activity_kiosk_diagnostic_wifi_row, null) as TableRow
val networkName = if(it.SSID.length === 0)
it.BSSID.replace('"', ' ').trim()
else it.SSID.replace('"', ' ').trim()
Log.d("NETWORK", networkName)
(row.findViewById<View>(R.id.tableCellSSID) as TextView).text = networkName.substring(0, min(networkName.length, 18))
(row.findViewById<View>(R.id.tableCellSignal) as TextView).text = it.level.toString()
(row.findViewById<View>(R.id.tableCellSignalPercent) as TextView).text = "$precisePercent %"
(row.findViewById<View>(R.id.tableCellSignalFreq) as TextView).text = getWifiType(it.frequency)
if (actualNetworkName == networkName && actualNetworkFreq == getWifiType(it.frequency)) {
val ssidTextView = (row.findViewById<View>(R.id.tableCellSSID) as TextView)
ssidTextView.setTypeface(ssidTextView.typeface, Typeface.BOLD_ITALIC)
ssidTextView.setTextColor(Color.BLACK)
}
tableLayoutWifiRows.addView(row)
}
// * Update Actual Wifi strength
val wifiInfo = mWifiManager.connectionInfo
val precisePercent = if (wifiInfo.rssi <= -100) 0 else if(wifiInfo.rssi >= -50) 100 else 2 * (wifiInfo.rssi + 100)
progressBarWifiStrengthPercent.max = 100
progressBarWifiStrengthPercent.min = 0
progressBarWifiStrengthPercent.progress = precisePercent
textViewWifiStrengthPercent.text = "$precisePercent %"
}
}
registerReceiver(mWifiScanReceiver, IntentFilter(WifiManager.NETWORK_STATE_CHANGED_ACTION))
registerReceiver(mWifiScanReceiver, IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION))
registerReceiver(mWifiScanReceiver, IntentFilter(WifiManager.EXTRA_RESULTS_UPDATED))
/**
* The WifiManager.startScan() usage is limited to: - Each foreground app is restricted to 4 scans every 2 minutes
* All background apps combined are restricted to one scan every 30 minutes.
* https://stackoverflow.com/questions/49178307/startscan-in-wifimanager-deprecated-in-android-p
*/
mWifiManager.startScan()
wifiManagerListening = true
}
/**
* Reset Wifi table View
*/
private fun resetWifiTableView() {
val childCount = tableLayoutWifiRows.childCount
if(childCount > 0) {
tableLayoutWifiRows.removeViews(0, max(0, childCount))
}
// * Append Table head
val row: TableRow = if (wifiManagerListening) {
LayoutInflater.from(this@KioskDiagnosticActivity)
.inflate(R.layout.activity_kiosk_diagnostic_wifi_header, null) as TableRow
} else {
LayoutInflater.from(this@KioskDiagnosticActivity)
.inflate(R.layout.activity_kiosk_diagnostic_wifi_press_refresh_row, null) as TableRow
}
tableLayoutWifiRows.addView(row)
}
/**
* Try to reboot device
*/
private fun startReboot()
{
try {
val pm: PowerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
pm.reboot(null)
} catch(e: UnsupportedOperationException) {
toastMessage(this ,"Erreur (${e.message}) : ${e.cause?.message}")
} catch (e: SecurityException) {
toastMessage(this ,"Erreur (${e.message}) : ${e.cause?.message}")
} finally {
toastMessage(this ,"Droits insufisants")
}
}
/**
* Show settings for location
* To allow user ot enable this
*/
private fun showSettingsAlert() {
val alertDialog: AlertDialog.Builder = AlertDialog.Builder(this)
// Setting Dialog Title
alertDialog.setTitle("Attention !")
// Setting Dialog Message
alertDialog.setMessage("Pour scanner les réseaux wifi, la localisation doit être activée.")
// On pressing Settings button
alertDialog.setPositiveButton(
"Accéder à la configuration",
DialogInterface.OnClickListener { _, _ ->
startActivity(Intent(
Settings.ACTION_LOCATION_SOURCE_SETTINGS
))
})
alertDialog.show()
}
/**
* Check if we can get location
* It has to be enabled to access wifi networks
*/
private fun canGetLocation(): Boolean {
var gpsEnabled = false
var networkEnabled = false
var passiveLocationEnabled = false
val lm: LocationManager = getSystemService(LOCATION_SERVICE) as LocationManager
// exceptions will be thrown if provider is not permitted.
try {
gpsEnabled = lm.isProviderEnabled(LocationManager.GPS_PROVIDER)
} catch (ex: Exception) {
toastMessage(this, "Erreur " + ex.message)
}
try {
passiveLocationEnabled = lm.isProviderEnabled(LocationManager.PASSIVE_PROVIDER)
} catch (ex: Exception) {
toastMessage(this, "Erreur " + ex.message)
}
try {
networkEnabled = lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
} catch (ex: Exception) {
toastMessage(this, "Erreur " + ex.message)
}
return gpsEnabled || networkEnabled || passiveLocationEnabled
}
/**
* Display toast message
*/
private fun toastMessage(activity: Activity, message: String) {
activity.runOnUiThread {
Toast.makeText(
activity.applicationContext,
message,
Toast.LENGTH_LONG
).show()
}
}
/**
* Check if all permissions are granted
*/
private fun checkPermissionsAreGranted(): Boolean
{
isInternetPermissionGranted = ContextCompat.checkSelfPermission(
this,
Manifest.permission.INTERNET
) == PackageManager.PERMISSION_GRANTED
isNetworkStatePermissionGranted = ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_NETWORK_STATE
) == PackageManager.PERMISSION_GRANTED
isWifiStatePermissionGranted = ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_WIFI_STATE
) == PackageManager.PERMISSION_GRANTED
isWifiStateChangePermissionGranted = ContextCompat.checkSelfPermission(
this,
Manifest.permission.CHANGE_WIFI_STATE
) == PackageManager.PERMISSION_GRANTED
isAccessFineLocationGranted = ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
isAccessCoarseLocationGranted = ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
return isInternetPermissionGranted && isNetworkStatePermissionGranted &&
isWifiStatePermissionGranted && isWifiStateChangePermissionGranted &&
isAccessFineLocationGranted && isAccessCoarseLocationGranted
}
/**
* Request application permissions
*/
private fun requestPermission() {
if (checkPermissionsAreGranted()) {
return
}
val permissionRequest: MutableList<String> = ArrayList()
if (!isInternetPermissionGranted) {
permissionRequest.add(Manifest.permission.INTERNET)
}
if (!isNetworkStatePermissionGranted) {
permissionRequest.add(Manifest.permission.ACCESS_NETWORK_STATE)
}
if (!isWifiStatePermissionGranted) {
permissionRequest.add(Manifest.permission.ACCESS_WIFI_STATE)
}
if (!isWifiStateChangePermissionGranted) {
permissionRequest.add(Manifest.permission.CHANGE_WIFI_STATE)
}
if (!isAccessFineLocationGranted) {
permissionRequest.add(Manifest.permission.ACCESS_FINE_LOCATION)
}
if (!isAccessCoarseLocationGranted) {
permissionRequest.add(Manifest.permission.ACCESS_COARSE_LOCATION)
}
ActivityCompat.requestPermissions(this@KioskDiagnosticActivity, permissionRequest.toTypedArray(), 100)
}
/**
* Get the wifi network type based on frequency
*/
private fun getWifiType(frequency: Int): String {
if (WifiFrequencies.AH_LOW.freq.rangeTo(WifiFrequencies.AH_HIGH.freq).contains(frequency)) {
return "860/900 MHz"
}
if (WifiFrequencies.BGNAX_LOW.freq.rangeTo(WifiFrequencies.BGNAX_HIGH.freq).contains(frequency)) {
return "2.4Ghz"
}
if (WifiFrequencies.Y_LOW.freq.rangeTo(WifiFrequencies.Y_HIGH.freq).contains(frequency)) {
return "3.65 GHz (US)"
}
if (WifiFrequencies.J_LOW.freq.rangeTo(WifiFrequencies.J_HIGH.freq).contains(frequency)) {
return "4.9–5.0 GHz (J)"
}
if (WifiFrequencies.AHNACAX_LOW.freq.rangeTo(WifiFrequencies.AHNACAX_HIGH.freq).contains(frequency)) {
return "5Ghz"
}
return "$frequency N/a"
}
}

View file

@ -1,146 +0,0 @@
package com.gaurav.avnc.ui.ldc
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.EditText
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.PreferenceManager.getDefaultSharedPreferences
import com.gaurav.avnc.R
import com.gaurav.avnc.model.ServerProfile
import com.gaurav.avnc.ui.ldc.lib.ApplicationInfo
import com.gaurav.avnc.ui.ldc.lib.exceptions.InvalidUriException
import com.gaurav.avnc.viewmodel.HomeViewModel
import java.net.InetAddress
import java.net.UnknownHostException
class KioskParameterActivity : AppCompatActivity(), View.OnClickListener {
// * View Elements
private lateinit var btnValidateConfig: Button
private lateinit var btnGoBack: Button
private lateinit var btnDiagnostic: Button
private lateinit var editTextServerUrl: EditText
private lateinit var editTextIp: EditText
private lateinit var editTextPort: EditText
// * AppInfo
private lateinit var appInfo: ApplicationInfo
/**
* On Create
*/
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_kiosk_parameter)
btnValidateConfig = findViewById<Button>(R.id.btnValidateConfig)
btnGoBack = findViewById<Button>(R.id.btnGoBack)
btnDiagnostic = findViewById<Button>(R.id.btnDiagnostic)
editTextServerUrl = findViewById<EditText>(R.id.editTextServerUrl)
editTextIp = findViewById<EditText>(R.id.editTextIp)
editTextPort = findViewById<EditText>(R.id.editTextPort)
// * EVENTS
btnDiagnostic.setOnClickListener(this)
btnValidateConfig.setOnClickListener(this)
btnGoBack.setOnClickListener(this)
// * Setup AppInfo
appInfo = ApplicationInfo(getDefaultSharedPreferences(applicationContext))
// * Assign inputs
editTextServerUrl.setText(appInfo.configServerUri.toString())
editTextIp.setText(appInfo.vncArgIp.hostAddress)
editTextPort.setText(appInfo.vncArgPort.toString())
}
/**
* Event on Activity Resume
*/
override fun onResume() {
super.onResume()
// * AppInfo Refresh
appInfo.refreshConfigProperties()
appInfo.retrieveDeviceNetworkInfo(applicationContext)
}
/**
* On Back pressed
*/
override fun onBackPressed() {
navigateUpTo(Intent(baseContext, KioskActivity::class.java))
finish()
}
// * Action UI events
override fun onClick(v: View) {
when (v.id) {
R.id.btnValidateConfig -> {
validateAndSaveConfig()
return
}
R.id.btnGoBack -> {
finish()
navigateUpTo(Intent(baseContext, KioskActivity::class.java))
return
}
R.id.btnDiagnostic -> {
startActivity(Intent(this, KioskDiagnosticActivity::class.java))
return
}
}
}
private fun validateAndSaveConfig() {
try {
appInfo.setConfigUri(Uri.parse(editTextServerUrl.text.toString()))
} catch(e: InvalidUriException) {
toastMessage(this, "L'url du fichier est invalide")
}
try {
appInfo.setVncIpPort(
InetAddress.getByName(editTextIp.text.toString()),
editTextPort.text.toString().toInt()
)
} catch(e: NumberFormatException) {
toastMessage(this, "Le port n'est pas valide")
} catch(e: UnknownHostException) {
toastMessage(this, "L'adresse ip n'est pas valide")
}
Thread {
val viewModel by viewModels<HomeViewModel>()
var profile = appInfo.loadProfile(viewModel)
if (profile == null) {
profile = ServerProfile()
profile.host = appInfo.vncArgIp.hostAddress
profile.port = appInfo.vncArgPort
viewModel.insertProfile(profile)
} else {
profile.host = appInfo.vncArgIp.hostAddress
profile.port = appInfo.vncArgPort
viewModel.updateProfile(profile)
}
toastMessage(this, "Enregistré")
}.start()
}
/**
* Display toast message
*/
private fun toastMessage(activity: Activity, message: String) {
activity.runOnUiThread(Runnable {
Toast.makeText(
activity.applicationContext,
message,
Toast.LENGTH_LONG
).show()
})
}
}

View file

@ -1,111 +0,0 @@
package com.gaurav.avnc.ui.ldc
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.Button
import android.widget.EditText
import android.widget.TextView.OnEditorActionListener
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.PreferenceManager.getDefaultSharedPreferences
import com.gaurav.avnc.R
import com.gaurav.avnc.ui.ldc.lib.ApplicationInfo
class KioskParameterPasswordActivity : AppCompatActivity(), View.OnClickListener {
// * View Elements
private lateinit var editTextPassword: EditText
private lateinit var btnSubmitPassword: Button
private lateinit var btnGoBack: Button
// * AppInfo
lateinit var appInfo: ApplicationInfo
private set
/**
* On Create
*/
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_kiosk_parameter_password)
editTextPassword = findViewById<EditText>(R.id.editTextPassword)
btnSubmitPassword = findViewById<Button>(R.id.btnSubmitPassword)
btnGoBack = findViewById<Button>(R.id.btnGoBack)
// * EVENTS
editTextPassword.setOnClickListener(this)
btnSubmitPassword.setOnClickListener(this)
btnGoBack.setOnClickListener(this)
editTextPassword.setOnEditorActionListener(OnEditorActionListener { _, actionId, _ ->
var handled = false
if (actionId == EditorInfo.IME_ACTION_DONE) {
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(editTextPassword.windowToken, 0)
handled = true
}
handled
})
// * Setup AppInfo
appInfo = ApplicationInfo(getDefaultSharedPreferences(applicationContext))
}
/**
* Event on Activity Resume
*/
override fun onResume() {
super.onResume()
// * AppInfo Refresh
appInfo.refreshConfigProperties()
appInfo.retrieveDeviceNetworkInfo(applicationContext)
}
// * Action UI events
override fun onClick(v: View) {
when (v.id) {
R.id.btnSubmitPassword -> {
validatePassword()
return
}
R.id.btnGoBack -> {
finish()
return
}
}
}
/**
* Validate password and go to next activity
* Simple validation pour next intent, pas besoin de haute sécurité
*/
private fun validatePassword() {
editTextPassword.clearFocus()
if(editTextPassword.text.toString() != this.appInfo.configPassword) {
toastMessage(this,"Mot de passe invalide")
runOnUiThread { editTextPassword.setTextColor(Color.RED) }
return
}
runOnUiThread {editTextPassword.setTextColor(Color.BLACK) }
startActivity(Intent(this, KioskParameterActivity::class.java))
}
/**
* Display toast message
*/
private fun toastMessage(activity: Activity, message: String) {
activity.runOnUiThread(Runnable {
Toast.makeText(
activity.applicationContext,
message,
Toast.LENGTH_LONG
).show()
})
}
}

View file

@ -1,259 +0,0 @@
package com.gaurav.avnc.ui.ldc
import android.app.Activity
import android.graphics.Color
import android.os.Bundle
import android.os.StrictMode
import android.view.View
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.graphics.toColorInt
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import com.gaurav.avnc.R
import com.gaurav.avnc.ui.ldc.lib.ApplicationInfo
import com.gaurav.avnc.ui.ldc.lib.exceptions.InvalidIpException
import com.gaurav.avnc.ui.ldc.lib.exceptions.PingFailedException
import com.google.common.net.InetAddresses
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
import java.net.UnknownHostException
import kotlin.math.roundToInt
class KioskPingActivity : AppCompatActivity(), View.OnClickListener {
// * View Elements
private lateinit var editTextPingUrl: EditText
private lateinit var btnPing: Button
private lateinit var btnGoBack: Button
private lateinit var textResult: TextView
// * AppInfo
private lateinit var appInfo: ApplicationInfo
/**
* On Create
*/
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val policy = StrictMode.ThreadPolicy.Builder().permitAll().build()
StrictMode.setThreadPolicy(policy)
setContentView(R.layout.activity_kiosk_ping)
editTextPingUrl = findViewById<EditText>(R.id.editTextPingUrl)
btnPing = findViewById<Button>(R.id.btnPing)
btnGoBack = findViewById<Button>(R.id.btnGoBack)
textResult = findViewById<TextView>(R.id.textResult)
// * EVENTS
btnPing.setOnClickListener(this)
btnGoBack.setOnClickListener(this)
// * Setup AppInfo
appInfo = ApplicationInfo(PreferenceManager.getDefaultSharedPreferences(applicationContext))
// * Init text result
textResult.visibility = View.INVISIBLE
}
/**
* Event on Activity Resume
*/
override fun onResume() {
super.onResume()
// * AppInfo Refresh
appInfo.refreshConfigProperties()
appInfo.retrieveDeviceNetworkInfo(applicationContext)
editTextPingUrl.setText(appInfo.gateway.hostAddress)
}
// * Action UI events
override fun onClick(v: View) {
when (v.id) {
R.id.btnPing -> {
ping()
return
}
R.id.btnGoBack -> {
finish()
return
}
}
}
/**
* Ping action
*/
private fun ping() {
// * Prepare UI
editTextPingUrl.clearFocus()
textResult.text = "En cours.."
textResult.setTextColor(Color.BLACK)
textResult.visibility = View.VISIBLE
// * Disable ping BTN
btnPing.isEnabled = false
btnPing.isClickable = false
btnPing.setBackgroundColor(Color.GRAY)
// * Disable textBox
editTextPingUrl.isEnabled = false
editTextPingUrl.isClickable = false
val elementToPing = editTextPingUrl.text.toString()
lifecycleScope.executeAsyncTask(onPreExecute = {}, doInBackground = {
Thread {
try {
lateinit var inetToPing: InetAddress
// ? Is IP
if (isValidIpAddress(elementToPing)) {
// Resolve
inetToPing = InetAddress.getByName(elementToPing)
} else {
// * Is FQDN
var host = elementToPing.toHttpUrlOrNull()?.host
// Resolve
inetToPing = InetAddress.getByName(if(host !== null) host else elementToPing)
}
// * Ping ping
val avg = pingHost(inetToPing)
toastMessage(this, "FQDN AVG $avg ms")
runOnUiThread {
textResult.text = "Accessible ${inetToPing.hostAddress}"
textResult.setTextColor("#009933".toColorInt())
}
return@Thread
} catch(e: PingFailedException) {
runOnUiThread {
textResult.text = "Echec du ping"
textResult.setTextColor(Color.RED)
}
} catch(e: InvalidIpException) {
runOnUiThread {
textResult.text = "Echec du ping ip invalide"
textResult.setTextColor(Color.RED)
}
} catch(e: SecurityException) {
toastMessage(this, "Erreur : ${e.message} - ${e.cause?.message}")
runOnUiThread {
textResult.text = "Echec du ping erreur de sécurité"
textResult.setTextColor(Color.RED)
}
} catch(e: UnknownHostException) {
toastMessage(this, "Echec du ping hote inconnu")
runOnUiThread {
textResult.text = "Echec du ping !!"
textResult.setTextColor(Color.RED)
}
} catch(e: IOException) {
toastMessage(this, "Echec du ping erreur de connection")
runOnUiThread {
textResult.text = "Echec du ping !!"
textResult.setTextColor(Color.RED)
}
}
}.run()
}, onPostExecute = {
// * Enable ping BTN
btnPing.isEnabled = true
btnPing.isClickable = true
btnPing.setBackgroundColor("#D4021D".toColorInt())
// * Enable textBox
editTextPingUrl.isEnabled = true
editTextPingUrl.isClickable = true
})
}
/**
* Ping host and return average time in ms
* @throws PingFailedException In case of failure
* @throws InvalidIpException If inetAddress is not a valid IPv4 or IPv6
*/
private fun pingHost(inetToPing: InetAddress): Int {
// ? Is Valid IP
if (!isValidIpAddress(inetToPing.hostAddress)) {
throw InvalidIpException()
}
inetToPing.isReachable(600)
var avg = -1
lateinit var process: Process
if (inetToPing is Inet6Address) {
process = Runtime.getRuntime().exec("/system/bin/ping6 -c 1 ${inetToPing.hostAddress}")
} else if (inetToPing is Inet4Address) {
process = Runtime.getRuntime().exec("/system/bin/ping -c 1 ${inetToPing.hostAddress}")
} else {
throw InvalidIpException()
}
BufferedReader(InputStreamReader(process.inputStream)).forEachLine {
// it: "PING 194.194.194.194 (194.194.194.194) 56(84) bytes of data."
// it: "rtt min/avg/max/mdev = 326.137/326.137/326.137/0.000 ms"
if (it.contains("rtt ")) {
avg = Regex("\\d+\\.\\d+").findAll(it).toList()[1].value.toFloat().roundToInt()
}
}
if (avg === -1) {
toastMessage(this, "IP erreur de connection")
throw PingFailedException()
}
return avg
}
/**
* Test if ip Address is valid
*/
private fun isValidIpAddress(str: String): Boolean {
if (str.isEmpty())
{
return false
}
// Regex for IPV4 and IPV6
return InetAddresses.isInetAddress(str)
}
/**
* Display toast message
*/
private fun toastMessage(activity: Activity, message: String) {
activity.runOnUiThread(Runnable {
Toast.makeText(
activity.applicationContext,
message,
Toast.LENGTH_LONG
).show()
})
}
fun <R> CoroutineScope.executeAsyncTask(
onPreExecute: () -> Unit,
doInBackground: () -> R,
onPostExecute: (R) -> Unit
) = launch {
onPreExecute()
val result = withContext(Dispatchers.IO) { // runs in background thread without blocking the Main Thread
doInBackground()
}
onPostExecute(result)
}
}

View file

@ -1,6 +0,0 @@
package com.gaurav.avnc.ui.ldc.events
/**
* The network connection state has changed
*/
class AppSwitchedToForegroundEvent()

View file

@ -1,6 +0,0 @@
package com.gaurav.avnc.ui.ldc.events
/**
* The network connection state has changed
*/
class NetworkStateChangedEvent()

View file

@ -1,503 +0,0 @@
package com.gaurav.avnc.ui.ldc.fragments
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import androidx.core.graphics.toColorInt
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.JsonMappingException
import com.fasterxml.jackson.databind.ObjectMapper
import com.gaurav.avnc.R
import com.gaurav.avnc.model.ServerProfile
import com.gaurav.avnc.ui.ldc.KioskActivity
import com.gaurav.avnc.ui.ldc.KioskParameterPasswordActivity
import com.gaurav.avnc.ui.ldc.events.AppSwitchedToForegroundEvent
import com.gaurav.avnc.ui.ldc.events.NetworkStateChangedEvent
import com.gaurav.avnc.ui.ldc.lib.exceptions.PasswordValidationException
import com.gaurav.avnc.ui.vnc.startVncActivity
import com.gaurav.avnc.viewmodel.HomeViewModel
import com.google.common.net.InetAddresses
import com.networknt.schema.JsonSchemaFactory
import com.networknt.schema.SchemaValidatorsConfig
import com.networknt.schema.SpecVersion
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.apache.commons.csv.CSVFormat
import org.apache.commons.csv.CSVParser
import org.apache.commons.csv.CSVRecord
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStream
import java.io.Reader
import java.net.InetAddress
import java.util.regex.Matcher
import java.util.regex.Pattern
/**
* HOME TAB
*/
class KioskHomeFragment : Fragment(), View.OnClickListener {
// * View Elements
private lateinit var txtMacAddress: TextView
private lateinit var txtIPAddress: TextView
private lateinit var txtVNCArgs: TextView
private lateinit var btnLoadConfig: Button
private lateinit var btnVNC: Button
private lateinit var btnParameters: Button
private lateinit var progressBar: ProgressBar
// * EVENTS
/**
* On Start
*/
override fun onStart() {
super.onStart()
}
/**
* On Stop
*/
override fun onStop() {
super.onStop()
}
/**
* On Create
*/
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
/**
* On Destroy
*/
override fun onDestroy() {
super.onDestroy()
// * Register event bus
EventBus.getDefault().unregister(this)
}
/**
* On Create view
*/
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate the layout for this fragment
val view:View = inflater.inflate(R.layout.fragment_kiosk_home, container, false)
txtIPAddress = view.findViewById<TextView>(R.id.txtIPAddress)
txtMacAddress = view.findViewById<TextView>(R.id.txtMacAddress)
txtVNCArgs = view.findViewById<TextView>(R.id.txtVNCArgs)
btnLoadConfig = view.findViewById<Button>(R.id.btnLoadConfig)
btnVNC = view.findViewById<Button>(R.id.btnVNC)
btnParameters = view.findViewById<Button>(R.id.btnParameters)
progressBar = view.findViewById<ProgressBar>(R.id.progressBar)
// * EVENTS
btnLoadConfig.setOnClickListener(this)
btnVNC.setOnClickListener(this)
btnParameters.setOnClickListener(this)
// * Register event bus
EventBus.getDefault().register(this)
return view;
}
/**
* Event on Activity Resume
*/
override fun onResume() {
super.onResume()
val activity = (this.activity as KioskActivity)
// * AppInfo Refresh
activity.appInfo.refreshConfigProperties()
activity.appInfo.retrieveDeviceNetworkInfo(activity.applicationContext)
showNetworkInfo(activity.appInfo.networkConnected)
showVNCArgs()
}
/**
* On App Background
*/
@Subscribe(threadMode = ThreadMode.MAIN)
fun onMessageEvent(event: AppSwitchedToForegroundEvent?) {
val activity = (activity as KioskActivity)
showNetworkInfo(activity.appInfo.networkConnected)
}
/**
* On network message received
*/
@Subscribe(threadMode = ThreadMode.MAIN)
fun onMessageEvent(event: NetworkStateChangedEvent?) {
val activity = (activity as KioskActivity)
showNetworkInfo(activity.appInfo.networkConnected)
}
// * Action UI events
override fun onClick(v: View) {
when (v.id) {
R.id.btnVNC -> btnStartVncSessionClick(v, this)
R.id.btnLoadConfig -> btnLoadConfigClick(v, this)
R.id.btnParameters -> {
startActivity(Intent(this.context, KioskParameterPasswordActivity::class.java))
}
}
}
/**
* Action on click button "Load config"
*/
private fun btnLoadConfigClick(view:View, fragment: KioskHomeFragment) {
val activity = (fragment.activity as KioskActivity)
manageButtons(false)
// Check we are connected to a network
if (!activity.appInfo.networkConnected) {
toastMessage(activity, "Vous devez être connecté à un réseau")
}
manageButtons(true)
// Download Config File
loadDistantConfiguration(fragment)
// Download Password config
fetchConfigPassword(fragment)
}
/**
* Action on click button "Load config"
*/
private fun btnStartVncSessionClick(view:View, fragment: KioskHomeFragment) {
val activity = (fragment.activity as KioskActivity)
val viewModel by viewModels<HomeViewModel>()
Thread {
if (!activity.appInfo.networkConnected) {
toastMessage(activity, "Vous devez être connecté à un réseau")
return@Thread
}
val profile = activity.appInfo.loadProfile(viewModel)
if (profile == null) {
toastMessage(activity, "La configuration est manquante")
} else {
startVncActivity(activity, profile)
}
}.start()
}
// * PRIVATE METHODS
/**
* Load config csv from server
*/
private fun loadDistantConfiguration(fragment: KioskHomeFragment) {
val activity = (fragment.activity as KioskActivity)
val viewModel by viewModels<HomeViewModel>()
val client = OkHttpClient()
val request = Request.Builder()
.url(activity.appInfo.configServerUri.toString())
.build()
val callback = object : Callback {
override fun onFailure(call: Call, e: IOException) {
toastMessage(activity, "Erreur : ${e.message} - ${e.cause?.message}")
manageButtons(true)
return
//e.printStackTrace()
}
@SuppressLint("SetTextI18n")
override fun onResponse(call: Call, response: Response) {
response.use {
if (!response.isSuccessful) {
toastMessage(activity, "Erreur (${response.code}) : ${response.message}")
manageButtons(true)
return
}
val res: Reader = response.body!!.charStream();
val bufferedReader: BufferedReader = BufferedReader(res);
val parser = CSVParser.parse(bufferedReader, CSVFormat.DEFAULT.withDelimiter(';'));
for (csvRecord: CSVRecord in parser) {
val identifier = csvRecord.get(0);
// Si le premier caractère est un dieze la ligne est commentée donc on passe
if (identifier[0] == '#' || identifier.isEmpty()) {
continue;
}
// On test si l´Identifiant TID correspond à celle du Device
if (identifier.length == 11 && identifier.equals(activity.appInfo.tidId)) {
val ip = csvRecord.get(3);
val port = csvRecord.get(4);
if (ip!!.isEmpty() || !isValidIpAddress(ip)) {
toastMessage(activity, "Adresse IP invalide")
manageButtons(true)
return
}
if (port!!.isEmpty() || !isValidPort(port)) {
toastMessage(activity, "Port invalide")
manageButtons(true)
return
}
var profile = activity.appInfo.loadProfile(viewModel)
if (profile == null) {
profile = ServerProfile()
profile.host = InetAddress.getByName(ip).hostAddress
profile.port = port.toInt()
viewModel.insertProfile(profile)
} else {
profile.host = InetAddress.getByName(ip).hostAddress
profile.port = port.toInt()
viewModel.updateProfile(profile)
}
// * AppInfo Refresh
activity.appInfo.setVncIpPort(InetAddress.getByName(ip), port.toInt())
activity.runOnUiThread {
fragment.txtVNCArgs?.text = "Arguments VNC : ${profile.host}:${profile.port}"
}
toastMessage(activity, "Configuration chargée")
manageButtons(true)
return
}
}
toastMessage(activity, "Configuration non trouvée")
manageButtons(true)
return
}
}
}
client.newCall(request).enqueue(callback)
}
/**
* Try to fetch config password from server
*/
private fun fetchConfigPassword(fragment: KioskHomeFragment) {
val activity = (fragment.activity as KioskActivity)
val client = OkHttpClient()
val request = Request.Builder()
.url(activity.appInfo.appConfigUri.toString())
.build()
val callback = object : Callback {
override fun onFailure(call: Call, e: IOException) {
toastMessage(activity, "Erreur : ${e.message} - ${e.cause?.message}")
return
}
@SuppressLint("SetTextI18n")
override fun onResponse(call: Call, response: Response) {
response.use {
if (!response.isSuccessful) {
toastMessage(activity ,"Erreur (${response.code}) : ${response.message}")
return
}
val bodyResponse = response.body?.string()
if (bodyResponse == null) {
toastMessage(activity, "Erreur réponse vide du serveur")
return
}
if(!isJSONValid(activity, bodyResponse)) {
toastMessage(activity, "Erreur réponse invalide du serveur")
return
} else {
try {
saveJSONPassword(activity, bodyResponse)
} catch(e: PasswordValidationException) {
toastMessage(activity, "Erreur mot de passe sur le serveur est invalide")
}
}
return
}
}
}
client.newCall(request).enqueue(callback)
}
/**
* Validate Json parameter schema
*/
private fun isJSONValid(activity: KioskActivity, jsonString: String): Boolean {
// * Validate JSON
try {
JSONObject(jsonString)
} catch (ex: JSONException) {
try {
JSONArray(jsonString)
} catch (ex1: JSONException) {
return false
}
}
// * Validate JSON Schema
return try {
val inputStream: InputStream = resources.assets.open("security.schema.json");
val schemaString = inputStream.reader().readText()
val schemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V4)
val schema = schemaFactory.getSchema(schemaString)
val config: SchemaValidatorsConfig = SchemaValidatorsConfig()
Log.d("test", config.toString())
val jsonNode = ObjectMapper().readTree(jsonString)
// * Validate json against schema
val errors = schema.validate(jsonNode)
if (errors.size > 0) {
Log.d("JSON", "Invalid JSON against schema")
return false
}
return true
} catch(e: IOException) {
false
} catch(e: JsonProcessingException) {
Log.d("JSON", "${e.message}")
false
} catch(e: JsonMappingException) {
Log.d("JSON", "${e.message}")
false
}
}
/**
* Save Json password
* @throws com.gaurav.avnc.ui.ldc.lib.exceptions.PasswordValidationException
*/
private fun saveJSONPassword(activity: KioskActivity, jsonString: String) {
val mapper = ObjectMapper()
val configNode = mapper.readValue<Map<String, String>>(jsonString, object : TypeReference<Map<String, String>>() {})
activity.appInfo.setPassword(configNode["password"]!!)
}
/**
* Display network info
*/
@SuppressLint("HardwareIds")
private fun showNetworkInfo(isConnected: Boolean) {
val activity = (activity as KioskActivity)
val ip = activity.appInfo.ip
val identifier = activity.appInfo.tidId
if (activity.appInfo.networkConnected) {
txtIPAddress?.text = "Adresse IP: ${ip?.hostAddress}"
txtMacAddress?.text = "Identifiant TID: $identifier"
} else {
txtIPAddress?.text = "Adresse IP: -"
txtMacAddress?.text = "Identifiant TID: $identifier"
}
}
/**
* Display VNCArgs
*/
private fun showVNCArgs() {
val activity = (activity as KioskActivity)
val ipVNC = activity.appInfo.vncArgIp.hostAddress
val portVNC = activity.appInfo.vncArgPort
//println("IPIP" + ipVNC)
txtVNCArgs?.text = "Arguments VNC : $ipVNC:$portVNC"
}
/**
* Manage interface buttons state
*/
private fun manageButtons(enable: Boolean) {
val activity = (activity as KioskActivity)
activity.runOnUiThread {
if (enable) {
btnLoadConfig?.isEnabled = true
btnLoadConfig?.isClickable = true
btnLoadConfig?.setBackgroundColor("#D4021D".toColorInt())
btnVNC?.isEnabled = true
btnVNC?.isClickable = true
btnVNC?.setBackgroundColor("#D4021D".toColorInt())
progressBar?.isVisible = false
} else {
btnLoadConfig?.isEnabled = false
btnLoadConfig?.isClickable = false
btnLoadConfig?.setBackgroundColor(Color.GRAY)
btnVNC?.isEnabled = false
btnVNC?.isClickable = false
btnVNC?.setBackgroundColor(Color.GRAY)
progressBar?.isVisible = true
}
}
}
/**
* Test if ip Address is valid
*/
private fun isValidIpAddress(str: String): Boolean {
if (str.isEmpty())
{
return false
}
// Regex for IPV4 and IPV6
return InetAddresses.isInetAddress(str)
}
/**
* Test if port is valid
*/
private fun isValidPort(str: String): Boolean {
if (str.isEmpty())
{
return false
}
try {
str.toInt()
} catch (e: NumberFormatException) {
return false
}
val regex: String =
"^((6553[0-5])|(655[0-2][0-9])|(65[0-4][0-9]{2})|(6[0-4][0-9]{3})|([1-5][0-9]{4})|([0-5]{0,5})|([0-9]{1,4}))\$"
val p: Pattern = Pattern.compile(regex)
val m: Matcher = p.matcher(str)
return m.matches()
}
/**
* Display toast message
*/
private fun toastMessage(activity: Activity, message: String) {
activity.runOnUiThread(Runnable {
Toast.makeText(
activity.applicationContext,
message,
Toast.LENGTH_LONG
).show()
})
}
}

View file

@ -1,144 +0,0 @@
package com.gaurav.avnc.ui.ldc.fragments
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.children
import androidx.fragment.app.Fragment
import com.gaurav.avnc.R
import com.gaurav.avnc.ui.ldc.KioskActivity
import com.gaurav.avnc.ui.ldc.events.AppSwitchedToForegroundEvent
import com.gaurav.avnc.ui.ldc.events.NetworkStateChangedEvent
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
/**
* INFORMATION'S TAB
*/
class KioskInformationFragment : Fragment(), View.OnClickListener {
// * View Elements
private lateinit var infoLinearLayout: LinearLayout
/**
* On Start
*/
override fun onStart() {
super.onStart()
}
/**
* On Stop
*/
override fun onStop() {
super.onStop()
}
/**
* On Create
*/
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// * Register event bus
EventBus.getDefault().register(this)
}
/**
* On Destroy
*/
override fun onDestroy() {
super.onDestroy()
// * Register event bus
EventBus.getDefault().unregister(this)
}
/**
* On Create view
*/
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate the layout for this fragment
val view:View = inflater.inflate(R.layout.fragment_kiosk_information, container, false)
infoLinearLayout = view.findViewById<LinearLayout>(R.id.infoLinearLayout)
return view;
}
/**
* Event on Activity Resume
*/
override fun onResume() {
super.onResume()
val activity = (this.activity as KioskActivity)
// * AppInfo Refresh
activity.appInfo.refreshConfigProperties()
activity.appInfo.retrieveDeviceNetworkInfo(activity.applicationContext)
displayTextInfo()
}
// * Action UI events
override fun onClick(v: View) {
Log.d("click", "click")
}
/**
* On App Background
*/
@Subscribe(threadMode = ThreadMode.MAIN)
fun onMessageEvent(event: AppSwitchedToForegroundEvent?) {
displayTextInfo()
}
/**
* On network message received
*/
@Subscribe(threadMode = ThreadMode.MAIN)
fun onMessageEvent(event: NetworkStateChangedEvent?) {
Log.d("network", "network state changed on kiosk fragment")
displayTextInfo()
}
/**
* Update Text Info
*/
private fun displayTextInfo() {
// * Load actual network configuration
val activity = (activity as KioskActivity)
val cells = infoLinearLayout.children as Sequence<TextView>
val ip = activity.appInfo.ip
val gateway = activity.appInfo.gateway
val info:Map<String, String> = mapOf(
"Identifiant TID" to activity.appInfo.tidId,
"Adresse IP" to if(ip !== null) ip.hostAddress else "-",
"Gateway" to if(gateway !== null) gateway.hostAddress else "-",
"Serveur de Config" to activity.appInfo.configServerUri.toString(),
"Système" to activity.appInfo.systemVERSION,
"Kiosk" to activity.appInfo.appVERSION,
"Dernière MàJ" to activity.appInfo.buildTimestamp.toLocaleString(),
)
info.onEachIndexed(fun (index, infoLine) {
(cells.elementAt(index ) as TextView).text = infoLine.key + "\t" + infoLine.value
})
}
private fun reloadNetWorkInfo (view: View, fragment: KioskInformationFragment) {
EventBus.getDefault().post(NetworkStateChangedEvent())
val activity = (fragment.activity as KioskActivity)
activity.appInfo.retrieveDeviceNetworkInfo(activity.applicationContext)
displayTextInfo()
EventBus.getDefault().post(NetworkStateChangedEvent())
}
}

View file

@ -1,298 +0,0 @@
package com.gaurav.avnc.ui.ldc.lib
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.Uri
import android.os.Build
import android.webkit.URLUtil
import com.gaurav.avnc.BuildConfig
import com.gaurav.avnc.model.ServerProfile
import com.gaurav.avnc.ui.ldc.lib.exceptions.InvalidUriException
import com.gaurav.avnc.ui.ldc.lib.exceptions.PasswordValidationException
import com.gaurav.avnc.viewmodel.HomeViewModel
import com.serialIntCaster.SerialCaster
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.net.InetAddress
import java.util.Date
import java.util.regex.Matcher
import java.util.regex.Pattern
import kotlin.reflect.KProperty1
/**
* This class stores application Info used for app execution
* and info display
*/
class ApplicationInfo(private var preferences: SharedPreferences) {
// * Static Elements (Readonly as val)
/** Android config filename */
private val appConfigFile = "security.json"
/** The unique TID identifier generated with SerialIntCaster */
val tidId: String = if(BuildConfig.DEBUG) "2T63SSH2D8F" else fetchIdentifier()
/** Android OS version */
val systemVERSION: String = "Android " + Build.VERSION.RELEASE
/** App version */
val appVERSION: String = BuildConfig.VERSION_NAME
/** App build timestamp generated using build.gradle */
val buildTimestamp: Date = BuildConfig.BUILD_TIME
/**
* Shared preferences items for dynamic update
*/
private val sharedPreferencesFields = mapOf<String,Set<String>>(
"Int" to setOf<String>("vncArgPort"),
"InetAddress" to setOf<String>("vncArgIp"),
"String" to setOf<String>("configPassword", "configServerFqdn", "configServerPath", "appConfigPath"),
"Uri" to setOf<String>("configServerUri", "appConfigUri")
)
// * Dynamic elements
/** Is the device actually connected to a network */
var networkConnected: Boolean = false
/** The device network ip that is used */
var ip: InetAddress? = null
private set
/** The device network gateway that is used */
var gateway: InetAddress = InetAddress.getByName("127.0.0.1")
private set
// * Dynamic elements stored prefs
/** The VNC ip fetched from server */
var vncArgIp: InetAddress = InetAddress.getByName("127.0.0.1")
private set
/** The VNC port fetched from server */
var vncArgPort: Int = 5900
private set
/** The default password for configuration */
var configPassword: String = "73ezheJ8"
private set
/** The server FQDN from where config should be fetched */
private var configServerFqdn: String = "http://localtid"
/** VNC config path */
private var configServerPath: String = if(BuildConfig.DEBUG) "/conf/AppCepa.conf" else "/conf/KioskTIDServer.conf"
/** Android config path */
private var appConfigPath: String = if(BuildConfig.DEBUG) "/conf/$appConfigFile" else "/conf/$appConfigFile"
/** VNC config URI */
var configServerUri: Uri = Uri.parse(configServerFqdn + configServerPath)
/** Android config URI */
var appConfigUri: Uri = Uri.parse(configServerFqdn + appConfigPath)
init {
refreshConfigProperties()
SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, s ->
sharedPreferencesFields.forEach(fun (key, items) {
if (items.indexOf(s) != -1) {
when (key) {
"Int" -> initProp(s, sharedPreferences.getString(s,
readInstanceProperty<Int>(this, s).toString()
)!!)
"InetAddress" -> initProp(s, InetAddress.getByName(sharedPreferences.getString(s,
readInstanceProperty<InetAddress>(this, s).hostAddress
)))
"String" -> initProp(s, sharedPreferences.getString(s,
readInstanceProperty<String>(this, s)
)!!)
"Uri" -> initProp(s, Uri.parse(sharedPreferences.getString(s,
readInstanceProperty<Uri>(this, s).toString()
)))
}
}
})
}
}
/** Init prop Integer */
private fun initProp(prop: String, value: Int): Int {
val preferenceValue = preferences.getInt(prop, value)
return if(preferenceValue !== null) preferenceValue else value
}
/** Init prop String */
private fun initProp(prop: String, value: String): String {
val preferenceValue = preferences.getString(prop, value)
return if(preferenceValue !== null) preferenceValue else value
}
/** Init prop Uri */
private fun initProp(prop: String, value: Uri): Uri {
val preferenceValue = Uri.parse(preferences.getString(prop, value.toString()))
return if(preferenceValue !== null) preferenceValue else value
}
/** Init prop Inet */
private fun initProp(prop: String, value: InetAddress): InetAddress {
val preferenceValue = InetAddress.getByName(preferences.getString(prop, value.hostAddress))
return if(preferenceValue !== null) preferenceValue else value
}
/** Set prop Integer */
private fun setProp(prop: String, value: Int) {
preferences.edit().putInt(prop, value).commit()
}
/** Set prop String */
private fun setProp(prop: String, value: String) {
preferences.edit().putString(prop, value).commit()
}
/** Set prop Uri */
private fun setProp(prop: String, value: Uri) {
preferences.edit().putString(prop, value.toString()).commit()
}
/** Set prop Inet */
private fun setProp(prop: String, value: InetAddress) {
preferences.edit().putString(prop, value.hostAddress).commit()
}
/**
* Refresh info or init from sharedPreferences
*/
fun refreshConfigProperties() {
vncArgIp = initProp("vncArgIp", vncArgIp)
vncArgPort = initProp("vncArgPort", vncArgPort)
configPassword = initProp("configPassword", configPassword)
configServerFqdn = initProp("configServerFqdn", configServerFqdn)
configServerPath = initProp("configServerPath", configServerPath)
appConfigPath = initProp("appConfigPath", appConfigPath)
configServerUri = initProp("configServerUri", configServerUri)
appConfigUri = initProp("appConfigUri", appConfigUri)
}
/**
* Generate identifier using SerialIntCaster or fetch from preferences
*/
private fun fetchIdentifier(): String {
val identifierPref: String? = preferences.getString("TID_IDENTIFIER", "NOT_SET")
var identifier = if(identifierPref === null) "NOT_SET" else identifierPref
if (identifier == "NOT_SET")
{
identifier = SerialCaster.encode(
System.currentTimeMillis(),
564646546765768,
11,
"ABCDEFGHJKLMNPQRSTUVWXYZ23456789".toCharArray()
)
preferences.edit().putString("TID_IDENTIFIER", identifier).commit()
}
return identifier
}
/**
* Set Device ip in app info using application context
*/
@SuppressLint("HardwareIds")
fun retrieveDeviceNetworkInfo(applicationContext: Context) {
val connectivityManager = applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
ip = InetAddress.getByName("127.0.0.1")
gateway = InetAddress.getByName("127.0.0.1")
val linkProperties = connectivityManager.getLinkProperties(connectivityManager.activeNetwork)
ip = linkProperties?.linkAddresses?.first {
!it.address.isLoopbackAddress && it.address.isSiteLocalAddress
}?.address
val fetchedGateway = linkProperties?.routes?.first {
val inetGateway = it.gateway
inetGateway !== null && !inetGateway.isLoopbackAddress && inetGateway.isSiteLocalAddress
}?.gateway
if(fetchedGateway !== null) {
gateway = fetchedGateway
}
}
/**
* Load VNC profile (blocked on the first slot)
*/
fun loadProfile(viewModel: HomeViewModel): ServerProfile? {
val profiles = viewModel.getAllProfiles()
val profile = profiles.find { it.ID == 1.toLong() }
if (profile != null) {
return profile
}
return null
}
/**
* Set config Uri
* @throws InvalidUriException If the url is not valid or the file pointing at is not a .conf
*/
fun setConfigUri(newConfigUri: Uri) {
val uriInput = newConfigUri.toString()
if (uriInput.toHttpUrlOrNull() == null || !URLUtil.isValidUrl(uriInput)) {
throw InvalidUriException()
}
var newConfigPath = newConfigUri.path
newConfigPath = if (newConfigPath !== null) newConfigPath else
(if(BuildConfig.DEBUG) "/AppCepa.conf" else "/conf/KioskTIDServer.conf")
val pathSegments: List<String> = Uri.parse(newConfigPath).pathSegments
val fileName = pathSegments.last()
if (fileName.substring(fileName.lastIndexOf(".") + 1, fileName.length) != "conf") {
throw InvalidUriException()
}
// * Save config
configServerFqdn = Uri.parse("${newConfigUri.scheme}://${newConfigUri.host}").toString()
setProp("configServerFqdn", configServerFqdn)
//println(configServerFqdn)
configServerPath = Uri.parse(newConfigPath).toString()
setProp("configServerPath", configServerPath)
//println(configServerPath)
/** VNC config URI */
configServerUri = Uri.parse(configServerFqdn + configServerPath)
setProp("configServerUri", configServerUri)
//println(configServerUri)
/** Android config URI */
appConfigPath = newConfigPath.substring(0, newConfigPath.indexOf(fileName)) + appConfigFile
setProp("appConfigPath", appConfigPath)
//println(appConfigPath)
appConfigUri = Uri.parse(configServerFqdn + appConfigPath)
setProp("appConfigUri", appConfigUri)
//println(appConfigUri)
}
/**
* Set VNC ip port
*/
fun setVncIpPort(newVncArgIp: InetAddress, newVncArgPort: Int) {
vncArgIp = newVncArgIp
setProp("vncArgIp", vncArgIp)
vncArgPort = newVncArgPort
setProp("vncArgPort", vncArgPort)
}
/**
* Assign settings password
* @throws PasswordValidationException
*/
fun setPassword(password: String) {
// * Must contain lower, upper and number min 8 max 30
val regex: String = "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?!.* ).{8,30}$"
val p: Pattern = Pattern.compile(regex)
val m: Matcher = p.matcher(password)
if (!m.matches()) {
throw PasswordValidationException()
}
configPassword = password
setProp("configPassword", configPassword)
}
@Suppress("UNCHECKED_CAST")
fun <R> readInstanceProperty(instance: Any, propertyName: String): R {
val property = instance::class.members
// don't cast here to <Any, R>, it would succeed silently
.first { it.name == propertyName } as KProperty1<Any, *>
// force a invalid cast exception if incorrect type here
return property.get(instance) as R
}
}

View file

@ -1,22 +0,0 @@
package com.gaurav.avnc.ui.ldc.lib
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.gaurav.avnc.ui.ldc.KioskActivity
import com.gaurav.avnc.ui.ldc.fragments.KioskHomeFragment
import com.gaurav.avnc.ui.ldc.fragments.KioskInformationFragment
class KioskViewPagerAdapter(kiosk_activity: KioskActivity) : FragmentStateAdapter(kiosk_activity) {
override fun getItemCount(): Int {
return 2
}
override fun createFragment(position: Int): Fragment {
return when(position) {
0 -> KioskHomeFragment()
1 -> KioskInformationFragment()
else -> throw Error("Fragment not Handled $position")
}
}
}

View file

@ -1,136 +0,0 @@
package com.gaurav.avnc.ui.ldc.lib
import android.content.Context
import android.content.Context.CONNECTIVITY_SERVICE
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
import android.net.NetworkRequest
import android.util.Log
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import com.gaurav.avnc.ui.ldc.events.NetworkStateChangedEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import java.util.concurrent.atomic.AtomicBoolean
/**
* This class is in charge of listening to the state of the network connection and notifying the
* activity if the state of the connection changes.
* */
class NetworkObserver constructor(
private val context: Context,
private val lifecycle: Lifecycle
) : DefaultLifecycleObserver {
private lateinit var networkCallback: ConnectivityManager.NetworkCallback
private var connectivityManager: ConnectivityManager? = null
private val validNetworks = HashSet<Network>()
private lateinit var job: Job
private lateinit var coroutineScope: CoroutineScope
// State Holder: Indicating either the network is available or not-available
private val _networkAvailableStateFlow: MutableStateFlow<NetworkState> = MutableStateFlow(NetworkState.Available)
val networkAvailableStateFlow
get() = _networkAvailableStateFlow
// ---> This variable can be accessed anytime to get the current state of the network
val isConnected: Boolean
get() = _isConnected.get()
private val _isConnected = AtomicBoolean(false)
override fun onCreate(owner: LifecycleOwner) {
super.onCreate(owner)
init()
}
override fun onStart(owner: LifecycleOwner) {
super.onStart(owner)
registerNetworkCallback()
checkValidNetworks()
}
override fun onStop(owner: LifecycleOwner) {
super.onStop(owner)
unregisterNetworkCallback()
}
private fun init() {
// Initialize the connectivity manager instance
connectivityManager = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
}
private fun registerNetworkCallback() {
if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
// Observing the network should happen only when the life-cycle is in started state
initCoroutine()
initNetworkMonitoring()
}
}
private fun unregisterNetworkCallback() {
validNetworks.clear()
connectivityManager?.unregisterNetworkCallback(networkCallback)
job.cancel()
}
/**
* Co-Routine used to monitor the connectivity
*/
private fun initCoroutine() {
// Create a job instance
job = Job()
// Provide a co-routine scope
coroutineScope = CoroutineScope(Dispatchers.Default + job)
}
private fun initNetworkMonitoring() {
networkCallback = createNetworkCallback()
val networkRequest = NetworkRequest.Builder()
.addCapability(NET_CAPABILITY_INTERNET)
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.build()
connectivityManager?.registerNetworkCallback(networkRequest, networkCallback)
}
private fun createNetworkCallback() = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Log.d("network", "Has new network $network")
validNetworks.add(network)
checkValidNetworks()
EventBus.getDefault().post(NetworkStateChangedEvent())
}
override fun onLost(network: Network) {
Log.d("network", "Remove network $network")
validNetworks.remove(network)
checkValidNetworks()
EventBus.getDefault().post(NetworkStateChangedEvent())
}
}
private fun checkValidNetworks() {
coroutineScope.launch {
_networkAvailableStateFlow.emit(
if (validNetworks.size > 0){
_isConnected.set(true)
NetworkState.Available
} else {
_isConnected.set(false)
NetworkState.UnAvailable
}
)
}
}
}

View file

@ -1,5 +0,0 @@
package com.gaurav.avnc.ui.ldc.lib
enum class NetworkState {
UnAvailable, Available
}

View file

@ -1,35 +0,0 @@
package com.gaurav.avnc.ui.ldc.lib
/**
* Wifi Channels
* https://en.wikipedia.org/wiki/List_of_WLAN_channels
*/
enum class WifiFrequencies() {
/** 802.11ah LOW (860/900 MHz) */
AH_LOW(755),
/** 802.11ah HIGH (860/900 MHz) */
AH_HIGH(755),
/** 802.11b/g/n/ax LOW (2.4Ghz) */
BGNAX_LOW(2400),
/** 802.11b/g/n/ax HIGH (2.4Ghz) */
BGNAX_HIGH(2500),
/** 802.11y LOW (3.65 GHz) */
Y_LOW(3650),
/** 802.11y HIGH (3.65 GHz) */
Y_HIGH(3700),
/** 802.11j LOW (4.9–5.0 GHz) */
J_LOW(4900),
/** 802.11j HIGH (4.9–5.0 GHz) */
J_HIGH(5100),
/** 802.11a/h/n/ac/axLOW (5Ghz) */
AHNACAX_LOW(5100),
/** 802.11a/h/n/ac/ax HIGH (5Ghz) */
AHNACAX_HIGH(5900);
var freq: Int = 0;
// custom constructors
constructor(freq: Int) : this() {
this.freq = freq
}
}

View file

@ -1,4 +0,0 @@
package com.gaurav.avnc.ui.ldc.lib.exceptions
class InvalidIpException: RuntimeException() {
}

View file

@ -1,4 +0,0 @@
package com.gaurav.avnc.ui.ldc.lib.exceptions
class InvalidUriException: RuntimeException() {
}

View file

@ -1,4 +0,0 @@
package com.gaurav.avnc.ui.ldc.lib.exceptions
class PasswordValidationException: RuntimeException() {
}

View file

@ -1,4 +0,0 @@
package com.gaurav.avnc.ui.ldc.lib.exceptions
class PingFailedException: RuntimeException() {
}

View file

@ -1,207 +0,0 @@
/*
* 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.graphics.Typeface
import android.os.Bundle
import android.text.TextUtils
import android.util.AttributeSet
import android.util.TypedValue
import android.view.*
import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
import androidx.annotation.Keep
import androidx.core.view.forEach
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.gaurav.avnc.R
import com.gaurav.avnc.databinding.FragmentKeyTestBinding
import com.gaurav.avnc.databinding.FragmentLogsBinding
import com.gaurav.avnc.databinding.FragmentTouchTestBinding
import com.gaurav.avnc.util.Debugging
import com.gaurav.avnc.viewmodel.PrefsViewModel
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
abstract class DebugFragment : Fragment() {
abstract fun title(): String
override fun onResume() {
super.onResume()
activity?.title = title()
}
fun copyLogs(logs: String) {
val wrapped = Debugging.wrapLogs(title(), logs)
activityViewModels<PrefsViewModel>().value.setClipboardText(wrapped)
snackbar(getString(R.string.msg_copied_to_clipboard))
}
fun snackbar(text: String) = Snackbar.make(requireView(), text, Snackbar.LENGTH_SHORT).show()
}
/**
* Provides easy access to Logcat.
*/
@Keep
class LogsFragment : DebugFragment() {
override fun title() = getString(R.string.pref_logs)
private var logs = ""
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val binding = FragmentLogsBinding.inflate(inflater, container, false)
binding.logsRv.layoutManager = LinearLayoutManager(requireContext())
binding.logsRv.adapter = LogsAdapter(listOf("Loading ..."))
lifecycleScope.launch {
withContext(Dispatchers.IO) {
logs = Debugging.logcat().ifBlank { "No logs yet!" }
val lines = logs.lines()
withContext(Dispatchers.Main) {
binding.logsRv.adapter = LogsAdapter(lines)
if (lines.isNotEmpty())
binding.logsRv.scrollToPosition(lines.size - 1)
}
}
}
binding.clearBtn.setOnClickListener {
Debugging.clearLogs()
snackbar("Cleared!")
parentFragmentManager.popBackStack()
}
binding.copyBtn.setOnClickListener { copyLogs(logs) }
return binding.root
}
class LogsAdapter(private val logList: List<String>) : RecyclerView.Adapter<LogsViewHolder>() {
override fun getItemCount() = logList.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LogsViewHolder {
val tv = TextView(parent.context)
tv.typeface = Typeface.MONOSPACE
tv.ellipsize = TextUtils.TruncateAt.END
tv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
tv.setSingleLine()
return LogsViewHolder(tv)
}
override fun onBindViewHolder(holder: LogsViewHolder, position: Int) {
holder.tv.text = logList[position]
}
}
class LogsViewHolder(val tv: TextView) : RecyclerView.ViewHolder(tv)
}
/**
* This fragment is used to record [MotionEvent]s generated for a gesture.
* It can help in understanding why a gesture is not working correctly on
* a particular device. It also helps in understanding how [MotionEvent]s
* are generated for other devices like Stylus, Touchpad etc.
*/
@Keep
class TouchTestFragment : DebugFragment() {
override fun title() = getString(R.string.pref_touch_test)
private val eventLog = StringBuilder()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val binding = FragmentTouchTestBinding.inflate(inflater, container, false)
@Suppress("ClickableViewAccessibility")
binding.gestureArea.setOnTouchListener { _, e -> eventLog.appendLine("OnTouch: $e"); true }
binding.gestureArea.setOnGenericMotionListener { _, e -> eventLog.appendLine("OnGeneric: $e"); true }
binding.gestureArea.setOnHoverListener { _, e -> eventLog.appendLine("OnHover: $e"); true }
binding.gestures.forEach { view ->
view.setOnClickListener {
eventLog.appendLine("\n\nGestureStart: " + (view as TextView).text.toString())
}
}
binding.resetBtn.setOnClickListener {
binding.gestures.clearCheck()
eventLog.setLength(0)
snackbar("Reset")
}
binding.copyBtn.setOnClickListener { copyLogs(eventLog.toString()) }
return binding.root
}
}
/**
* This fragment is used to record [KeyEvent]s generated for different key presses.
* It can help in understanding key combination generates which character.
*/
@Keep
class KeyTestFragment : DebugFragment() {
override fun title() = getString(R.string.pref_key_test)
lateinit var binding: FragmentKeyTestBinding
private val eventLog = StringBuilder()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentKeyTestBinding.inflate(inflater, container, false)
binding.inputArea.setOnKeyListener { _, _, event ->
eventLog.appendLine("$event")
event.dispatch(binding.preview, binding.preview.keyDispatcherState, binding.preview)
}
binding.inputArea.setOnClickListener {
with(requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager) {
binding.inputArea.requestFocus()
showSoftInput(binding.inputArea, 0)
}
}
binding.resetBtn.setOnClickListener {
eventLog.setLength(0)
snackbar("Reset")
}
binding.copyBtn.setOnClickListener {
eventLog.appendLine("\n\nPreview text: " + binding.preview.text)
copyLogs(eventLog.toString())
}
binding.inputArea.requestFocus()
return binding.root
}
override fun onStop() {
super.onStop()
if (binding.autoCopy.isChecked)
binding.copyBtn.callOnClick()
}
}
class KeyTestView(context: Context, attributeSet: AttributeSet?) : View(context, attributeSet) {
override fun onCreateInputConnection(outAttrs: EditorInfo): BaseInputConnection {
outAttrs.imeOptions = outAttrs.imeOptions or EditorInfo.IME_FLAG_NO_EXTRACT_UI or EditorInfo.IME_FLAG_NO_FULLSCREEN
return BaseInputConnection(this, false)
}
}

View file

@ -16,11 +16,14 @@ import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.Keep
import androidx.annotation.StringRes
import androidx.biometric.BiometricPrompt
import androidx.biometric.auth.AuthPromptCallback
import androidx.biometric.auth.startClass2BiometricOrCredentialAuthentication
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.activityViewModels
import com.gaurav.avnc.R
import com.gaurav.avnc.databinding.FragmentImportExportBinding
import com.gaurav.avnc.util.DeviceAuthPrompt
import com.gaurav.avnc.util.OpenableDocument
import com.gaurav.avnc.viewmodel.PrefsViewModel
import com.google.android.material.snackbar.Snackbar
@ -31,11 +34,10 @@ import java.util.*
class ImportExportFragment : Fragment() {
private val importFilePicker = registerForActivityResult(OpenableDocument()) { import(it) }
private val exportFilePicker = registerForActivityResult(ActivityResultContracts.CreateDocument("*/*")) { export(it) }
private val exportFilePicker = registerForActivityResult(ActivityResultContracts.CreateDocument()) { export(it) }
private lateinit var binding: FragmentImportExportBinding
private val viewModel by activityViewModels<PrefsViewModel>()
private val exportAuthPrompt by lazy { DeviceAuthPrompt(requireActivity()) }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@ -52,11 +54,6 @@ class ImportExportFragment : Fragment() {
viewModel.importFinishedEvent.observe(viewLifecycleOwner) { if (it == true) showMsg(R.string.msg_imported) }
viewModel.exportFinishedEvent.observe(viewLifecycleOwner) { if (it == true) showMsg(R.string.msg_exported) }
exportAuthPrompt.init(
onSuccess = { exportFilePicker.launch(generateFilename()) },
onFail = { viewModel.importExportError.value = it }
)
return binding.root
}
@ -87,10 +84,13 @@ class ImportExportFragment : Fragment() {
* This is to protect sensitive info that might be present in exported data.
*/
private fun startExport() {
if (exportAuthPrompt.canLaunch()) {
exportAuthPrompt.launch(getString(R.string.msg_export_auth_required))
viewModel.importExportError.value = null //Clear old error
} else
if (viewModel.canAuthenticateUser)
startClass2BiometricOrCredentialAuthentication(
title = getString(R.string.msg_export_auth_required),
confirmationRequired = false,
callback = ExportAuthCallback()
)
else
exportFilePicker.launch(generateFilename())
}
@ -103,4 +103,22 @@ class ImportExportFragment : Fragment() {
if (uri != null)
viewModel.export(uri)
}
/**
* Used for authentication before Export
*/
private inner class ExportAuthCallback : AuthPromptCallback() {
override fun onAuthenticationSucceeded(a: FragmentActivity?, r: BiometricPrompt.AuthenticationResult) {
exportFilePicker.launch(generateFilename())
}
override fun onAuthenticationError(a: FragmentActivity?, errorCode: Int, errString: CharSequence) {
viewModel.importExportError.value = errString.toString()
}
override fun onAuthenticationFailed(activity: FragmentActivity?) {
viewModel.importExportError.value = "Authentication failed!"
}
}
}

View file

@ -0,0 +1,97 @@
/*
* Copyright (c) 2021 Gaurav Ujjwal.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* See COPYING.txt for more details.
*/
package com.gaurav.avnc.ui.prefs
import android.os.Bundle
import android.view.*
import androidx.annotation.Keep
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import com.gaurav.avnc.R
import com.gaurav.avnc.databinding.FragmentInfoBinding
import com.gaurav.avnc.util.Debugging
import com.gaurav.avnc.viewmodel.PrefsViewModel
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
/**
* Simple fragment which shows different types of info in scrollable text view.
* It also allows copying the value to clipboard.
*/
@Keep
abstract class InfoFragment : Fragment() {
var text = ""
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
setHasOptionsMenu(true)
val binding = FragmentInfoBinding.inflate(inflater, container, false)
lifecycleScope.launchWhenCreated {
withContext(Dispatchers.IO) {
text = getInfo()
}
binding.text.text = text
delay(100)
binding.vScroll.fullScroll(View.FOCUS_DOWN)
}
return binding.root
}
override fun onResume() {
super.onResume()
activity?.title = getTitle()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
menu.add(getString(android.R.string.copy))
.setOnMenuItemClickListener { copyToClipboard(); true }
.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
}
private fun copyToClipboard() {
activityViewModels<PrefsViewModel>().value.setClipboardText(text)
Snackbar.make(requireView(), R.string.msg_copied_to_clipboard, Snackbar.LENGTH_SHORT).show()
}
abstract fun getInfo(): String
abstract fun getTitle(): String
}
@Keep
class LogsFragment : InfoFragment() {
override fun getInfo() = Debugging.logcat()
override fun getTitle() = getString(R.string.pref_logs)
/**
* In addition to Copy, users can Clear the logs
*/
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
menu.add(getString(R.string.title_clear)).setOnMenuItemClickListener {
Debugging.clearLogs()
Snackbar.make(requireView(), "Cleared!", Snackbar.LENGTH_SHORT).show()
parentFragmentManager.popBackStack()
true
}
}
}
@Keep
class KeyCodeMapFragment : InfoFragment() {
override fun getInfo() = Debugging.keyCodeMap()
override fun getTitle() = getString(R.string.pref_keycode_map)
}

View file

@ -14,19 +14,16 @@ 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.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreference
import com.gaurav.avnc.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)
@ -42,7 +39,7 @@ class PrefsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPreference
}
override fun onSupportNavigateUp(): Boolean {
onBackPressedDispatcher.onBackPressed()
onBackPressed()
return true
}
@ -50,7 +47,7 @@ class PrefsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPreference
* Starts new fragment corresponding to given [pref].
*/
override fun onPreferenceStartFragment(caller: PreferenceFragmentCompat, pref: Preference): Boolean {
val fragment = supportFragmentManager.fragmentFactory.instantiate(classLoader, pref.fragment!!)
val fragment = supportFragmentManager.fragmentFactory.instantiate(classLoader, pref.fragment)
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_host, fragment)
@ -98,7 +95,7 @@ class PrefsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPreference
}
@Keep class Input : PrefFragment(R.xml.pref_input) {
private var invertScrollingUpdater: OnSharedPreferenceChangeListener? = null
private var naturalScrollingUpdater: OnSharedPreferenceChangeListener? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -112,54 +109,26 @@ class PrefsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPreference
}
}
val style = findPreference<ListPreferenceEx>("gesture_style")!!
val swipe1 = findPreference<ListPreferenceEx>("gesture_swipe1")!!
val longPressSwipe = findPreference<ListPreferenceEx>("gesture_long_press_swipe")!!
val swipe2 = findPreference<ListPreference>("gesture_swipe2")!!
val drag = findPreference<ListPreferenceEx>("gesture_drag")!!
swipe1.disabledStateSummary = getString(R.string.pref_gesture_action_move_pointer)
longPressSwipe.helpMessage = getText(R.string.msg_drag_gesture_help)
swipe1.apply { disabledStateSummary = getString(R.string.pref_gesture_action_move_pointer) }
drag.apply { 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")
}
// To reduce clutter & avoid 'UI overload', Natural scrolling pref is not visible by default.
// It becomes visible only when 'Scroll remote content' option is used.
naturalScrollingUpdater = OnSharedPreferenceChangeListener { _, _ ->
val pref = findPreference<SwitchPreference>("natural_scrolling")
pref?.isVisible = "remote-scroll" in listOf(swipe1.value, swipe2.value, drag.value)
}
invertScrollingUpdater?.onSharedPreferenceChanged(swipe1.sharedPreferences, null) //Initial update
swipe1.sharedPreferences?.registerOnSharedPreferenceChangeListener(invertScrollingUpdater)
naturalScrollingUpdater?.onSharedPreferenceChanged(null, null) //Initial update
swipe1.sharedPreferences.registerOnSharedPreferenceChangeListener(naturalScrollingUpdater)
}
}
@Keep class Server : PrefFragment(R.xml.pref_server) {
private val authPrompt by lazy { DeviceAuthPrompt(requireActivity()) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val savedServerLock = findPreference<SwitchPreference>("lock_saved_server")!!
if (authPrompt.canLaunch()) {
authPrompt.init({ savedServerLock.isChecked = !savedServerLock.isChecked }, { })
savedServerLock.setOnPreferenceChangeListener { _, _ ->
authPrompt.launch(getString(R.string.title_unlock_dialog))
false
}
} else {
savedServerLock.isEnabled = false
}
}
}
@Keep class Server : PrefFragment(R.xml.pref_server)
@Keep class Tools : PrefFragment(R.xml.pref_tools)
@Keep class Experimental : PrefFragment(R.xml.pref_experimental)
}

View file

@ -0,0 +1,135 @@
/*
* 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.fragment.app.DialogFragment
import androidx.fragment.app.activityViewModels
import com.gaurav.avnc.R
import com.gaurav.avnc.databinding.FragmentCredentialBinding
import com.gaurav.avnc.model.LoginInfo
import com.gaurav.avnc.viewmodel.VncViewModel
import com.gaurav.avnc.vnc.UserCredential
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputLayout
/**
* Allows user to enter credentials for remote server.
*/
class CredentialFragment : DialogFragment() {
val viewModel by activityViewModels<VncViewModel>()
lateinit var binding: FragmentCredentialBinding
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
binding = FragmentCredentialBinding.inflate(layoutInflater, null, false)
binding.usernameRequired = viewModel.credentialRequest.value
binding.canRemember = viewModel.profile.ID != 0L
setupAutoComplete()
isCancelable = false
return prepareDialog()
}
/**
* Prepare dialog instance
*/
private fun prepareDialog(): Dialog {
return MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.title_credentials_required)
.setView(binding.root)
.setPositiveButton(android.R.string.ok) { _, _ ->
val username = binding.username.text.toString()
val password = getRealPassword(binding.password.text.toString())
val cred = UserCredential(username, password)
viewModel.credentialRequest.offerResponse(cred)
if (binding.remember.isChecked)
scheduleCredentialSave(viewModel, cred)
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
viewModel.credentialRequest.cancelRequest()
requireActivity().finish()
}
.create()
}
companion object {
/**
* 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.
*
* This method is 'static' to avoid any accidental leak of fragment instance.
*/
private fun scheduleCredentialSave(viewModel: VncViewModel, cred: UserCredential) {
with(viewModel) {
state.observeForever {
if (it == VncViewModel.State.Connected) {
profile.username = cred.username
profile.password = cred.password
saveProfile()
}
}
}
}
}
/**
* 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() {
if (!viewModel.pref.server.credAutocomplete) {
binding.usernameLayout.endIconMode = TextInputLayout.END_ICON_NONE
binding.passwordLayout.endIconMode = TextInputLayout.END_ICON_NONE
return
}
viewModel.knownCredentials.observe(this) { list ->
val usernames = list.map { it.username }.filter { it.isNotEmpty() }.distinct()
val passwords = preparePasswordSuggestions(list)
val usernameAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_list_item_1, usernames)
val passwordAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_list_item_1, passwords)
binding.username.setAdapter(usernameAdapter)
binding.password.setAdapter(passwordAdapter)
}
}
/**
* 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

@ -9,6 +9,7 @@
package com.gaurav.avnc.ui.vnc
import android.graphics.PointF
import com.gaurav.avnc.ui.vnc.Dispatcher.SwipeAction
import com.gaurav.avnc.viewmodel.VncViewModel
import com.gaurav.avnc.vnc.Messenger
import com.gaurav.avnc.vnc.PointerButton
@ -52,7 +53,6 @@ import kotlin.math.abs
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
@ -62,102 +62,85 @@ class Dispatcher(private val activity: VncActivity) {
**************************************************************************/
private val directMode = DirectMode()
private val relativeMode = RelativeMode()
private var config = Config()
private val defaultMode = if (gesturePref.directTouch) directMode else relativeMode
private inner class Config {
val gestureStyle = if (profile.gestureStyle == "auto") gesturePref.style else profile.gestureStyle
val defaultMode = if (gestureStyle == "touchscreen") directMode else relativeMode
private val tap1Action = selectPointAction(gesturePref.tap1)
private val tap2Action = selectPointAction(gesturePref.tap2)
private val doubleTapAction = selectPointAction(gesturePref.doubleTap)
private val longPressAction = selectPointAction(gesturePref.longPress)
val tap1Action = selectPointAction(gesturePref.tap1)
val tap2Action = selectPointAction(gesturePref.tap2)
val doubleTapAction = selectPointAction(gesturePref.doubleTap)
val longPressAction = selectPointAction(gesturePref.longPress)
private val swipe1Action = selectSwipeAction(if (gesturePref.directTouch) gesturePref.swipe1 else "move-pointer")
private val swipe2Action = selectSwipeAction(gesturePref.swipe2)
private val dragAction = selectSwipeAction(gesturePref.drag)
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()
private val mouseBackAction = selectPointAction(viewModel.pref.input.mouseBack)
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 { _, _ -> }
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
}
}
private fun selectSwipeAction(actionName: String): SwipeAction {
return when (actionName) {
"pan" -> SwipeAction { _, _, dx, dy -> doPan(dx, dy) }
"move-pointer" -> SwipeAction { _, cp, dx, dy -> defaultMode.doMovePointer(cp, dx, dy) }
"remote-scroll" -> SwipeAction { sp, _, dx, dy -> defaultMode.doRemoteScroll(sp, dx, dy) }
"remote-drag" -> SwipeAction { _, cp, dx, dy -> defaultMode.doDrag(cp, dx, dy) }
else -> SwipeAction { _, _, _, _ -> } //Nothing
}
}
//Instead of using generic lambda, like point actions, we are using a functional
//interface with SAM conversion to avoid boxing/unboxing overhead for dx & dy.
private fun interface SwipeAction {
/**
* [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
*/
operator fun invoke(sp: PointF, cp: PointF, dx: Float, dy: Float)
}
/**************************************************************************
* Event receivers
**************************************************************************/
fun onGestureStart() = config.defaultMode.onGestureStart()
fun onGestureStop(p: PointF) = config.defaultMode.onGestureStop(p)
fun onGestureStart() = defaultMode.onGestureStart()
fun onGestureStop(p: PointF) = 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 onTap1(p: PointF) = tap1Action(p)
fun onTap2(p: PointF) = tap2Action(p)
fun onDoubleTap(p: PointF) = doubleTapAction(p)
fun onLongPress(p: PointF) = 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 onSwipe1(sp: PointF, cp: PointF, dx: Float, dy: Float) = swipe1Action(sp, cp, dx, dy)
fun onSwipe2(sp: PointF, cp: PointF, dx: Float, dy: Float) = swipe2Action(sp, cp, dx, dy)
fun onDrag(sp: PointF, cp: PointF, dx: Float, dy: Float) = dragAction(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 onFling(vx: Float, vy: Float) = defaultMode.doFling(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 onMouseBack(p: PointF) = 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 onXKeySym(keySym: Int, isDown: Boolean) = messenger.sendKey(keySym, isDown)
fun onGestureStyleChanged() {
config = Config()
}
/**************************************************************************
* Available actions
@ -166,8 +149,6 @@ class Dispatcher(private val activity: VncActivity) {
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
@ -179,13 +160,14 @@ class Dispatcher(private val activity: VncActivity) {
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)
private val yScrollDirection = (if (gesturePref.naturalScrolling) 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)
abstract fun doDrag(p: PointF, dx: Float, dy: Float)
abstract fun doFling(vx: Float, vy: Float)
open fun onGestureStart() = stopFrameFling()
open fun onGestureStart() {}
open fun onGestureStop(p: PointF) = doButtonRelease(p)
fun doButtonDown(button: PointerButton, p: PointF) {
@ -202,9 +184,6 @@ class Dispatcher(private val activity: VncActivity) {
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.cfButtonUpDelay)
messenger.insertButtonUpDelay()
doButtonUp(button, p)
}
@ -256,7 +235,9 @@ class Dispatcher(private val activity: VncActivity) {
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)
override fun doDrag(p: PointF, dx: Float, dy: Float) = doButtonDown(PointerButton.Left, p)
override fun doFling(vx: Float, vy: Float) = viewModel.frameScroller.fling(vx, vy)
override fun onGestureStart() = viewModel.frameScroller.stop()
}
/**
@ -283,28 +264,25 @@ class Dispatcher(private val activity: VncActivity) {
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)
x = x.coerceIn(0f, viewModel.frameState.fbWidth - 1)
y = y.coerceIn(0f, viewModel.frameState.fbHeight - 1)
}
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
val centerDiffX = viewModel.frameState.vpWidth / 2 - vp.x
val centerDiffY = viewModel.frameState.vpHeight / 2 - vp.y
viewModel.panFrame(centerDiffX, centerDiffY)
}
override fun doRemoteDrag(button: PointerButton, p: PointF, dx: Float, dy: Float) {
doButtonDown(button, p)
override fun doDrag(p: PointF, dx: Float, dy: Float) {
doButtonDown(PointerButton.Left, p)
doMovePointer(p, dx, dy)
}
override fun doFling(vx: Float, vy: Float) {} //Disabled
}
}

View file

@ -47,25 +47,24 @@ class FrameScroller(val viewModel: VncViewModel) {
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).
* 1) x >= 0 : 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.
* 2) x < 0 : Here, 'scaled frame width' > 'viewport width'. In this case
* minX is negative and maxX = 0.
*
* 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
val minX = if (x >= 0) x else fs.vpWidth - (fs.fbWidth * fs.scale)
val maxX = if (x >= 0) x else 0F
val minY = if (y >= 0) y else fs.vpHeight - (fs.fbHeight * fs.scale)
val maxY = if (y >= 0) y else 0F
xAnimator.apply {
setStartValue(x)

View file

@ -9,7 +9,7 @@
package com.gaurav.avnc.ui.vnc
import android.graphics.PointF
import android.graphics.RectF
import com.gaurav.avnc.util.AppPreferences
import kotlin.math.max
import kotlin.math.min
@ -27,30 +27,25 @@ import kotlin.math.min
*
* Window: Top-level window of the application/activity.
*
* Viewport: This is the area of window where frame is rendered. It is denoted by [FrameView].
* Viewport: This is the area of screen 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 the 'total' area available to our activity while viewport
* denotes the 'visible to user' area. Normally they would be same but
* viewport can be smaller than window (e.g. if soft keyboard is visible).
*
* 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).
* +---------------------------+
* | |
* | ViewPort |
* | | Window
* +---------------------------+
* | Soft Keyboard |
* +---------------------------+
*
* +---------------------------+ - -
* | \Cutout/ | | | Viewport
* | | | | -
* | | | | | SafeArea
* +---------------------------+ | - -
* | Soft Keyboard | | Window
* +---------------------------+ -
* Differentiating between window & viewport 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. And viewport size is used for coercing
* frame position so that user can pan the whole frame even if keyboard is visible.
*
* 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
* ===================
@ -81,12 +76,10 @@ import kotlin.math.min
* - 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.
* This is the user controlled part. It always starts at 1 (100% zoom), and only updated in response
* to pinch gestures. Conceptually, it works 'on top of' the base scale.
*
*
* 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
@ -100,11 +93,13 @@ import kotlin.math.min
* Renderer thread may see half-updated state. But it should "eventually" settle down
* because any change in frame state is usually followed by a new render request.
*/
class FrameState(
private val minZoomScale: Float = 0.5F,
private val maxZoomScale: Float = 5F,
private val usePerOrientationZoom: Boolean = false
) {
class FrameState(private val minZoomScale: Float = 0.5F, private val maxZoomScale: Float = 5F) {
constructor(prefs: AppPreferences) : this(prefs.viewer.zoomMin, prefs.viewer.zoomMax)
var baseScale = 1F; private set
var zoomScale = 1F; private set
val scale get() = baseScale * zoomScale
//Frame position, relative to top-left corner (0,0)
var frameX = 0F; private set
@ -122,20 +117,6 @@ class FrameState(
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
fun setFramebufferSize(w: Float, h: Float) {
fbWidth = w
@ -157,11 +138,6 @@ class FrameState(
coerceValues()
}
fun setSafeArea(rect: RectF) {
safeArea = RectF(rect)
coerceValues()
}
/**
* Adjust zoom scale according to give [scaleFactor].
*
@ -176,9 +152,8 @@ class FrameState(
return zoomScale / oldScale //Applied scale factor
}
fun setZoom(zoom1: Float, zoom2: Float) {
zoomScale1 = zoom1
zoomScale2 = zoom2
fun resetZoom() {
zoomScale = 1F
coerceValues()
}
@ -241,24 +216,19 @@ class FrameState(
* 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 = RectF(0f, 0f, vpWidth, vpHeight)
frameX = coercePosition(frameX, safeArea.left, safeArea.right, fbWidth)
frameY = coercePosition(frameY, safeArea.top, safeArea.bottom, fbHeight)
zoomScale = zoomScale.coerceIn(minZoomScale, maxZoomScale)
frameX = coercePosition(frameX, vpWidth, fbWidth)
frameY = coercePosition(frameY, vpHeight, fbHeight)
}
/**
* Coerce position value in a direction (horizontal/vertical).
*/
private fun coercePosition(current: Float, safeMin: Float, safeMax: Float, fb: Float): Float {
private fun coercePosition(current: Float, vp: Float, fb: Float): Float {
val scaledFb = (fb * scale)
val diff = (safeMax - safeMin) - scaledFb
val diff = vp - 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.
return if (diff >= 0) diff / 2 //Frame will be smaller than viewport, so center it
else current.coerceIn(diff, 0F) //otherwise, make sure viewport is completely filled.
}
}

View file

@ -78,6 +78,11 @@ class FrameView(context: Context?, attrs: AttributeSet? = null) : GLSurfaceView(
pointerIcon = PointerIcon.getSystemIcon(context, PointerIcon.TYPE_NULL)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
frameState.setViewportSize(w.toFloat(), h.toFloat())
}
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
outAttrs.imeOptions = outAttrs.imeOptions or
EditorInfo.IME_FLAG_NO_EXTRACT_UI or
@ -85,10 +90,6 @@ class FrameView(context: Context?, attrs: AttributeSet? = null) : GLSurfaceView(
return InputConnection()
}
override fun onCheckIsTextEditor(): Boolean {
return true
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
return touchHandler.onTouchEvent(event)

View file

@ -15,6 +15,7 @@ import androidx.fragment.app.activityViewModels
import com.gaurav.avnc.R
import com.gaurav.avnc.viewmodel.VncViewModel
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.util.*
/**
* This dialog is used to get user-confirmation before connecting to unknown SSH servers.
@ -30,7 +31,7 @@ class HostKeyFragment : DialogFragment() {
val message = """
|
|Host: ${hostKey.host}
|Key type: ${hostKey.algo.uppercase()}
|Key type: ${hostKey.algo.toUpperCase(Locale.ENGLISH)}
|Key fingerprint:
|
|${hostKey.getFingerprint()}

View file

@ -1,108 +0,0 @@
/*
* 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.gaurav.avnc.R
import com.gaurav.avnc.model.db.MainDb
import com.gaurav.avnc.vnc.VncUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* 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)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (intent.data?.scheme == "vnc")
launchFromVncUri(VncUri(intent.data!!))
else if (intent.hasExtra(SHORTCUT_PROFILE_ID_KEY))
launchFromProfileId(intent.getLongExtra(SHORTCUT_PROFILE_ID_KEY, 0))
else
handleUnknownIntent()
}
private 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)
finish()
}
private fun launchFromProfileName(name: String) {
val context = this
lifecycleScope.launch(Dispatchers.IO) {
val dao = MainDb.getInstance(context).serverProfileDao
val profile = dao.getByName(name).firstOrNull()
withContext(Dispatchers.Main) {
if (profile == null)
toast("No server found with name '$name'")
else
startVncActivity(context, profile)
finish()
}
}
}
private fun launchFromProfileId(profileId: Long) {
val context = this
lifecycleScope.launch(Dispatchers.IO) {
val dao = MainDb.getInstance(context).serverProfileDao
val profile = dao.getByID(profileId)
withContext(Dispatchers.Main) {
if (profile == null)
toast(getString(R.string.msg_shortcut_server_deleted))
else
startVncActivity(context, profile)
finish()
}
}
}
private fun handleUnknownIntent() {
toast("Invalid intent: Server info is missing!")
finish()
}
private fun toast(msg: String) = Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
}

View file

@ -16,7 +16,6 @@ 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
@ -26,10 +25,6 @@ import com.gaurav.avnc.vnc.XTKeyCode
* 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.
@ -42,14 +37,14 @@ import com.gaurav.avnc.vnc.XTKeyCode
*
* 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
* 2. 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]
* c. Current [compatMode]
*
*
*- [KeyEvent]
@ -83,7 +78,7 @@ import com.gaurav.avnc.vnc.XTKeyCode
* [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) {
class KeyHandler(private val dispatcher: Dispatcher, private val compatMode: Boolean, prefs: AppPreferences) {
/**
* Shortcut to send both up & down events. Useful for Virtual Keys.
@ -115,8 +110,8 @@ class KeyHandler(private val dispatcher: Dispatcher, private val cfLegacyKeysym:
@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_DOWN -> return emitForKeyEvent(event.keyCode, getUnicodeChar(event), true)
KeyEvent.ACTION_UP -> return emitForKeyEvent(event.keyCode, getUnicodeChar(event), false)
KeyEvent.ACTION_MULTIPLE -> {
if (event.keyCode == KeyEvent.KEYCODE_UNKNOWN) {
@ -143,11 +138,10 @@ class KeyHandler(private val dispatcher: Dispatcher, private val cfLegacyKeysym:
}
/**
* Emits an event for given details.
* Emits a KeySym for given event 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)
private fun emitForKeyEvent(keyCode: Int, unicodeChar: Int, isDown: Boolean): Boolean {
if (handleDiacritics(keyCode, unicodeChar, isDown))
return true
@ -159,7 +153,7 @@ class KeyHandler(private val dispatcher: Dispatcher, private val cfLegacyKeysym:
KeyEvent.KEYCODE_NUMPAD_ENTER,
KeyEvent.KEYCODE_SPACE,
KeyEvent.KEYCODE_TAB ->
return emitForAndroidKeyCode(keyCode, isDown, xtCode)
return emitForAndroidKeyCode(keyCode, isDown)
}
// We prefer to use unicodeChar even when keyCode is available because
@ -168,26 +162,26 @@ class KeyHandler(private val dispatcher: Dispatcher, private val cfLegacyKeysym:
// it works well with these servers.
if (unicodeChar != 0)
return emitForUnicodeChar(unicodeChar, isDown, xtCode)
return emitForUnicodeChar(unicodeChar, isDown)
else
return emitForAndroidKeyCode(keyCode, isDown, xtCode)
return emitForAndroidKeyCode(keyCode, isDown)
}
/**
* Emits X KeySym corresponding to [keyCode]
*/
private fun emitForAndroidKeyCode(keyCode: Int, isDown: Boolean, xtCode: Int = 0): Boolean {
private fun emitForAndroidKeyCode(keyCode: Int, isDown: Boolean): Boolean {
val keySym = XKeySymAndroid.getKeySymForAndroidKeyCode(keyCode)
return emit(keySym, isDown, xtCode)
return emit(keySym, isDown)
}
/**
* Emits either Unicode KeySym or legacy KeySym for [uChar], depending on [cfLegacyKeysym].
* Emits either Unicode KeySym or legacy KeySym for [uChar], depending on [compatMode].
*/
private fun emitForUnicodeChar(uChar: Int, isDown: Boolean, xtCode: Int = 0): Boolean {
private fun emitForUnicodeChar(uChar: Int, isDown: Boolean): Boolean {
var uKeySym = 0
if (cfLegacyKeysym)
if (compatMode)
uKeySym = XKeySymUnicode.getLegacyKeySymForUnicodeChar(uChar)
if (uKeySym == 0)
@ -202,7 +196,7 @@ class KeyHandler(private val dispatcher: Dispatcher, private val cfLegacyKeysym:
if (shouldFakeShift)
emitForAndroidKeyCode(KeyEvent.KEYCODE_SHIFT_LEFT, true)
emit(uKeySym, isDown, xtCode)
emit(uKeySym, isDown)
if (shouldFakeShift)
emitForAndroidKeyCode(KeyEvent.KEYCODE_SHIFT_LEFT, false)
@ -210,15 +204,14 @@ class KeyHandler(private val dispatcher: Dispatcher, private val cfLegacyKeysym:
return true
}
/**
* Sends given X key to [dispatcher].
* Sends given [keySym] to [dispatcher].
*/
private fun emit(keySym: Int, isDown: Boolean, xtCode: Int = 0): Boolean {
private fun emit(keySym: Int, isDown: Boolean): Boolean {
if (keySym == 0)
return false
return dispatcher.onXKey(keySym, xtCode, isDown)
return dispatcher.onXKeySym(keySym, isDown)
}
@ -251,7 +244,7 @@ class KeyHandler(private val dispatcher: Dispatcher, private val cfLegacyKeysym:
// 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
// the number keycode (e.g. KEYCODE_NUMPAD_7) first. And if apps doesn'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.
if ((keyCode in KeyEvent.KEYCODE_NUMPAD_0..KeyEvent.KEYCODE_NUMPAD_9
@ -347,12 +340,12 @@ class KeyHandler(private val dispatcher: Dispatcher, private val cfLegacyKeysym:
private fun toCodePoints(string: String): IntArray {
//Handle simple & most probable case
if (string.length == 1)
return cpCache.apply { this[0] = string[0].code }
return cpCache.apply { this[0] = string[0].toInt() }
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()
return string.map { it.toInt() }.toIntArray()
}
}

View file

@ -1,314 +0,0 @@
/*
* 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.*
import androidx.core.view.WindowInsetsCompat.Type
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

@ -1,170 +0,0 @@
/*
* 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.gaurav.avnc.R
import com.gaurav.avnc.databinding.FragmentCredentialBinding
import com.gaurav.avnc.model.LoginInfo
import com.gaurav.avnc.model.ServerProfile
import com.gaurav.avnc.viewmodel.VncViewModel
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
if (loginType == LoginInfo.Type.SSH_KEY_PASSWORD)
binding.passwordLayout.setHint(R.string.hint_key_password)
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 = l.password
}
}
private fun onOk() {
loginInfo.password = getRealPassword(loginInfo.password)
viewModel.loginInfoRequest.offerResponse(loginInfo)
if (binding.remember.isChecked)
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(t: VncViewModel.State?) {
if (t == VncViewModel.State.Connected) {
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() {
if (viewModel.pref.server.lockSavedServer)
return
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

@ -8,11 +8,9 @@
package com.gaurav.avnc.ui.vnc
import android.content.Context
import android.graphics.PointF
import android.os.Build
import android.view.*
import com.gaurav.avnc.util.SimpleOnGestureListener
import com.gaurav.avnc.viewmodel.VncViewModel
import com.gaurav.avnc.vnc.PointerButton
import kotlin.math.PI
@ -117,18 +115,17 @@ class TouchHandler(private val viewModel: VncViewModel, private val dispatcher:
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) {
if (event.isFromSource(InputDevice.SOURCE_STYLUS)) {
stylusGestureDetector.onTouchEvent(event)
return true
}
return false
}
inner class StylusGestureListener : SimpleOnGestureListener() {
inner class StylusGestureListener : GestureDetector.SimpleOnGestureListener() {
private var scrolling = false
override fun onDown(e: MotionEvent): Boolean {
override fun onDown(e: MotionEvent?): Boolean {
scrolling = false
return true
}
@ -148,7 +145,7 @@ class TouchHandler(private val viewModel: VncViewModel, private val dispatcher:
dispatcher.onStylusLongPress(e.point())
}
override fun checkedOnScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
override fun onScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
// Scrolling with stylus button pressed is currently used for scale gesture
if (e2.buttonState and MotionEvent.BUTTON_STYLUS_PRIMARY == 0) {
@ -166,17 +163,25 @@ class TouchHandler(private val viewModel: VncViewModel, private val dispatcher:
/****************************************************************************************
* Finger Gestures (and everything else beside mouse & stylus)
* Finger Gestures
*
* In addition to gestures supported by [GestureDetector] & [ScaleGestureDetector],
* we use [DragDetector] & [MultiFingerTapDetector] for more gestures.
*
****************************************************************************************/
private val scaleDetector = ScaleGestureDetector(viewModel.app, this).apply { isQuickScaleEnabled = false }
private val gestureDetector = GestureDetectorEx(viewModel.app, FingerGestureListener())
private val gestureDetector = GestureDetector(viewModel.app, this)
private val multiFingerTapDetector = MultiFingerTapDetector()
private val dragDetector = DragDetector()
private val swipeVsScale = SwipeVsScale()
private val longPressSwipeEnabled = viewModel.pref.input.gesture.longPressSwipeEnabled
private val dragEnabled = viewModel.pref.input.gesture.dragEnabled
private val swipeSensitivity = viewModel.pref.input.gesture.swipeSensitivity
private fun handleGestureEvent(event: MotionEvent): Boolean {
swipeVsScale.onTouchEvent(event)
dragDetector.onTouchEvent(event)
multiFingerTapDetector.onTouchEvent(event)
scaleDetector.onTouchEvent(event)
return gestureDetector.onTouchEvent(event)
}
@ -189,249 +194,163 @@ class TouchHandler(private val viewModel: VncViewModel, private val dispatcher:
return true
}
private inner class FingerGestureListener : GestureDetectorEx.GestureListenerEx {
override fun onDown(e: MotionEvent) = true
override fun onSingleTapConfirmed(e: MotionEvent) = dispatcher.onTap1(e.point())
override fun onDoubleTapConfirmed(e: MotionEvent) = dispatcher.onDoubleTap(e.point())
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
dispatcher.onTap1(e.point())
return true
}
override fun onMultiFingerTap(e: MotionEvent, fingerCount: Int) {
when (fingerCount) {
2 -> dispatcher.onTap2(e.point())
// Taps by 3+ fingers are not exposed yet
override fun onDoubleTap(e: MotionEvent): Boolean {
dispatcher.onDoubleTap(e.point())
return true
}
override fun onLongPress(e: MotionEvent) {
viewModel.frameViewRef.get()?.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
if (dragEnabled)
dragDetector.onLongPress(e)
else
dispatcher.onLongPress(e.point())
}
override fun onFling(e1: MotionEvent, e2: MotionEvent, vX: Float, vY: Float): Boolean {
dispatcher.onFling(vX, vY)
return true
}
override fun onScroll(e1: MotionEvent?, e2: MotionEvent, dX: Float, dY: Float): Boolean {
// As weird as it may be, e1 can be null on some devices
val startPoint = e1?.point() ?: return false
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)
}
multiFingerTapDetector.reset()
return true
}
/**
* Utility class for detecting drag gesture.
*
* Detection:
*
* 1. Wait for the long-press from [gestureDetector] before doing anything.
* 2. Once long-press is detected, start looking at incoming events.
* 3. If [MotionEvent.ACTION_UP] is received next, send long-press.
* 4. If scroll event is received from [scrollDetector], start dragging.
* 5. Reset, if gesture is finished, or another finger goes down.
*
* But, if long-press detection is enabled for a [GestureDetector] object, it will not report
* scroll events after long-press. So, we rely on [gestureDetector] for long-press detection,
* and use a separate object [scrollDetector] for scroll events.
*
* Left mouse button pressed during dragging is automatically released
* by [Dispatcher.onGestureStop].
*/
private inner class DragDetector {
private var longPressDetected = false
private var isDragging = false
private var startPoint = PointF()
private val scrollDetector = GestureDetector(viewModel.app, ScrollListener()).apply {
setIsLongpressEnabled(false)
}
private inner class ScrollListener : GestureDetector.SimpleOnGestureListener() {
override fun onScroll(e1: MotionEvent, e2: MotionEvent, dx: Float, dy: Float): Boolean {
if (longPressDetected) {
if (!isDragging) {
isDragging = true
//Send first drag event at startPoint, otherwise drag gesture
//will start off by (at-least) touch-slope used by GestureDetector
dispatcher.onDrag(startPoint, startPoint, 0f, 0f)
}
dispatcher.onDrag(startPoint, e2.point(), -dx, -dy)
}
return true
}
}
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())
fun onLongPress(e: MotionEvent) {
longPressDetected = true
startPoint = e.point()
}
override fun onLongPressConfirmed(e: MotionEvent) {
if (longPressSwipeEnabled) dispatcher.onLongPress(e.point())
}
fun onTouchEvent(event: MotionEvent) {
if (!dragEnabled)
return
scrollDetector.onTouchEvent(event)
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
val action = event.actionMasked
if (action == MotionEvent.ACTION_UP && longPressDetected && !isDragging)
dispatcher.onLongPress(event.point())
when (e2.pointerCount) {
1 -> dispatcher.onSwipe1(startPoint, currentPoint, normalizedDx, normalizedDy)
2 -> if (swipeVsScale.shouldSwipe())
dispatcher.onSwipe2(startPoint, currentPoint, normalizedDx, normalizedDy)
when (action) {
MotionEvent.ACTION_UP,
MotionEvent.ACTION_POINTER_DOWN,
MotionEvent.ACTION_CANCEL -> {
longPressDetected = false
isDragging = false
}
}
}
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.
* Detects 'tapping' by two or more fingers.
*
* [GestureDetectorEx] is used to for this purpose. It internally uses stock
* [GestureDetector], and some custom event processing to detect more gestures.
* Detection:
*
* 1. First finger goes down. We start tracking by updating [startEvent]
* 2. More fingers go down. [fingerCount] is used to track them
* 3. Fingers start going up
* 4. Last finger goes up. Timestamps are checked to ensure the gesture
* was finished within a timeout, and if more than 1 finger went down,
* appropriate handler is invoked.
*
* If fingers are moved after going down, user probably intends to pan/scale,
* so tap detection is stopped if we receive [onScroll].
*/
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 checkedOnScroll(e1: MotionEvent, e2: MotionEvent, dx: Float, dy: Float) = handleScroll(e1, e2, dx, dy)
}
private inner class InnerListener3 : SimpleOnGestureListener() {
override fun checkedOnScroll(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 {
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)
private inner class MultiFingerTapDetector {
private var startEvent: MotionEvent? = null
private var fingerCount = 0
fun onTouchEvent(e: MotionEvent) {
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)
}
MotionEvent.ACTION_DOWN -> startEvent = MotionEvent.obtain(e)
MotionEvent.ACTION_POINTER_DOWN -> fingerCount = max(fingerCount, e.pointerCount)
MotionEvent.ACTION_CANCEL -> reset()
MotionEvent.ACTION_UP -> startEvent?.let { startEvent ->
if ((e.eventTime - startEvent.eventTime) <= ViewConfiguration.getDoubleTapTimeout())
when (fingerCount) {
2 -> dispatcher.onTap2(startEvent.point())
// Taps by 3+ fingers are not exposed yet
}
reset()
}
MotionEvent.ACTION_CANCEL -> reset()
}
return true
}
private fun reset() {
longPressDetected = false
doubleTapDetected = false
scrolling = false
maxFingerDown = 0
currentDownEvent?.recycle()
currentDownEvent = null
fun reset() {
startEvent?.recycle()
startEvent = null
fingerCount = 0
}
}
/**
* Swipe vs Scale detector.
*
@ -482,8 +401,8 @@ class TouchHandler(private val viewModel: VncViewModel, private val dispatcher:
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))
f1Start.set(e.getX(f1Id), e.getY(f1Id))
f2Start.set(e.getX(f2Id), e.getY(f2Id))
f1Current.set(f1Start)
f2Current.set(f2Start)
}
@ -491,12 +410,8 @@ class TouchHandler(private val viewModel: VncViewModel, private val dispatcher:
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))
}
f1Current.set(e.getX(f1Id), e.getY(f1Id))
f2Current.set(e.getX(f2Id), e.getY(f2Id))
}
}
}

View file

@ -0,0 +1,47 @@
/*
* 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.Activity
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import com.gaurav.avnc.R
import com.gaurav.avnc.vnc.VncUri
/**
* Handles intents with 'vnc' URI.
*
* TODO:
* - Add a prompt before connecting.
* - Add ability to save connection info.
*/
class UriReceiverActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val uri = getUri()
if (uri.host.isEmpty())
Toast.makeText(this, R.string.msg_invalid_vnc_uri, Toast.LENGTH_LONG).show()
else
startVncActivity(this, uri)
finish()
}
private fun getUri(): VncUri {
if (intent.data?.scheme == "vnc") {
return VncUri(intent.data!!)
}
Log.e(javaClass.simpleName, "Invalid intent!")
return VncUri("")
}
}

View file

@ -8,10 +8,7 @@
package com.gaurav.avnc.ui.vnc
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import androidx.databinding.BindingAdapter
import com.gaurav.avnc.databinding.VirtualKeysBinding
/**
@ -27,20 +24,18 @@ class VirtualKeys(activity: VncActivity) {
private val stub = activity.binding.virtualKeysStub
private var openedWithKb = false
val container: View? get() = stub.root
fun show() {
init()
container?.visibility = View.VISIBLE
stub.root?.visibility = View.VISIBLE
}
fun hide() {
container?.visibility = View.GONE
stub.root?.visibility = View.GONE
openedWithKb = false //Reset flag
}
fun onKeyboardOpen() {
if (pref.vkOpenWithKeyboard && container?.visibility != View.VISIBLE) {
if (pref.vkOpenWithKeyboard && stub.root?.visibility != View.VISIBLE) {
show()
openedWithKb = true
}
@ -56,7 +51,6 @@ class VirtualKeys(activity: VncActivity) {
fun releaseMetaKeys() {
val binding = stub.binding as? VirtualKeysBinding
binding?.apply {
superBtn.isChecked = false
shiftBtn.isChecked = false
ctrlBtn.isChecked = false
altBtn.isChecked = false
@ -73,44 +67,4 @@ class VirtualKeys(activity: VncActivity) {
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())
}
}
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
}
})
}
}

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