Compare commits
162 commits
master
...
cepa-upstr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84d45032fa | ||
|
|
41c25ca049 | ||
|
|
d5dfdbdaaa | ||
|
|
66c9ee9095 | ||
|
|
52bc4cd6b5 | ||
|
|
e5d98d7d52 | ||
|
|
0d994b6fb5 | ||
|
|
37b4df9f4c | ||
|
|
1aedcb6dfa | ||
|
|
60fa2cee20 | ||
|
|
ca64c76316 | ||
|
|
ce5d64214c | ||
|
|
a801f29572 | ||
|
|
f2b994774f | ||
|
|
8b0e4f845c | ||
|
|
385e7b8aaf | ||
|
|
505abaa0b1 | ||
|
|
425e25d10f | ||
|
|
d04e4e1a4f | ||
|
|
8d1eeba474 | ||
|
|
7ec479d84a | ||
|
|
cbbe1d5f28 | ||
|
|
42b8fb8d79 | ||
|
|
8408af806a | ||
|
|
41b69fd996 | ||
|
|
681fd315f1 | ||
|
|
22bad9c320 | ||
|
|
8261a295a7 | ||
|
|
a774989053 | ||
|
|
f248789e31 | ||
|
|
e336d4d23d | ||
|
|
cb992d6396 | ||
|
|
65b1144b2e | ||
|
|
8169500562 | ||
|
|
4ebc1fa547 | ||
|
|
afd6fa53b7 | ||
|
|
c50a511743 | ||
|
|
9bfdc85a8e | ||
|
|
59765052b5 | ||
|
|
af5e6652f2 | ||
|
|
7e178bac0d | ||
|
|
fcebf87d98 | ||
|
|
53c09d064a | ||
|
|
836f33cf8a | ||
|
|
f58f4d49e7 | ||
|
|
b0dcdfd78b | ||
|
|
25827423eb | ||
|
|
e2abfdafcc | ||
|
|
53ac22d7c1 | ||
|
|
06e5407a0f | ||
|
|
4b118e853d | ||
|
|
bc0f472163 | ||
|
|
01a44963ec | ||
|
|
502a01c4a3 | ||
|
|
183e50f9cb | ||
|
|
e89bbc5243 | ||
|
|
b5fd61433c | ||
|
|
2b17828cd3 | ||
|
|
7e2f91e3c4 | ||
|
|
68af560709 | ||
|
|
ae31db2d31 | ||
|
|
4e4f795bb0 | ||
|
|
e4feff0488 | ||
|
|
d803aa469a | ||
|
|
bb81bec87b | ||
|
|
3e0c2e9808 | ||
|
|
5a2daeaaf0 | ||
|
|
6f6c12965a | ||
|
|
5308c9da81 | ||
|
|
b852387b5e | ||
|
|
d13efb088d | ||
|
|
d5755ef0fa | ||
|
|
875bf8f2a3 | ||
|
|
4469065c79 | ||
|
|
3fab34e7ca | ||
|
|
62836ec8b7 | ||
|
|
77cbb8303f | ||
|
|
dba5c3ebe8 | ||
|
|
bb9dc41f50 | ||
|
|
29fb39df00 | ||
|
|
87af8811fb | ||
|
|
e109989fc4 | ||
|
|
ceca58e78d | ||
|
|
b4bd5d7584 | ||
|
|
5e4ee94e45 | ||
|
|
e2487d1a2f | ||
|
|
926d50d371 | ||
|
|
6cfa44dc94 | ||
|
|
a65cb7af26 | ||
|
|
5c4e3701d6 | ||
|
|
7ef81be406 | ||
|
|
24d7e760ba | ||
|
|
9f8e13813b | ||
|
|
71f84b594b | ||
|
|
b66e76cf33 | ||
|
|
826f8c157b | ||
|
|
3624de1757 | ||
|
|
754d44c0c3 | ||
|
|
96e73237dd | ||
|
|
f0df38a4d4 | ||
|
|
3cac561413 | ||
|
|
2eac4cbac7 | ||
|
|
1c2a1009d0 | ||
|
|
6189f4a741 | ||
|
|
f384e0ba59 | ||
|
|
e4030d706f | ||
|
|
c36e02cca7 | ||
|
|
8cc8085816 | ||
|
|
cc2b5600fd | ||
|
|
8eae95ddd2 | ||
|
|
ac9a4ba248 | ||
|
|
666c4d5592 | ||
|
|
1a1815cdb5 | ||
|
|
4ffce9d1cb | ||
|
|
577fdd1344 | ||
|
|
c2e577e905 | ||
|
|
2fc764d42a | ||
|
|
32c1e18823 | ||
|
|
d21e074fe7 | ||
|
|
0793a1f8c9 | ||
|
|
3a5cdea968 | ||
|
|
d1b7186036 | ||
|
|
453f923f69 | ||
|
|
f7d8a4cf9e | ||
|
|
95e8d60fc1 | ||
|
|
62579d30ae | ||
|
|
b0c3e40876 | ||
|
|
9bc5d7fd5c | ||
|
|
fdf7b35a53 | ||
|
|
1ba5374a8c | ||
|
|
7935ed9cb2 | ||
|
|
e7007cce4f | ||
|
|
581572b6d0 | ||
|
|
d871c3a35e | ||
|
|
c238fba911 | ||
|
|
7712640d7b | ||
|
|
97d7809ff9 | ||
|
|
a6c87749c4 | ||
|
|
d70bec02bf | ||
|
|
a668486214 | ||
|
|
5113b182c6 | ||
|
|
7e82923e19 | ||
|
|
df37ba0a9f | ||
|
|
fdc090030d | ||
|
|
9e1ba4009c | ||
|
|
e70bcc070a | ||
|
|
6daa5f8ba3 | ||
|
|
ea62c5d54e | ||
|
|
468c73a43c | ||
|
|
dc57295ebb | ||
|
|
5332c2c24c | ||
|
|
faf1cb57e7 | ||
|
|
26b1f02bb9 | ||
|
|
b73f6bd7b8 | ||
|
|
c715fdfec6 | ||
|
|
a487e4dd94 | ||
|
|
31148fdcd3 | ||
|
|
cb84cb2712 | ||
|
|
75e0d4324a | ||
|
|
4dbfaf4a71 | ||
|
|
2e1a87b752 | ||
|
|
611a34b8b2 |
2
.github/FUNDING.yml
vendored
|
|
@ -1,2 +1,2 @@
|
|||
liberapay: gujjwal00
|
||||
github: gujjwal00
|
||||
custom: ["https://paypal.me/gujjwal00"]
|
||||
|
|
|
|||
15
.github/workflows/main.yml
vendored
|
|
@ -1,4 +1,4 @@
|
|||
name: Android CI
|
||||
name: Main
|
||||
|
||||
on: push
|
||||
|
||||
|
|
@ -9,13 +9,14 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: true
|
||||
- name: set up JDK 1.8
|
||||
uses: actions/setup-java@v1
|
||||
- name: set up JDK
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: 1.8
|
||||
distribution: 'temurin'
|
||||
java-version: '11'
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
- name: Build with Gradle
|
||||
|
|
@ -28,10 +29,10 @@ jobs:
|
|||
working-directory: ./app/build/outputs/apk/ci/
|
||||
run: |
|
||||
echo $CI_SIGNING_KS | base64 --decode > keystore.jks
|
||||
$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'
|
||||
$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'
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v3.1.1
|
||||
with:
|
||||
name: avnc-ci
|
||||
path: app/build/outputs/apk/ci/app-ci.apk
|
||||
13
.github/workflows/pr.yml
vendored
|
|
@ -1,4 +1,4 @@
|
|||
name: Android CI
|
||||
name: Pull Requests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
|
@ -11,19 +11,20 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: true
|
||||
- name: set up JDK 1.8
|
||||
uses: actions/setup-java@v1
|
||||
- name: set up JDK
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: 1.8
|
||||
distribution: 'temurin'
|
||||
java-version: '11'
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew assembleDebug
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v3.1.1
|
||||
with:
|
||||
name: avnc-debug
|
||||
path: app/build/outputs/apk/debug/*.apk
|
||||
7
.gitignore
vendored
|
|
@ -39,3 +39,10 @@ gastest.o
|
|||
|
||||
#Private data
|
||||
private/
|
||||
|
||||
# Release APK
|
||||
app/release/
|
||||
|
||||
# Proguard generated files
|
||||
app/seeds.txt
|
||||
app/usage.txt
|
||||
|
|
|
|||
5
.idea/codeStyles/Project.xml
generated
|
|
@ -4,6 +4,9 @@
|
|||
<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" />
|
||||
|
|
@ -121,9 +124,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>
|
||||
|
|
|
|||
11
Readme.md
|
|
@ -21,9 +21,8 @@
|
|||
- 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://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)
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
|
@ -50,7 +49,7 @@ Tools required:
|
|||
To get started, simply clone the repo and initialize submodules:
|
||||
|
||||
```bash
|
||||
git clone git@github.com:gujjwal00/avnc.git
|
||||
git clone https://github.com/gujjwal00/avnc.git
|
||||
cd avnc
|
||||
git submodule update --init --depth 1
|
||||
```
|
||||
|
|
@ -61,5 +60,7 @@ 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/)
|
||||
|
|
|
|||
|
|
@ -77,10 +77,11 @@ 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} ${AVNC_LIBVNC_BUILD_DIR})
|
||||
include_directories(${AVNC_LIBVNC_SRC_DIR}/include ${AVNC_LIBVNC_BUILD_DIR}/include)
|
||||
|
||||
|
||||
###############################################################################
|
||||
|
|
|
|||
109
app/build.gradle
|
|
@ -1,18 +1,21 @@
|
|||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'kotlinx-serialization'
|
||||
apply plugin: 'kotlin-parcelize'
|
||||
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"
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion 30
|
||||
compileSdkVersion 33
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.gaurav.avnc"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 30
|
||||
applicationId "com.ldc.kiosk"
|
||||
minSdkVersion 30
|
||||
targetSdkVersion 33
|
||||
versionCode 14
|
||||
versionName "1.9.2"
|
||||
//versionName "2.2.3"
|
||||
versionName "1.0.1"
|
||||
|
||||
javaCompileOptions {
|
||||
annotationProcessorOptions {
|
||||
|
|
@ -31,6 +34,10 @@ android {
|
|||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
androidTest.assets.srcDirs += files("$projectDir/roomSchema".toString())
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
debug {
|
||||
|
|
@ -53,16 +60,35 @@ 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')
|
||||
}
|
||||
}
|
||||
|
|
@ -78,34 +104,51 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
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.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.dynamicanimation:dynamicanimation:1.0.0"
|
||||
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.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.drawerlayout:drawerlayout:1.1.1"
|
||||
implementation "androidx.room:room-runtime:2.3.0"
|
||||
implementation "androidx.room:room-ktx:2.3.0"
|
||||
kapt "androidx.room:room-compiler:2.3.0"
|
||||
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 "com.google.android.material:material:1.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 "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.1.0"
|
||||
// Json
|
||||
implementation "com.networknt:json-schema-validator:1.0.70"
|
||||
|
||||
implementation "org.connectbot:sshlib:2.2.19"
|
||||
kapt "androidx.room:room-compiler:2.4.3"
|
||||
implementation "com.google.guava:guava:30.0-android"
|
||||
|
||||
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"
|
||||
|
||||
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"
|
||||
androidTestImplementation "io.mockk:mockk-android:1.12.0"
|
||||
|
||||
implementation("com.squareup.okhttp3:okhttp:4.10.0")
|
||||
implementation("org.apache.commons:commons-csv:1.5")
|
||||
}
|
||||
BIN
app/libs/com.serialIntCaster-1.1.0-javadoc.jar
Normal file
BIN
app/libs/com.serialIntCaster-1.1.0.jar
Normal file
22
app/proguard-rules.pro
vendored
|
|
@ -1,24 +1,2 @@
|
|||
-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(...);
|
||||
}
|
||||
188
app/roomSchema/com.gaurav.avnc.model.db.MainDb/2.json
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 2,
|
||||
"identityHash": "d54a9ffbaa53dfe8f4ce8f5708a719ae",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "profiles",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ID` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `host` TEXT NOT NULL, `port` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `securityType` INTEGER NOT NULL, `channelType` INTEGER NOT NULL, `colorLevel` INTEGER NOT NULL, `imageQuality` INTEGER NOT NULL, `useRawEncoding` INTEGER NOT NULL DEFAULT 0, `zoom1` REAL NOT NULL DEFAULT 1.0, `zoom2` REAL NOT NULL DEFAULT 1.0, `viewOnly` INTEGER NOT NULL, `useLocalCursor` INTEGER NOT NULL, `keyCompatMode` INTEGER NOT NULL, `gestureStyle` TEXT NOT NULL DEFAULT 'auto', `useRepeater` INTEGER NOT NULL, `idOnRepeater` INTEGER NOT NULL, `sshHost` TEXT NOT NULL, `sshPort` INTEGER NOT NULL, `sshUsername` TEXT NOT NULL, `sshAuthType` INTEGER NOT NULL, `sshPassword` TEXT NOT NULL, `sshPrivateKey` TEXT NOT NULL, `sshPrivateKeyPassword` TEXT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "ID",
|
||||
"columnName": "ID",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "host",
|
||||
"columnName": "host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "port",
|
||||
"columnName": "port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "username",
|
||||
"columnName": "username",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "password",
|
||||
"columnName": "password",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "securityType",
|
||||
"columnName": "securityType",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "channelType",
|
||||
"columnName": "channelType",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "colorLevel",
|
||||
"columnName": "colorLevel",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imageQuality",
|
||||
"columnName": "imageQuality",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "useRawEncoding",
|
||||
"columnName": "useRawEncoding",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "zoom1",
|
||||
"columnName": "zoom1",
|
||||
"affinity": "REAL",
|
||||
"notNull": true,
|
||||
"defaultValue": "1.0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "zoom2",
|
||||
"columnName": "zoom2",
|
||||
"affinity": "REAL",
|
||||
"notNull": true,
|
||||
"defaultValue": "1.0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "viewOnly",
|
||||
"columnName": "viewOnly",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "useLocalCursor",
|
||||
"columnName": "useLocalCursor",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "keyCompatMode",
|
||||
"columnName": "keyCompatMode",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "gestureStyle",
|
||||
"columnName": "gestureStyle",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'auto'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "useRepeater",
|
||||
"columnName": "useRepeater",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "idOnRepeater",
|
||||
"columnName": "idOnRepeater",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sshHost",
|
||||
"columnName": "sshHost",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sshPort",
|
||||
"columnName": "sshPort",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sshUsername",
|
||||
"columnName": "sshUsername",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sshAuthType",
|
||||
"columnName": "sshAuthType",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sshPassword",
|
||||
"columnName": "sshPassword",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sshPrivateKey",
|
||||
"columnName": "sshPrivateKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sshPrivateKeyPassword",
|
||||
"columnName": "sshPrivateKeyPassword",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"ID"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd54a9ffbaa53dfe8f4ce8f5708a719ae')"
|
||||
]
|
||||
}
|
||||
}
|
||||
209
app/roomSchema/com.gaurav.avnc.model.db.MainDb/3.json
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 3,
|
||||
"identityHash": "eb32c7692bb75a6297413b807713616c",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "profiles",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ID` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `host` TEXT NOT NULL, `port` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `securityType` INTEGER NOT NULL, `channelType` INTEGER NOT NULL, `colorLevel` INTEGER NOT NULL, `imageQuality` INTEGER NOT NULL, `useRawEncoding` INTEGER NOT NULL DEFAULT 0, `zoom1` REAL NOT NULL DEFAULT 1.0, `zoom2` REAL NOT NULL DEFAULT 1.0, `viewOnly` INTEGER NOT NULL, `useLocalCursor` INTEGER NOT NULL, `serverTypeHint` TEXT NOT NULL DEFAULT '', `compatFlags` INTEGER NOT NULL, `gestureStyle` TEXT NOT NULL DEFAULT 'auto', `screenOrientation` TEXT NOT NULL DEFAULT 'auto', `shortcutRank` INTEGER NOT NULL DEFAULT 0, `useRepeater` INTEGER NOT NULL, `idOnRepeater` INTEGER NOT NULL, `sshHost` TEXT NOT NULL, `sshPort` INTEGER NOT NULL, `sshUsername` TEXT NOT NULL, `sshAuthType` INTEGER NOT NULL, `sshPassword` TEXT NOT NULL, `sshPrivateKey` TEXT NOT NULL, `sshPrivateKeyPassword` TEXT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "ID",
|
||||
"columnName": "ID",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "host",
|
||||
"columnName": "host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "port",
|
||||
"columnName": "port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "username",
|
||||
"columnName": "username",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "password",
|
||||
"columnName": "password",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "securityType",
|
||||
"columnName": "securityType",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "channelType",
|
||||
"columnName": "channelType",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "colorLevel",
|
||||
"columnName": "colorLevel",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imageQuality",
|
||||
"columnName": "imageQuality",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "useRawEncoding",
|
||||
"columnName": "useRawEncoding",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "zoom1",
|
||||
"columnName": "zoom1",
|
||||
"affinity": "REAL",
|
||||
"notNull": true,
|
||||
"defaultValue": "1.0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "zoom2",
|
||||
"columnName": "zoom2",
|
||||
"affinity": "REAL",
|
||||
"notNull": true,
|
||||
"defaultValue": "1.0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "viewOnly",
|
||||
"columnName": "viewOnly",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "useLocalCursor",
|
||||
"columnName": "useLocalCursor",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serverTypeHint",
|
||||
"columnName": "serverTypeHint",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "compatFlags",
|
||||
"columnName": "compatFlags",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "gestureStyle",
|
||||
"columnName": "gestureStyle",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'auto'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "screenOrientation",
|
||||
"columnName": "screenOrientation",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'auto'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "shortcutRank",
|
||||
"columnName": "shortcutRank",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "useRepeater",
|
||||
"columnName": "useRepeater",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "idOnRepeater",
|
||||
"columnName": "idOnRepeater",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sshHost",
|
||||
"columnName": "sshHost",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sshPort",
|
||||
"columnName": "sshPort",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sshUsername",
|
||||
"columnName": "sshUsername",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sshAuthType",
|
||||
"columnName": "sshAuthType",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sshPassword",
|
||||
"columnName": "sshPassword",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sshPrivateKey",
|
||||
"columnName": "sshPrivateKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sshPrivateKeyPassword",
|
||||
"columnName": "sshPrivateKeyPassword",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"ID"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'eb32c7692bb75a6297413b807713616c')"
|
||||
]
|
||||
}
|
||||
}
|
||||
216
app/roomSchema/com.gaurav.avnc.model.db.MainDb/4.json
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 4,
|
||||
"identityHash": "5ff9de8e52fb13b10ee86f9c75714cd5",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "profiles",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ID` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `host` TEXT NOT NULL, `port` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `securityType` INTEGER NOT NULL, `channelType` INTEGER NOT NULL, `colorLevel` INTEGER NOT NULL, `imageQuality` INTEGER NOT NULL, `useRawEncoding` INTEGER NOT NULL DEFAULT 0, `zoom1` REAL NOT NULL DEFAULT 1.0, `zoom2` REAL NOT NULL DEFAULT 1.0, `viewOnly` INTEGER NOT NULL, `useLocalCursor` INTEGER NOT NULL, `serverTypeHint` TEXT NOT NULL DEFAULT '', `compatFlags` INTEGER NOT NULL, `gestureStyle` TEXT NOT NULL DEFAULT 'auto', `screenOrientation` TEXT NOT NULL DEFAULT 'auto', `shortcutRank` INTEGER NOT NULL DEFAULT 0, `useRepeater` INTEGER NOT NULL, `idOnRepeater` INTEGER NOT NULL, `resizeRemoteDesktop` INTEGER NOT NULL DEFAULT 0, `sshHost` TEXT NOT NULL, `sshPort` INTEGER NOT NULL, `sshUsername` TEXT NOT NULL, `sshAuthType` INTEGER NOT NULL, `sshPassword` TEXT NOT NULL, `sshPrivateKey` TEXT NOT NULL, `sshPrivateKeyPassword` TEXT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "ID",
|
||||
"columnName": "ID",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "host",
|
||||
"columnName": "host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "port",
|
||||
"columnName": "port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "username",
|
||||
"columnName": "username",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "password",
|
||||
"columnName": "password",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "securityType",
|
||||
"columnName": "securityType",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "channelType",
|
||||
"columnName": "channelType",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "colorLevel",
|
||||
"columnName": "colorLevel",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imageQuality",
|
||||
"columnName": "imageQuality",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "useRawEncoding",
|
||||
"columnName": "useRawEncoding",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "zoom1",
|
||||
"columnName": "zoom1",
|
||||
"affinity": "REAL",
|
||||
"notNull": true,
|
||||
"defaultValue": "1.0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "zoom2",
|
||||
"columnName": "zoom2",
|
||||
"affinity": "REAL",
|
||||
"notNull": true,
|
||||
"defaultValue": "1.0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "viewOnly",
|
||||
"columnName": "viewOnly",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "useLocalCursor",
|
||||
"columnName": "useLocalCursor",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serverTypeHint",
|
||||
"columnName": "serverTypeHint",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "compatFlags",
|
||||
"columnName": "compatFlags",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "gestureStyle",
|
||||
"columnName": "gestureStyle",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'auto'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "screenOrientation",
|
||||
"columnName": "screenOrientation",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'auto'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "shortcutRank",
|
||||
"columnName": "shortcutRank",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "useRepeater",
|
||||
"columnName": "useRepeater",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "idOnRepeater",
|
||||
"columnName": "idOnRepeater",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "resizeRemoteDesktop",
|
||||
"columnName": "resizeRemoteDesktop",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "sshHost",
|
||||
"columnName": "sshHost",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sshPort",
|
||||
"columnName": "sshPort",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sshUsername",
|
||||
"columnName": "sshUsername",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sshAuthType",
|
||||
"columnName": "sshAuthType",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sshPassword",
|
||||
"columnName": "sshPassword",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sshPrivateKey",
|
||||
"columnName": "sshPrivateKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sshPrivateKeyPassword",
|
||||
"columnName": "sshPrivateKeyPassword",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"ID"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5ff9de8e52fb13b10ee86f9c75714cd5')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
package com.gaurav.avnc
|
||||
|
||||
import com.gaurav.avnc.model.db.MainDb
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.rules.ExternalResource
|
||||
|
||||
/**
|
||||
|
|
@ -20,6 +21,6 @@ class DatabaseRule : ExternalResource() {
|
|||
val db by lazy { MainDb.getInstance(targetContext) }
|
||||
|
||||
override fun before() {
|
||||
db.serverProfileDao.deleteAll()
|
||||
runBlocking { db.serverProfileDao.deleteAll() }
|
||||
}
|
||||
}
|
||||
|
|
@ -8,22 +8,40 @@
|
|||
|
||||
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 androidx.test.core.app.ApplicationProvider
|
||||
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.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 com.gaurav.avnc.viewmodel.BaseViewModel
|
||||
import junit.framework.AssertionFailedError
|
||||
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
|
||||
|
|
@ -33,7 +51,8 @@ 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))!!
|
||||
fun ViewInteraction.doTypeText(text: String) = perform(typeText(text)).perform(closeSoftKeyboard())!!
|
||||
fun ViewInteraction.inDialog() = inRoot(RootMatchers.isDialog())!!
|
||||
|
||||
|
||||
/**
|
||||
|
|
@ -59,11 +78,66 @@ 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 {
|
||||
// Reusing BaseViewModel to access clipboard
|
||||
text = BaseViewModel(ApplicationProvider.getApplicationContext<App>()).getClipboardText()
|
||||
(targetContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).let {
|
||||
text = it.primaryClip?.getItemAt(0)?.text?.toString()
|
||||
}
|
||||
}
|
||||
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) }
|
||||
|
|
@ -8,7 +8,6 @@
|
|||
|
||||
package com.gaurav.avnc
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.InputStream
|
||||
import java.net.ServerSocket
|
||||
import java.nio.ByteBuffer
|
||||
|
|
@ -39,7 +38,7 @@ class TestServer(name: String = "Friends") {
|
|||
//Server config
|
||||
private val ss = ServerSocket(0)
|
||||
private val cutTextQueue = LinkedTransferQueue<String>()
|
||||
private lateinit var serverJob: Job
|
||||
private val serverJob = Thread { theServer() }
|
||||
val host = ss.inetAddress.hostAddress!!
|
||||
val port = ss.localPort
|
||||
var receivedKeySyms = arrayListOf<Int>()
|
||||
|
|
@ -47,11 +46,11 @@ class TestServer(name: String = "Friends") {
|
|||
|
||||
|
||||
fun start() {
|
||||
serverJob = GlobalScope.launch(Dispatchers.IO) { theServer() }
|
||||
serverJob.start()
|
||||
}
|
||||
|
||||
fun awaitStop() {
|
||||
runBlocking { serverJob.join() }
|
||||
serverJob.join(30_000)
|
||||
}
|
||||
|
||||
fun sendCutText(str: String) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,16 +23,24 @@ 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)
|
||||
|
|
@ -53,13 +61,8 @@ class DiscoveryListTest {
|
|||
|
||||
@Before
|
||||
fun before() {
|
||||
// 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()
|
||||
onView(withContentDescription(R.string.desc_discovered_servers_tab)).doClick()
|
||||
onView(withContentDescription(R.string.desc_discovery_btn)).checkWithTimeout(matches(isCompletelyDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -8,8 +8,11 @@
|
|||
|
||||
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
|
||||
|
|
@ -17,12 +20,14 @@ 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 ProfileEditorTest {
|
||||
class BasicEditorTest {
|
||||
|
||||
private val testProfile = ServerProfile(name = "foo", host = "bar")
|
||||
|
||||
|
|
@ -68,6 +73,70 @@ class ProfileEditorTest {
|
|||
hasDescendant(withText(testProfile.host)))
|
||||
).checkWillBeDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
//TODO add more tests
|
||||
@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()
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ package com.gaurav.avnc.ui.home
|
|||
|
||||
import android.app.Activity
|
||||
import android.app.Instrumentation
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.core.content.edit
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.assertion.PositionAssertions.isCompletelyAbove
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
|
|
@ -23,6 +23,7 @@ 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
|
||||
|
|
@ -56,7 +57,7 @@ class ServerListTest {
|
|||
|
||||
@Before
|
||||
fun before() {
|
||||
dbRule.db.serverProfileDao.insert(testProfile)
|
||||
runBlocking { dbRule.db.serverProfileDao.insert(testProfile) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -110,21 +111,21 @@ class ServerListTest {
|
|||
|
||||
@Test
|
||||
fun sortServers() {
|
||||
val prefEditor = PreferenceManager.getDefaultSharedPreferences(targetContext).edit()
|
||||
|
||||
with(dbRule.db.serverProfileDao) {
|
||||
deleteAll()
|
||||
insert(ServerProfile(name = "pqr"))
|
||||
insert(ServerProfile(name = "abc"))
|
||||
runBlocking {
|
||||
deleteAll()
|
||||
insert(ServerProfile(name = "pqr"))
|
||||
insert(ServerProfile(name = "abc"))
|
||||
}
|
||||
}
|
||||
|
||||
//Without sorting, "pqr" should be above "abc", as it was inserted first
|
||||
prefEditor.putBoolean("sort_server_list", false).apply()
|
||||
targetPrefs.edit { putBoolean("sort_server_list", false) }
|
||||
onView(withText("pqr")).checkWithTimeout(isCompletelyAbove(withText("abc")))
|
||||
|
||||
|
||||
//With sorting, "abc" should be above "pqr"
|
||||
prefEditor.putBoolean("sort_server_list", true).commit()
|
||||
targetPrefs.edit { putBoolean("sort_server_list", true) }
|
||||
onView(withText("abc")).checkWithTimeout(isCompletelyAbove(withText("pqr")))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* 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())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,13 @@
|
|||
/*
|
||||
* Copyright (c) 2020 Gaurav Ujjwal.
|
||||
* Copyright (c) 2023 Gaurav Ujjwal.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* See COPYING.txt for more details.
|
||||
*/
|
||||
|
||||
package com.gaurav.avnc.ui
|
||||
package com.gaurav.avnc.ui.vnc
|
||||
|
||||
import com.gaurav.avnc.ui.vnc.FrameState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
|
@ -110,6 +109,39 @@ class FrameStateTest {
|
|||
assertEquals(.5F, state.baseScale)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun perOrientationZoomScaleEnabledTest() {
|
||||
val state = FrameState(usePerOrientationZoom = true)
|
||||
|
||||
state.setWindowSize(100f, 50f)
|
||||
state.updateZoom(2f)
|
||||
assertEquals(2F, state.zoomScale)
|
||||
|
||||
//Zoom scale should be separate for each orientation
|
||||
state.setWindowSize(50f, 100f)
|
||||
assertEquals(1f, state.zoomScale)
|
||||
|
||||
state.updateZoom(5f)
|
||||
assertEquals(5f, state.zoomScale)
|
||||
|
||||
//Restore orientation
|
||||
state.setWindowSize(100f, 50f)
|
||||
assertEquals(2f, state.zoomScale)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun perOrientationZoomScaleDisabledTest() {
|
||||
val state = FrameState(usePerOrientationZoom = false)
|
||||
|
||||
state.setWindowSize(100f, 50f)
|
||||
state.updateZoom(2f)
|
||||
assertEquals(2F, state.zoomScale)
|
||||
|
||||
//Zoom scale should be same in all orientations
|
||||
state.setWindowSize(50f, 100f)
|
||||
assertEquals(2f, state.zoomScale)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun positionCoerceTest1() {
|
||||
val state = FrameState()
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,8 @@ 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() {
|
||||
|
|
@ -42,10 +44,19 @@ class KeyHandlerTest {
|
|||
|
||||
dispatchedKeyDowns = arrayListOf()
|
||||
dispatchedKeyUps = arrayListOf()
|
||||
dispatchedXTDowns = arrayListOf()
|
||||
dispatchedXTUps = arrayListOf()
|
||||
mockDispatcher = mockk()
|
||||
every { mockDispatcher.onXKeySym(any(), true) } answers { dispatchedKeyDowns.add(firstArg()); true }
|
||||
every { mockDispatcher.onXKeySym(any(), false) } answers { dispatchedKeyUps.add(firstArg()); true }
|
||||
|
||||
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
|
||||
}
|
||||
keyHandler = KeyHandler(mockDispatcher, true, prefs)
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +88,7 @@ class KeyHandlerTest {
|
|||
}
|
||||
|
||||
private fun sendKey(keyCode: Int, uChar: Char) {
|
||||
sendKey(keyCode, uChar.toInt())
|
||||
sendKey(keyCode, uChar.code)
|
||||
}
|
||||
|
||||
private fun sendAccent(accent: Int) {
|
||||
|
|
@ -91,60 +102,73 @@ 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'.toInt(), dispatchedKeyDowns.firstOrNull())
|
||||
assertEquals('a'.code, dispatchedKeyDowns.firstOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun charWithShift() {
|
||||
sendKeyWithMeta(KeyEvent.KEYCODE_A, KeyEvent.META_SHIFT_ON)
|
||||
assertEquals('A'.toInt(), dispatchedKeyDowns.firstOrNull())
|
||||
assertEquals('A'.code, dispatchedKeyDowns.firstOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun charWithShiftCtrl() {
|
||||
sendKeyWithMeta(KeyEvent.KEYCODE_A, KeyEvent.META_SHIFT_ON or KeyEvent.META_CTRL_ON)
|
||||
assertEquals('A'.toInt(), dispatchedKeyDowns.firstOrNull())
|
||||
assertEquals('A'.code, 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'.toInt(), dispatchedKeyDowns.firstOrNull())
|
||||
assertEquals('A'.code, dispatchedKeyDowns.firstOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun charWithCapslock() {
|
||||
sendKeyWithMeta(KeyEvent.KEYCODE_A, KeyEvent.META_CAPS_LOCK_ON)
|
||||
assertEquals('A'.toInt(), dispatchedKeyDowns.firstOrNull())
|
||||
assertEquals('A'.code, 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'.toInt(), dispatchedKeys.firstOrNull())
|
||||
assertEquals('a'.code, dispatchedKeys.firstOrNull())
|
||||
}*/
|
||||
|
||||
@Test
|
||||
fun charWithCapslockCtrl() {
|
||||
sendKeyWithMeta(KeyEvent.KEYCODE_A, KeyEvent.META_CAPS_LOCK_ON or KeyEvent.META_CTRL_ON)
|
||||
assertEquals('A'.toInt(), dispatchedKeyDowns.firstOrNull())
|
||||
assertEquals('A'.code, dispatchedKeyDowns.firstOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun charWithCapslockAlt() {
|
||||
sendKeyWithMeta(KeyEvent.KEYCODE_A, KeyEvent.META_CAPS_LOCK_ON or KeyEvent.META_ALT_ON)
|
||||
assertEquals('A'.toInt(), dispatchedKeyDowns.firstOrNull())
|
||||
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])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun numpadWithNumlock() {
|
||||
sendKeyWithMeta(KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.META_NUM_LOCK_ON)
|
||||
assertEquals('1'.toInt(), dispatchedKeyDowns.firstOrNull())
|
||||
assertEquals('1'.code, dispatchedKeyDowns.firstOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -159,6 +183,23 @@ 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
|
||||
|
|
@ -188,7 +229,7 @@ class KeyHandlerTest {
|
|||
fun diacriticTest_charAfterAccent() {
|
||||
sendAccent(ACCENT_TILDE)
|
||||
sendKey(KeyEvent.KEYCODE_A, 'a')
|
||||
assertEquals('ã'.toInt(), dispatchedKeyDowns.firstOrNull())
|
||||
assertEquals('ã'.code, dispatchedKeyDowns.firstOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -196,7 +237,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('ã'.toInt(), dispatchedKeyDowns.firstOrNull())
|
||||
assertEquals('ã'.code, dispatchedKeyDowns.firstOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -204,30 +245,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'.toInt())
|
||||
sendDown(KeyEvent.KEYCODE_A, 'a'.code)
|
||||
sendDown(0, ACCENT_TILDE or KeyCharacterMap.COMBINING_ACCENT)
|
||||
sendUp(KeyEvent.KEYCODE_A, 'a'.toInt())
|
||||
sendUp(KeyEvent.KEYCODE_A, 'a'.code)
|
||||
sendUp(0, ACCENT_TILDE or KeyCharacterMap.COMBINING_ACCENT)
|
||||
sendDown(KeyEvent.KEYCODE_A, 'a'.toInt())
|
||||
sendUp(KeyEvent.KEYCODE_A, 'a'.toInt())
|
||||
sendDown(KeyEvent.KEYCODE_A, 'a'.code)
|
||||
sendUp(KeyEvent.KEYCODE_A, 'a'.code)
|
||||
|
||||
assertEquals('a'.toInt(), dispatchedKeyDowns[0]) // First should be normal, as accent was pressed later
|
||||
assertEquals('ã'.toInt(), dispatchedKeyDowns[1]) // Second should be accented
|
||||
assertEquals('a'.code, dispatchedKeyDowns[0]) // First should be normal, as accent was pressed later
|
||||
assertEquals('ã'.code, dispatchedKeyDowns[1]) // Second should be accented
|
||||
}
|
||||
|
||||
@Test
|
||||
fun diacriticTest_interMixedUpDowns2() {
|
||||
// Another variation of intermixed up/downs
|
||||
|
||||
sendDown(KeyEvent.KEYCODE_A, 'a'.toInt())
|
||||
sendDown(KeyEvent.KEYCODE_A, 'a'.code)
|
||||
sendDown(0, ACCENT_TILDE or KeyCharacterMap.COMBINING_ACCENT)
|
||||
sendUp(KeyEvent.KEYCODE_A, 'a'.toInt())
|
||||
sendDown(KeyEvent.KEYCODE_A, 'a'.toInt())
|
||||
sendUp(KeyEvent.KEYCODE_A, 'a'.toInt())
|
||||
sendUp(KeyEvent.KEYCODE_A, 'a'.code)
|
||||
sendDown(KeyEvent.KEYCODE_A, 'a'.code)
|
||||
sendUp(KeyEvent.KEYCODE_A, 'a'.code)
|
||||
sendUp(0, ACCENT_TILDE or KeyCharacterMap.COMBINING_ACCENT)
|
||||
|
||||
assertEquals('a'.toInt(), dispatchedKeyDowns[0])
|
||||
assertEquals('ã'.toInt(), dispatchedKeyDowns[1])
|
||||
assertEquals('a'.code, dispatchedKeyDowns[0])
|
||||
assertEquals('ã'.code, dispatchedKeyDowns[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -235,15 +276,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('ã'.toInt(), dispatchedKeyDowns[0])
|
||||
assertEquals('a'.toInt(), dispatchedKeyDowns[1])
|
||||
assertEquals('ã'.code, dispatchedKeyDowns[0])
|
||||
assertEquals('a'.code, dispatchedKeyDowns[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun diacriticTest_capitalCharAfterAccent() {
|
||||
sendAccent(ACCENT_TILDE)
|
||||
sendKey(KeyEvent.KEYCODE_A, 'A')
|
||||
assertEquals('Ã'.toInt(), dispatchedKeyDowns.firstOrNull())
|
||||
assertEquals('Ã'.code, dispatchedKeyDowns.firstOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -251,7 +292,7 @@ class KeyHandlerTest {
|
|||
sendAccent(ACCENT_CIRCUMFLEX)
|
||||
sendAccent(ACCENT_TILDE)
|
||||
sendKey(KeyEvent.KEYCODE_A, 'a')
|
||||
assertEquals('ẫ'.toInt() + 0x1000000, dispatchedKeyDowns.firstOrNull())
|
||||
assertEquals('ẫ'.code + 0x1000000, dispatchedKeyDowns.firstOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -262,7 +303,7 @@ class KeyHandlerTest {
|
|||
sendUp(0, ACCENT_TILDE or KeyCharacterMap.COMBINING_ACCENT)
|
||||
|
||||
sendKey(KeyEvent.KEYCODE_A, 'a')
|
||||
assertEquals('ẫ'.toInt() + 0x1000000, dispatchedKeyDowns.firstOrNull())
|
||||
assertEquals('ẫ'.code + 0x1000000, dispatchedKeyDowns.firstOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -273,7 +314,7 @@ class KeyHandlerTest {
|
|||
sendUp(0, ACCENT_CIRCUMFLEX or KeyCharacterMap.COMBINING_ACCENT)
|
||||
|
||||
sendKey(KeyEvent.KEYCODE_A, 'a')
|
||||
assertEquals('ẫ'.toInt() + 0x1000000, dispatchedKeyDowns.firstOrNull())
|
||||
assertEquals('ẫ'.code + 0x1000000, dispatchedKeyDowns.firstOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -283,7 +324,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('ẫ'.toInt() + 0x1000000, dispatchedKeyDowns.firstOrNull())
|
||||
assertEquals('ẫ'.code + 0x1000000, dispatchedKeyDowns.firstOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -306,7 +347,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'.toInt(), dispatchedKeyDowns.first())
|
||||
assertEquals('b'.code, dispatchedKeyDowns.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
package com.gaurav.avnc.ui.vnc
|
||||
|
||||
import android.graphics.Point
|
||||
import android.graphics.PointF
|
||||
import android.os.SystemClock
|
||||
import android.view.InputDevice
|
||||
|
|
@ -15,20 +16,31 @@ 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.*
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import io.mockk.verifyOrder
|
||||
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)
|
||||
|
|
@ -37,79 +49,31 @@ class TouchHandlerTest {
|
|||
fun setup() {
|
||||
instrumentation.runOnMainSync {
|
||||
mockDispatcher = mockk(relaxed = true)
|
||||
touchHandler = TouchHandler(VncViewModel(ApplicationProvider.getApplicationContext()), mockDispatcher)
|
||||
touchHandler = TouchHandler(VncViewModel(ServerProfile(), 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) {
|
||||
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)
|
||||
targetPrefs.edit {
|
||||
putBoolean("mouse_passthrough", mousePassthrough)
|
||||
putString("gesture_drag", if (dragEnabled) "remote-scroll" else "none")
|
||||
}
|
||||
setup()
|
||||
}
|
||||
|
||||
|
||||
/************************ 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))
|
||||
/************************* Finger Gestures *******************************************************/
|
||||
|
||||
@Test
|
||||
fun singleTap() {
|
||||
sendDown()
|
||||
sendUp()
|
||||
Thread.sleep(ViewConfiguration.getDoubleTapTimeout() + timeoutGrace)
|
||||
Thread.sleep(Delay.SINGLE_TAP_CONFIRM)
|
||||
verify { mockDispatcher.onTap1(testPoint) }
|
||||
}
|
||||
|
||||
|
|
@ -117,7 +81,7 @@ class TouchHandlerTest {
|
|||
fun doubleTap() {
|
||||
sendDown()
|
||||
sendUp()
|
||||
Thread.sleep(50) //Required due to the minimum time limit in GestureDetector
|
||||
Thread.sleep(Delay.BETWEEN_DOUBLE_TAPS)
|
||||
sendDown()
|
||||
sendUp()
|
||||
verify { mockDispatcher.onDoubleTap(testPoint) }
|
||||
|
|
@ -126,8 +90,8 @@ class TouchHandlerTest {
|
|||
@Test
|
||||
fun twoFingerTap() {
|
||||
sendDown()
|
||||
sendEvent(createEvent2(MotionEvent.ACTION_POINTER_DOWN, testPoint, PointF(50f, 50f)))
|
||||
sendEvent(createEvent2(MotionEvent.ACTION_POINTER_UP, testPoint, PointF(50f, 50f)))
|
||||
sendEvent(Factory.obtainPointerDownEvent(downEvent, testPoint, PointF(50f, 50f)))
|
||||
sendEvent(Factory.obtainPointerUpEvent(downEvent, testPoint, PointF(50f, 50f)))
|
||||
sendUp()
|
||||
verify { mockDispatcher.onTap2(testPoint) }
|
||||
}
|
||||
|
|
@ -136,7 +100,7 @@ class TouchHandlerTest {
|
|||
fun longPress() {
|
||||
setupWithPref(dragEnabled = false)
|
||||
sendDown()
|
||||
Thread.sleep(ViewConfiguration.getLongPressTimeout() + timeoutGrace)
|
||||
Thread.sleep(Delay.LONG_PRESS_CONFIRM)
|
||||
verify { mockDispatcher.onLongPress(testPoint) }
|
||||
}
|
||||
|
||||
|
|
@ -157,38 +121,56 @@ class TouchHandlerTest {
|
|||
val b2 = PointF(400f, 400f)
|
||||
|
||||
sendDown(a1)
|
||||
sendEvent(createEvent2(MotionEvent.ACTION_POINTER_DOWN, a1, b1))
|
||||
sendEvent(createEvent2(MotionEvent.ACTION_MOVE, a2, b2))
|
||||
sendEvent(Factory.obtainPointerDownEvent(downEvent, a1, b1))
|
||||
sendEvent(Factory.obtainMoveEvent(downEvent, a2, b2))
|
||||
verify { mockDispatcher.onSwipe2(a1, a2, 100f, 100f) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scale() {
|
||||
val a1 = PointF(100f, 100f)
|
||||
val a2 = PointF(10f, 10f)
|
||||
val b1 = PointF(200f, 200f)
|
||||
val b2 = PointF(300f, 300f)
|
||||
val a1 = PointF(300f, 300f)
|
||||
val b1 = PointF(400f, 400f)
|
||||
|
||||
sendDown(a1)
|
||||
sendEvent(createEvent2(MotionEvent.ACTION_POINTER_DOWN, a1, b1))
|
||||
sendEvent(createEvent2(MotionEvent.ACTION_MOVE, a2, b2))
|
||||
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)))
|
||||
verify { mockDispatcher.onScale(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun drag() {
|
||||
fun longPressSwipe() {
|
||||
setupWithPref(dragEnabled = true)
|
||||
|
||||
val a1 = PointF(100f, 100f)
|
||||
val a2 = PointF(150f, 150f)
|
||||
|
||||
sendDown(a1)
|
||||
Thread.sleep(ViewConfiguration.getLongPressTimeout() + timeoutGrace)
|
||||
Thread.sleep(Delay.LONG_PRESS_CONFIRM)
|
||||
sendMove(a2)
|
||||
sendUp(a2)
|
||||
|
||||
verifyOrder {
|
||||
mockDispatcher.onDrag(a1, a2, 50f, 50f)
|
||||
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.onGestureStop(a2)
|
||||
}
|
||||
}
|
||||
|
|
@ -206,7 +188,7 @@ class TouchHandlerTest {
|
|||
@Test
|
||||
fun gestureStartStopOnCancel() {
|
||||
sendDown()
|
||||
sendEvent(createEvent(MotionEvent.ACTION_CANCEL, testPoint))
|
||||
sendEvent(Factory.obtainCancelEvent(downEvent, testPoint))
|
||||
verifyOrder {
|
||||
mockDispatcher.onGestureStart()
|
||||
mockDispatcher.onGestureStop(testPoint)
|
||||
|
|
@ -218,8 +200,8 @@ class TouchHandlerTest {
|
|||
@Test
|
||||
fun mouseLeftClick() {
|
||||
setupWithPref(mousePassthrough = true)
|
||||
sendGenericEvent(createMouseEvent(MotionEvent.ACTION_BUTTON_PRESS, testPoint, MotionEvent.BUTTON_PRIMARY))
|
||||
sendGenericEvent(createMouseEvent(MotionEvent.ACTION_BUTTON_RELEASE, testPoint, MotionEvent.BUTTON_PRIMARY))
|
||||
sendMouseButtonPress(MotionEvent.BUTTON_PRIMARY)
|
||||
sendMouseButtonRelease()
|
||||
verifyOrder {
|
||||
mockDispatcher.onMouseButtonDown(PointerButton.Left, testPoint)
|
||||
mockDispatcher.onMouseButtonUp(PointerButton.Left, testPoint)
|
||||
|
|
@ -229,8 +211,8 @@ class TouchHandlerTest {
|
|||
@Test
|
||||
fun mouseRightClick() {
|
||||
setupWithPref(mousePassthrough = true)
|
||||
sendGenericEvent(createMouseEvent(MotionEvent.ACTION_BUTTON_PRESS, testPoint, MotionEvent.BUTTON_SECONDARY))
|
||||
sendGenericEvent(createMouseEvent(MotionEvent.ACTION_BUTTON_RELEASE, testPoint, MotionEvent.BUTTON_SECONDARY))
|
||||
sendMouseButtonPress(MotionEvent.BUTTON_SECONDARY)
|
||||
sendMouseButtonRelease()
|
||||
verifyOrder {
|
||||
mockDispatcher.onMouseButtonDown(PointerButton.Right, testPoint)
|
||||
mockDispatcher.onMouseButtonUp(PointerButton.Right, testPoint)
|
||||
|
|
@ -240,21 +222,23 @@ class TouchHandlerTest {
|
|||
@Test
|
||||
fun mouseMove() {
|
||||
setupWithPref(mousePassthrough = true)
|
||||
sendEvent(createMouseEvent(MotionEvent.ACTION_MOVE, testPoint))
|
||||
sendMouseButtonPress(MotionEvent.BUTTON_PRIMARY)
|
||||
sendMouseButtonMove(testPoint)
|
||||
sendMouseButtonRelease()
|
||||
verify { mockDispatcher.onMouseMove(testPoint) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mouseScroll() {
|
||||
setupWithPref(mousePassthrough = true)
|
||||
sendGenericEvent(createMouseEvent(MotionEvent.ACTION_SCROLL, testPoint))
|
||||
sendGenericEvent(Factory.obtainScrollEvent(testPoint))
|
||||
verify { mockDispatcher.onMouseScroll(testPoint, any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mouseHover1() {
|
||||
setupWithPref(mousePassthrough = true)
|
||||
sendHoverEvent(createMouseEvent(MotionEvent.ACTION_HOVER_MOVE, testPoint))
|
||||
sendHoverEvent(Factory.obtainHoverEvent(testPoint, InputDevice.SOURCE_MOUSE))
|
||||
verify { mockDispatcher.onMouseMove(testPoint) }
|
||||
}
|
||||
|
||||
|
|
@ -262,22 +246,18 @@ class TouchHandlerTest {
|
|||
@Test
|
||||
fun mouseHover2() {
|
||||
setupWithPref(mousePassthrough = true)
|
||||
sendGenericEvent(createMouseEvent(MotionEvent.ACTION_HOVER_MOVE, testPoint))
|
||||
sendGenericEvent(Factory.obtainHoverEvent(testPoint, InputDevice.SOURCE_MOUSE))
|
||||
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(ViewConfiguration.getDoubleTapTimeout() + timeoutGrace)
|
||||
Thread.sleep(Delay.SINGLE_TAP_CONFIRM)
|
||||
verify { mockDispatcher.onStylusTap(testPoint) }
|
||||
}
|
||||
|
||||
|
|
@ -285,7 +265,7 @@ class TouchHandlerTest {
|
|||
fun stylusDoubleTap() {
|
||||
sendStylusDown()
|
||||
sendStylusUp()
|
||||
Thread.sleep(50) //Required due to the minimum time limit in GestureDetector
|
||||
Thread.sleep(Delay.BETWEEN_DOUBLE_TAPS)
|
||||
sendStylusDown()
|
||||
sendStylusUp()
|
||||
verify { mockDispatcher.onStylusDoubleTap(testPoint) }
|
||||
|
|
@ -294,7 +274,7 @@ class TouchHandlerTest {
|
|||
@Test
|
||||
fun stylusLongPress() {
|
||||
sendStylusDown()
|
||||
Thread.sleep(ViewConfiguration.getLongPressTimeout() + timeoutGrace)
|
||||
Thread.sleep(Delay.LONG_PRESS_CONFIRM)
|
||||
verify { mockDispatcher.onStylusLongPress(testPoint) }
|
||||
}
|
||||
|
||||
|
|
@ -309,13 +289,199 @@ class TouchHandlerTest {
|
|||
|
||||
@Test
|
||||
fun stylusHover1() {
|
||||
sendHoverEvent(createStylusEvent(MotionEvent.ACTION_HOVER_MOVE, testPoint))
|
||||
sendHoverEvent(Factory.obtainHoverEvent(testPoint, InputDevice.SOURCE_STYLUS))
|
||||
verify { mockDispatcher.onMouseMove(testPoint) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stylusHover2() {
|
||||
sendGenericEvent(createStylusEvent(MotionEvent.ACTION_HOVER_MOVE, testPoint))
|
||||
sendGenericEvent(Factory.obtainHoverEvent(testPoint, InputDevice.SOURCE_STYLUS))
|
||||
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))
|
||||
|
||||
}
|
||||
|
|
@ -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.withId
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.espresso.matcher.ViewMatchers.*
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.gaurav.avnc.*
|
||||
import com.gaurav.avnc.model.ServerProfile
|
||||
|
|
@ -78,6 +78,21 @@ 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{}[]()`~@#$%^&*_+-=/*"
|
||||
|
|
@ -86,9 +101,46 @@ class VncActivityTest {
|
|||
onView(withId(R.id.frame_view)).doTypeText(text)
|
||||
}
|
||||
|
||||
val sentByClient = text.toCharArray().map { it.toInt() }.toList()
|
||||
val sentByClient = text.toCharArray().map { it.code }.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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -84,4 +84,10 @@ class VncUriTest {
|
|||
val uri = VncUri("[fe80:2a10:b62b]")
|
||||
assertEquals("", uri.host)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun opaqueUri() {
|
||||
// Correct format is 'vnc://host'
|
||||
assertEquals("", VncUri("vnc:host").host)
|
||||
}
|
||||
}
|
||||
BIN
app/src/ci/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
5
app/src/ci/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?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>
|
||||
5
app/src/ci/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?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>
|
||||
BIN
app/src/ci/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
app/src/ci/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
app/src/ci/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
app/src/ci/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/ci/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
app/src/ci/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/ci/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
app/src/ci/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
app/src/ci/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
app/src/ci/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
app/src/ci/res/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
app/src/ci/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
app/src/ci/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
app/src/ci/res/mipmap-xxxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
app/src/ci/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
4
app/src/ci/res/values/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
|
|
@ -1,16 +1,15 @@
|
|||
<?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.
|
||||
-->
|
||||
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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"
|
||||
|
|
@ -18,39 +17,52 @@
|
|||
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
|
||||
|
|
@ -65,10 +77,25 @@
|
|||
android:supportsPictureInPicture="true"
|
||||
tools:ignore="UnusedAttribute" />
|
||||
|
||||
<activity android:name=".ui.vnc.UriReceiverActivity" />
|
||||
<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.prefs.PrefsActivity" />
|
||||
|
||||
<activity android:name=".ui.about.AboutActivity" />
|
||||
</application>
|
||||
|
||||
|
|
|
|||
14
app/src/main/assets/security.schema.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"$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"]
|
||||
}
|
||||
|
|
@ -126,15 +126,23 @@ static void onBell(rfbClient *client) {
|
|||
env->CallVoidMethod(obj, mid);
|
||||
}
|
||||
|
||||
static void onGotXCutText(rfbClient *client, const char *text, int len) {
|
||||
static void onGotXCutText(rfbClient *client, const char *text, int len, bool is_utf8) {
|
||||
auto obj = getManagedClient(client);
|
||||
auto env = context.getEnv();
|
||||
auto cls = context.managedCls;
|
||||
|
||||
jmethodID mid = env->GetMethodID(cls, "cbGotXCutText", "([B)V");
|
||||
jmethodID mid = env->GetMethodID(cls, "cbGotXCutText", "([BZ)V");
|
||||
jbyteArray bytes = env->NewByteArray(len);
|
||||
env->SetByteArrayRegion(bytes, 0, len, reinterpret_cast<const jbyte *>(text));
|
||||
env->CallVoidMethod(obj, mid, bytes);
|
||||
env->CallVoidMethod(obj, mid, bytes, is_utf8);
|
||||
}
|
||||
|
||||
static void onGotXCutTextLatin1(rfbClient *client, const char *text, int len) {
|
||||
onGotXCutText(client, text, len, false);
|
||||
}
|
||||
|
||||
static void onGotXCutTextUTF8(rfbClient *client, const char *text, int len) {
|
||||
onGotXCutText(client, text, len, true);
|
||||
}
|
||||
|
||||
static rfbBool onHandleCursorPos(rfbClient *client, int x, int y) {
|
||||
|
|
@ -231,7 +239,8 @@ static void setCallbacks(rfbClient *client) {
|
|||
client->GetPassword = onGetPassword;
|
||||
client->GetCredential = onGetCredential;
|
||||
client->Bell = onBell;
|
||||
client->GotXCutText = onGotXCutText;
|
||||
client->GotXCutText = onGotXCutTextLatin1;
|
||||
client->GotXCutTextUTF8 = onGotXCutTextUTF8;
|
||||
client->HandleCursorPos = onHandleCursorPos;
|
||||
client->FinishedFrameBufferUpdate = onFinishedFrameBufferUpdate;
|
||||
client->MallocFrameBuffer = onMallocFrameBuffer;
|
||||
|
|
@ -266,7 +275,8 @@ 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 securityType, jboolean use_local_cursor, jint image_quality,
|
||||
jboolean use_raw_encoding) {
|
||||
auto client = (rfbClient *) client_ptr;
|
||||
|
||||
// 0 means all auth types
|
||||
|
|
@ -279,6 +289,10 @@ 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"
|
||||
|
|
@ -306,6 +320,12 @@ 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
|
||||
|
|
@ -353,8 +373,14 @@ 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,
|
||||
jlong key, jboolean is_down) {
|
||||
return (jboolean) SendKeyEvent((rfbClient *) client_ptr, (uint32_t) key, is_down ? TRUE : FALSE);
|
||||
jint key_sym, jint xt_code, jboolean is_down) {
|
||||
auto client = (rfbClient *) client_ptr;
|
||||
rfbBool down = is_down ? TRUE : FALSE;
|
||||
|
||||
if (xt_code > 0 && SendExtendedKeyEvent(client, key_sym, xt_code, down))
|
||||
return JNI_TRUE;
|
||||
else
|
||||
return SendKeyEvent(client, key_sym, down);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
|
|
@ -366,22 +392,42 @@ 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) {
|
||||
Java_com_gaurav_avnc_vnc_VncClient_nativeSendCutText(JNIEnv *env, jobject thiz, jlong client_ptr, jbyteArray bytes,
|
||||
jboolean is_utf8) {
|
||||
auto client = (rfbClient *) client_ptr;
|
||||
auto textBuffer = env->GetByteArrayElements(bytes, nullptr);
|
||||
auto textLen = env->GetArrayLength(bytes);
|
||||
auto textChars = reinterpret_cast<char *>(textBuffer);
|
||||
|
||||
rfbBool result = SendClientCutText((rfbClient *) client_ptr, textChars, textLen);
|
||||
rfbBool result = is_utf8
|
||||
? SendClientCutTextUTF8(client, textChars, textLen)
|
||||
: SendClientCutText(client, textChars, textLen);
|
||||
|
||||
env->ReleaseByteArrayElements(bytes, textBuffer, JNI_ABORT);
|
||||
return (jboolean) result;
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_com_gaurav_avnc_vnc_VncClient_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, FALSE);
|
||||
return (jboolean) SendFramebufferUpdateRequest(client, 0, 0, client->width, client->height, TRUE);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_gaurav_avnc_vnc_VncClient_nativeSetAutomaticFramebufferUpdates(JNIEnv *env, jobject thiz, jlong client_ptr,
|
||||
jboolean enabled) {
|
||||
auto client = ((rfbClient *) client_ptr);
|
||||
client->automaticUpdateRequests = enabled ? TRUE : FALSE;
|
||||
if (enabled) SendIncrementalFramebufferUpdateRequest(client);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
|
|
|
|||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
|
|
@ -9,11 +9,13 @@
|
|||
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() {
|
||||
|
|
@ -24,11 +26,13 @@ class App : Application() {
|
|||
}
|
||||
|
||||
private fun updateNightMode(theme: String) {
|
||||
val nightMode = when (theme) {
|
||||
var 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
* - [CredentialFragment] is used to ask username & password from user.
|
||||
* - [LoginFragment] 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.
|
||||
* -
|
||||
|
|
|
|||
|
|
@ -9,11 +9,19 @@
|
|||
package com.gaurav.avnc.model
|
||||
|
||||
/**
|
||||
* Simple model used by login auto-completion.
|
||||
* Generic wrapper for login information.
|
||||
* This can be used to hold different [Type]s of credentials.
|
||||
*/
|
||||
data class LoginInfo(
|
||||
val name: String, //Profile name
|
||||
val host: String,
|
||||
val username: String,
|
||||
val password: String,
|
||||
)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
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
|
||||
|
|
@ -76,7 +77,27 @@ data class ServerProfile(
|
|||
* Specifies the image quality of the frames.
|
||||
* This mainly affects the compression level used by some encodings.
|
||||
*/
|
||||
var imageQuality: Int = 0,
|
||||
var imageQuality: Int = 5,
|
||||
|
||||
/**
|
||||
* Use raw encoding for framebuffer.
|
||||
* This can improve performance when server is running on localhost.
|
||||
*/
|
||||
@ColumnInfo(defaultValue = "0")
|
||||
var useRawEncoding: Boolean = false,
|
||||
|
||||
/**
|
||||
* Initial zoom for the viewer.
|
||||
* This will be used in portrait orientation, or when per-orientation zooming is disabled.
|
||||
*/
|
||||
@ColumnInfo(defaultValue = "1.0")
|
||||
var zoom1: Float = 1f,
|
||||
|
||||
/**
|
||||
* This will be used in landscape orientation if per-orientation zooming is enabled.
|
||||
*/
|
||||
@ColumnInfo(defaultValue = "1.0")
|
||||
var zoom2: Float = 1f,
|
||||
|
||||
/**
|
||||
* Specifies whether 'View Only' mode should be used.
|
||||
|
|
@ -87,15 +108,43 @@ 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.configureClient]
|
||||
* See [com.gaurav.avnc.viewmodel.VncViewModel.preConnect]
|
||||
*/
|
||||
var useLocalCursor: Boolean = true,
|
||||
|
||||
/**
|
||||
* Compatibility mode for key events.
|
||||
* If enabled, we will try to emit legacy X KeySym events.
|
||||
* Server type hint received from user, e.g. tigervnc, tightvnc, vino
|
||||
* Can be used in future to handle known server quirks.
|
||||
*/
|
||||
var keyCompatMode: Boolean = true,
|
||||
@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,
|
||||
|
||||
/**
|
||||
* Whether UltraVNC Repeater is used for connections.
|
||||
|
|
@ -109,6 +158,12 @@ 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
|
||||
*/
|
||||
|
|
@ -130,5 +185,18 @@ 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,19 +9,20 @@
|
|||
package com.gaurav.avnc.model.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.*
|
||||
import androidx.room.migration.AutoMigrationSpec
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.gaurav.avnc.model.ServerProfile
|
||||
|
||||
@Database(entities = [ServerProfile::class], version = 1, exportSchema = true)
|
||||
@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
|
||||
])
|
||||
abstract class MainDb : RoomDatabase() {
|
||||
abstract val serverProfileDao: ServerProfileDao
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Database singleton.
|
||||
*/
|
||||
private var instance: MainDb? = null
|
||||
|
||||
/**
|
||||
|
|
@ -36,4 +37,16 @@ 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
|
||||
}
|
||||
|
|
@ -10,7 +10,6 @@ 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
|
||||
|
|
@ -24,23 +23,26 @@ interface ServerProfileDao {
|
|||
|
||||
//Synchronous version
|
||||
@Query("SELECT * FROM profiles")
|
||||
fun getList(): List<ServerProfile>
|
||||
suspend fun getList(): List<ServerProfile>
|
||||
|
||||
@Query("SELECT name, host, username, password FROM profiles")
|
||||
fun getCredentials(): LiveData<List<LoginInfo>>
|
||||
@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>
|
||||
|
||||
@Insert
|
||||
fun insert(profile: ServerProfile): Long
|
||||
suspend fun insert(profile: ServerProfile): Long
|
||||
|
||||
@Insert
|
||||
fun insert(profiles: List<ServerProfile>)
|
||||
suspend fun insert(profiles: List<ServerProfile>)
|
||||
|
||||
@Update
|
||||
fun update(profile: ServerProfile)
|
||||
suspend fun update(profile: ServerProfile)
|
||||
|
||||
@Delete
|
||||
fun delete(profile: ServerProfile)
|
||||
suspend fun delete(profile: ServerProfile)
|
||||
|
||||
@Query("DELETE FROM profiles")
|
||||
fun deleteAll()
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ class AboutActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
onBackPressed()
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -12,19 +12,29 @@ 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.
|
||||
|
|
@ -54,9 +64,12 @@ class HomeActivity : AppCompatActivity() {
|
|||
//Observers
|
||||
viewModel.editProfileEvent.observe(this) { showProfileEditor() }
|
||||
viewModel.profileInsertedEvent.observe(this) { onProfileInserted(it) }
|
||||
viewModel.profileDeletedEvent.observe(this) { showProfileDeletedMsg(it) }
|
||||
viewModel.newConnectionEvent.observe(this) { startVncActivity(this, it) }
|
||||
viewModel.profileDeletedEvent.observe(this) { onProfileDeleted(it) }
|
||||
viewModel.newConnectionEvent.observe(this) { startNewConnection(it) }
|
||||
viewModel.discovery.servers.observe(this) { updateDiscoveryBadge(it) }
|
||||
viewModel.serverProfiles.observe(this) { updateShortcuts(it) }
|
||||
|
||||
showWelcomeMsg()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
|
|
@ -84,6 +97,11 @@ class HomeActivity : AppCompatActivity() {
|
|||
return true
|
||||
}
|
||||
|
||||
private fun startNewConnection(profile: ServerProfile) {
|
||||
if (checkNativeLib())
|
||||
startVncActivity(this, profile)
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches Settings activity
|
||||
*/
|
||||
|
|
@ -127,9 +145,9 @@ class HomeActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Shows delete confirmation snackbar.
|
||||
* Shows delete confirmation snackbar, allowing the user to Undo deletion.
|
||||
*/
|
||||
private fun showProfileDeletedMsg(profile: ServerProfile) {
|
||||
private fun onProfileDeleted(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()
|
||||
|
|
@ -138,4 +156,79 @@ 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -11,10 +11,13 @@ 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
|
||||
|
|
@ -30,9 +33,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
|
||||
|
||||
|
|
@ -56,6 +59,7 @@ class ProfileEditorFragment : DialogFragment() {
|
|||
private var profile = ServerProfile()
|
||||
|
||||
private fun loadProfile(savedInstanceState: Bundle?) {
|
||||
@Suppress("DEPRECATION")
|
||||
profile = savedInstanceState?.getParcelable("EditorProfile")
|
||||
?: viewModel.editProfileEvent.value
|
||||
?: profile
|
||||
|
|
@ -164,6 +168,11 @@ 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
|
||||
|
|
@ -172,19 +181,54 @@ 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_default_security) to 0,
|
||||
getString(R.string.pref_gesture_action_none) to 1,
|
||||
getString(R.string.title_automatic) to 0,
|
||||
getString(R.string.title_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
|
||||
|
|
@ -218,7 +262,6 @@ class ProfileEditorFragment : DialogFragment() {
|
|||
result = result and
|
||||
validateNotEmpty(binding.sshHost) and
|
||||
validateNotEmpty(binding.sshUsername) and
|
||||
validateNotEmpty(binding.sshPassword, binding.sshAuthTypePassword.isChecked) and
|
||||
validatePrivateKey()
|
||||
}
|
||||
|
||||
|
|
@ -242,11 +285,6 @@ 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
|
||||
}
|
||||
|
|
@ -261,29 +299,30 @@ class ProfileEditorFragment : DialogFragment() {
|
|||
return
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
lifecycleScope.launchWhenCreated {
|
||||
if (pem.isSuccess) {
|
||||
profile.sshPrivateKey = text
|
||||
result.onSuccess {
|
||||
profile.sshPrivateKey = key
|
||||
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()
|
||||
} else {
|
||||
}.onFailure {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ 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() {
|
||||
|
|
|
|||
|
|
@ -35,13 +35,15 @@ class UrlBarActivity : AppCompatActivity() {
|
|||
val binding = DataBindingUtil.setContentView<ActivityUrlBinding>(this, R.layout.activity_url)
|
||||
|
||||
binding.url.setOnEditorActionListener { _, _, _ -> go(binding.url.text.toString()) }
|
||||
binding.backBtn.setOnClickListener { onBackPressed() }
|
||||
binding.backBtn.setOnClickListener { onBackPressedDispatcher.onBackPressed() }
|
||||
binding.clearBtn.setOnClickListener {
|
||||
if (binding.url.text.isEmpty())
|
||||
finish()
|
||||
else
|
||||
binding.url.setText("")
|
||||
}
|
||||
|
||||
binding.url.requestFocus()
|
||||
}
|
||||
|
||||
private fun go(url: String): Boolean {
|
||||
|
|
|
|||
62
app/src/main/java/com/gaurav/avnc/ui/home/WelcomeFragment.kt
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
196
app/src/main/java/com/gaurav/avnc/ui/ldc/KioskActivity.kt
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,453 @@
|
|||
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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
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()
|
||||
})
|
||||
}
|
||||
}
|
||||
259
app/src/main/java/com/gaurav/avnc/ui/ldc/KioskPingActivity.kt
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.gaurav.avnc.ui.ldc.events
|
||||
|
||||
/**
|
||||
* The network connection state has changed
|
||||
*/
|
||||
class AppSwitchedToForegroundEvent()
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.gaurav.avnc.ui.ldc.events
|
||||
|
||||
/**
|
||||
* The network connection state has changed
|
||||
*/
|
||||
class NetworkStateChangedEvent()
|
||||
|
|
@ -0,0 +1,503 @@
|
|||
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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
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())
|
||||
}
|
||||
}
|
||||
298
app/src/main/java/com/gaurav/avnc/ui/ldc/lib/ApplicationInfo.kt
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
136
app/src/main/java/com/gaurav/avnc/ui/ldc/lib/NetworkObserver.kt
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.gaurav.avnc.ui.ldc.lib
|
||||
|
||||
enum class NetworkState {
|
||||
UnAvailable, Available
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
package com.gaurav.avnc.ui.ldc.lib.exceptions
|
||||
|
||||
class InvalidIpException: RuntimeException() {
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
package com.gaurav.avnc.ui.ldc.lib.exceptions
|
||||
|
||||
class InvalidUriException: RuntimeException() {
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
package com.gaurav.avnc.ui.ldc.lib.exceptions
|
||||
|
||||
class PasswordValidationException: RuntimeException() {
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
package com.gaurav.avnc.ui.ldc.lib.exceptions
|
||||
|
||||
class PingFailedException: RuntimeException() {
|
||||
}
|
||||
207
app/src/main/java/com/gaurav/avnc/ui/prefs/DebugTools.kt
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -16,14 +16,11 @@ 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
|
||||
|
|
@ -34,10 +31,11 @@ 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 {
|
||||
|
|
@ -54,6 +52,11 @@ 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
|
||||
}
|
||||
|
||||
|
|
@ -84,13 +87,10 @@ class ImportExportFragment : Fragment() {
|
|||
* This is to protect sensitive info that might be present in exported data.
|
||||
*/
|
||||
private fun startExport() {
|
||||
if (viewModel.canAuthenticateUser)
|
||||
startClass2BiometricOrCredentialAuthentication(
|
||||
title = getString(R.string.msg_export_auth_required),
|
||||
confirmationRequired = false,
|
||||
callback = ExportAuthCallback()
|
||||
)
|
||||
else
|
||||
if (exportAuthPrompt.canLaunch()) {
|
||||
exportAuthPrompt.launch(getString(R.string.msg_export_auth_required))
|
||||
viewModel.importExportError.value = null //Clear old error
|
||||
} else
|
||||
exportFilePicker.launch(generateFilename())
|
||||
}
|
||||
|
||||
|
|
@ -103,22 +103,4 @@ 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!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,97 +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.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)
|
||||
}
|
||||
|
|
@ -14,16 +14,19 @@ import android.os.Build
|
|||
import android.os.Bundle
|
||||
import androidx.annotation.Keep
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.core.text.HtmlCompat
|
||||
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)
|
||||
|
||||
|
|
@ -39,7 +42,7 @@ class PrefsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPreference
|
|||
}
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
onBackPressed()
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -47,7 +50,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)
|
||||
|
|
@ -95,7 +98,7 @@ class PrefsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPreference
|
|||
}
|
||||
|
||||
@Keep class Input : PrefFragment(R.xml.pref_input) {
|
||||
private var naturalScrollingUpdater: OnSharedPreferenceChangeListener? = null
|
||||
private var invertScrollingUpdater: OnSharedPreferenceChangeListener? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
|
@ -109,26 +112,54 @@ class PrefsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPreference
|
|||
}
|
||||
}
|
||||
|
||||
val style = findPreference<ListPreferenceEx>("gesture_style")!!
|
||||
val swipe1 = findPreference<ListPreferenceEx>("gesture_swipe1")!!
|
||||
val swipe2 = findPreference<ListPreference>("gesture_swipe2")!!
|
||||
val drag = findPreference<ListPreferenceEx>("gesture_drag")!!
|
||||
val longPressSwipe = findPreference<ListPreferenceEx>("gesture_long_press_swipe")!!
|
||||
|
||||
swipe1.apply { disabledStateSummary = getString(R.string.pref_gesture_action_move_pointer) }
|
||||
drag.apply { helpMessage = getText(R.string.msg_drag_gesture_help) }
|
||||
swipe1.disabledStateSummary = getString(R.string.pref_gesture_action_move_pointer)
|
||||
longPressSwipe.helpMessage = getText(R.string.msg_drag_gesture_help)
|
||||
|
||||
// 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)
|
||||
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")
|
||||
}
|
||||
}
|
||||
naturalScrollingUpdater?.onSharedPreferenceChanged(null, null) //Initial update
|
||||
swipe1.sharedPreferences.registerOnSharedPreferenceChangeListener(naturalScrollingUpdater)
|
||||
invertScrollingUpdater?.onSharedPreferenceChanged(swipe1.sharedPreferences, null) //Initial update
|
||||
swipe1.sharedPreferences?.registerOnSharedPreferenceChangeListener(invertScrollingUpdater)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Keep class Server : PrefFragment(R.xml.pref_server)
|
||||
@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 Tools : PrefFragment(R.xml.pref_tools)
|
||||
@Keep class Experimental : PrefFragment(R.xml.pref_experimental)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,135 +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.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
|
||||
}
|
||||
|
|
@ -9,7 +9,6 @@
|
|||
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
|
||||
|
|
@ -53,6 +52,7 @@ 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,85 +62,102 @@ class Dispatcher(private val activity: VncActivity) {
|
|||
**************************************************************************/
|
||||
private val directMode = DirectMode()
|
||||
private val relativeMode = RelativeMode()
|
||||
private val defaultMode = if (gesturePref.directTouch) directMode else relativeMode
|
||||
private var config = Config()
|
||||
|
||||
private val tap1Action = selectPointAction(gesturePref.tap1)
|
||||
private val tap2Action = selectPointAction(gesturePref.tap2)
|
||||
private val doubleTapAction = selectPointAction(gesturePref.doubleTap)
|
||||
private val longPressAction = selectPointAction(gesturePref.longPress)
|
||||
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 swipe1Action = selectSwipeAction(if (gesturePref.directTouch) gesturePref.swipe1 else "move-pointer")
|
||||
private val swipe2Action = selectSwipeAction(gesturePref.swipe2)
|
||||
private val dragAction = selectSwipeAction(gesturePref.drag)
|
||||
val tap1Action = selectPointAction(gesturePref.tap1)
|
||||
val tap2Action = selectPointAction(gesturePref.tap2)
|
||||
val doubleTapAction = selectPointAction(gesturePref.doubleTap)
|
||||
val longPressAction = selectPointAction(gesturePref.longPress)
|
||||
|
||||
private val mouseBackAction = selectPointAction(viewModel.pref.input.mouseBack)
|
||||
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 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
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
* 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
|
||||
*/
|
||||
operator fun invoke(sp: PointF, cp: PointF, dx: Float, dy: Float)
|
||||
}
|
||||
private fun selectSwipeAction(actionName: String): (PointF, PointF, Float, Float) -> Unit {
|
||||
return when (actionName) {
|
||||
"pan" -> { _, _, dx, dy -> doPan(dx, dy) }
|
||||
"move-pointer" -> { _, cp, dx, dy -> defaultMode.doMovePointer(cp, dx, dy) }
|
||||
"remote-scroll" -> { sp, _, dx, dy -> defaultMode.doRemoteScroll(sp, dx, dy) }
|
||||
"remote-drag" -> { _, cp, dx, dy -> defaultMode.doRemoteDrag(PointerButton.Left, cp, dx, dy) }
|
||||
"remote-drag-middle" -> { _, cp, dx, dy -> defaultMode.doRemoteDrag(PointerButton.Middle, cp, dx, dy) }
|
||||
else -> { _, _, _, _ -> } //Nothing
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fling is only used for smooth-scrolling the frame.
|
||||
* So it only makes sense when 1-finger-swipe is set to "pan".
|
||||
*/
|
||||
private fun selectFlingAction(): (Float, Float) -> Unit {
|
||||
return if (swipe1Pref == "pan") { vx, vy -> startFrameFling(vx, vy) }
|
||||
else { _, _ -> }
|
||||
}
|
||||
}
|
||||
|
||||
/**************************************************************************
|
||||
* Event receivers
|
||||
**************************************************************************/
|
||||
|
||||
fun onGestureStart() = defaultMode.onGestureStart()
|
||||
fun onGestureStop(p: PointF) = defaultMode.onGestureStop(p)
|
||||
fun onGestureStart() = config.defaultMode.onGestureStart()
|
||||
fun onGestureStop(p: PointF) = config.defaultMode.onGestureStop(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 onTap1(p: PointF) = config.tap1Action(p)
|
||||
fun onTap2(p: PointF) = config.tap2Action(p)
|
||||
fun onDoubleTap(p: PointF) = config.doubleTapAction(p)
|
||||
fun onLongPress(p: PointF) = config.longPressAction(p)
|
||||
|
||||
fun onSwipe1(sp: PointF, cp: PointF, dx: Float, dy: Float) = 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 onSwipe1(sp: PointF, cp: PointF, dx: Float, dy: Float) = config.swipe1Action(sp, cp, dx, dy)
|
||||
fun onSwipe2(sp: PointF, cp: PointF, dx: Float, dy: Float) = config.swipe2Action(sp, cp, dx, dy)
|
||||
fun onDoubleTapSwipe(sp: PointF, cp: PointF, dx: Float, dy: Float) = config.doubleTapSwipeAction(sp, cp, dx, dy)
|
||||
fun onLongPressSwipe(sp: PointF, cp: PointF, dx: Float, dy: Float) = config.longPressSwipeAction(sp, cp, dx, dy)
|
||||
|
||||
fun onScale(scaleFactor: Float, fx: Float, fy: Float) = doScale(scaleFactor, fx, fy)
|
||||
fun onFling(vx: Float, vy: Float) = defaultMode.doFling(vx, vy)
|
||||
fun onFling(vx: Float, vy: Float) = config.flingAction(vx, vy)
|
||||
|
||||
fun onMouseButtonDown(button: PointerButton, p: PointF) = directMode.doButtonDown(button, p)
|
||||
fun onMouseButtonUp(button: PointerButton, p: PointF) = directMode.doButtonUp(button, p)
|
||||
fun onMouseMove(p: PointF) = directMode.doMovePointer(p, 0f, 0f)
|
||||
fun onMouseScroll(p: PointF, hs: Float, vs: Float) = directMode.doRemoteScrollFromMouse(p, hs, vs)
|
||||
fun onMouseBack(p: PointF) = mouseBackAction(p)
|
||||
fun onMouseBack(p: PointF) = config.mouseBackAction(p)
|
||||
|
||||
fun onStylusTap(p: PointF) = directMode.doClick(PointerButton.Left, p)
|
||||
fun onStylusDoubleTap(p: PointF) = directMode.doDoubleClick(PointerButton.Left, p)
|
||||
fun onStylusLongPress(p: PointF) = directMode.doClick(PointerButton.Right, p)
|
||||
fun onStylusScroll(p: PointF) = directMode.doButtonDown(PointerButton.Left, p)
|
||||
|
||||
fun onXKeySym(keySym: Int, isDown: Boolean) = messenger.sendKey(keySym, isDown)
|
||||
fun onXKey(keySym: Int, xtCode: Int, isDown: Boolean) = messenger.sendKey(keySym, xtCode, isDown)
|
||||
|
||||
fun onGestureStyleChanged() {
|
||||
config = Config()
|
||||
}
|
||||
|
||||
/**************************************************************************
|
||||
* Available actions
|
||||
|
|
@ -149,6 +166,8 @@ 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
|
||||
|
|
@ -160,14 +179,13 @@ 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.naturalScrolling) 1 else -1)
|
||||
private val yScrollDirection = (if (gesturePref.invertVerticalScrolling) -1 else 1)
|
||||
|
||||
abstract fun transformPoint(p: PointF): PointF?
|
||||
abstract fun doMovePointer(p: PointF, dx: Float, dy: Float)
|
||||
abstract fun doDrag(p: PointF, dx: Float, dy: Float)
|
||||
abstract fun doFling(vx: Float, vy: Float)
|
||||
abstract fun doRemoteDrag(button: PointerButton, p: PointF, dx: Float, dy: Float)
|
||||
|
||||
open fun onGestureStart() {}
|
||||
open fun onGestureStart() = stopFrameFling()
|
||||
open fun onGestureStop(p: PointF) = doButtonRelease(p)
|
||||
|
||||
fun doButtonDown(button: PointerButton, p: PointF) {
|
||||
|
|
@ -184,6 +202,9 @@ 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)
|
||||
}
|
||||
|
||||
|
|
@ -235,9 +256,7 @@ 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 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()
|
||||
override fun doRemoteDrag(button: PointerButton, p: PointF, dx: Float, dy: Float) = doButtonDown(button, p)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -264,25 +283,28 @@ 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, viewModel.frameState.fbWidth - 1)
|
||||
y = y.coerceIn(0f, viewModel.frameState.fbHeight - 1)
|
||||
x = x.coerceIn(0f, xLimit)
|
||||
y = y.coerceIn(0f, yLimit)
|
||||
}
|
||||
doButtonDown(PointerButton.None, pointerPosition)
|
||||
|
||||
//Try to keep the pointer centered on screen
|
||||
val vp = viewModel.frameState.toVP(pointerPosition)
|
||||
val centerDiffX = viewModel.frameState.vpWidth / 2 - vp.x
|
||||
val centerDiffY = viewModel.frameState.vpHeight / 2 - vp.y
|
||||
val centerDiffX = viewModel.frameState.safeArea.centerX() - vp.x
|
||||
val centerDiffY = viewModel.frameState.safeArea.centerY() - vp.y
|
||||
viewModel.panFrame(centerDiffX, centerDiffY)
|
||||
}
|
||||
|
||||
override fun doDrag(p: PointF, dx: Float, dy: Float) {
|
||||
doButtonDown(PointerButton.Left, p)
|
||||
override fun doRemoteDrag(button: PointerButton, p: PointF, dx: Float, dy: Float) {
|
||||
doButtonDown(button, p)
|
||||
doMovePointer(p, dx, dy)
|
||||
}
|
||||
|
||||
override fun doFling(vx: Float, vy: Float) {} //Disabled
|
||||
}
|
||||
}
|
||||
|
|
@ -47,24 +47,25 @@ 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 >= 0 : It means frame is completely visible and centered horizontally.
|
||||
* In this case both minX,maxX = x (ie. no movement possible).
|
||||
* 1) x >= safeLeft : It means frame is completely visible and centered horizontally.
|
||||
* In this case both minX,maxX = x (ie. no movement possible).
|
||||
|
||||
* 2) x < 0 : Here, 'scaled frame width' > 'viewport width'. In this case
|
||||
* minX is negative and maxX = 0.
|
||||
* 2) x < safeLeft : Here, 'scaled frame width' > 'safe width'. In this case
|
||||
* minX is negative and maxX = safeLeft.
|
||||
*
|
||||
* minY,maxY are calculated similarly.
|
||||
*/
|
||||
val minX = if (x >= 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
|
||||
val minX = if (x >= safe.left) x else (safe.width()) - (fs.fbWidth * fs.scale)
|
||||
val maxX = if (x >= safe.left) x else safe.left
|
||||
val minY = if (y >= safe.top) y else (safe.height()) - (fs.fbHeight * fs.scale)
|
||||
val maxY = if (y >= safe.top) y else safe.top
|
||||
|
||||
xAnimator.apply {
|
||||
setStartValue(x)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
package com.gaurav.avnc.ui.vnc
|
||||
|
||||
import android.graphics.PointF
|
||||
import com.gaurav.avnc.util.AppPreferences
|
||||
import android.graphics.RectF
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
|
|
@ -27,25 +27,30 @@ import kotlin.math.min
|
|||
*
|
||||
* Window: Top-level window of the application/activity.
|
||||
*
|
||||
* Viewport: This is the area of screen where frame is rendered. It is denoted by [FrameView].
|
||||
* Viewport: This is the area of window where frame is rendered. It is denoted by [FrameView].
|
||||
*
|
||||
* 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).
|
||||
* Safe area: Area inside viewport which is safe for interaction with frame, maintained in [safeArea].
|
||||
*
|
||||
* +---------------------------+
|
||||
* | |
|
||||
* | ViewPort |
|
||||
* | | Window
|
||||
* +---------------------------+
|
||||
* | Soft Keyboard |
|
||||
* +---------------------------+
|
||||
* 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).
|
||||
*
|
||||
* 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.
|
||||
* +---------------------------+ - -
|
||||
* | \Cutout/ | | | Viewport
|
||||
* | | | | -
|
||||
* | | | | | SafeArea
|
||||
* +---------------------------+ | - -
|
||||
* | Soft Keyboard | | Window
|
||||
* +---------------------------+ -
|
||||
*
|
||||
* Differentiating between these allows us to handle layout changes more easily and cleanly.
|
||||
* We use window size to calculate base scale because we don't want to change scale when
|
||||
* keyboard is shown/hidden. Viewport size is used for rendering the frame, fully immersive.
|
||||
* Safe area is used to coerce frame position so that user can pan every part of frame inside
|
||||
* safe area to interact with it.
|
||||
*
|
||||
* See [LayoutManager] for more information about these values.
|
||||
*
|
||||
* State & Coordinates
|
||||
* ===================
|
||||
|
|
@ -76,10 +81,12 @@ import kotlin.math.min
|
|||
* - Maximum window space is utilized
|
||||
*
|
||||
* 2. Zoom Scale [zoomScale] :
|
||||
* 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.
|
||||
*
|
||||
* This is the user controlled part. It is updated only in response to pinch gestures.
|
||||
* To allow different zoom in different orientations, two separate zoom scales are maintained
|
||||
* in [zoomScale1] & [zoomScale2]. Based on user preference and current orientation, [zoomScale]
|
||||
* will be delegated to one of these.
|
||||
*
|
||||
* Conceptually, zoom scale works 'on top of' the base scale.
|
||||
* Effective scale [scale] is calculated as the product of these two parts, so:
|
||||
|
||||
* FrameSize = (FramebufferSize * BaseScale) * ZoomScale
|
||||
|
|
@ -93,13 +100,11 @@ 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) {
|
||||
|
||||
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
|
||||
class FrameState(
|
||||
private val minZoomScale: Float = 0.5F,
|
||||
private val maxZoomScale: Float = 5F,
|
||||
private val usePerOrientationZoom: Boolean = false
|
||||
) {
|
||||
|
||||
//Frame position, relative to top-left corner (0,0)
|
||||
var frameX = 0F; private set
|
||||
|
|
@ -117,6 +122,20 @@ class FrameState(private val minZoomScale: Float = 0.5F, private val maxZoomScal
|
|||
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
|
||||
|
|
@ -138,6 +157,11 @@ class FrameState(private val minZoomScale: Float = 0.5F, private val maxZoomScal
|
|||
coerceValues()
|
||||
}
|
||||
|
||||
fun setSafeArea(rect: RectF) {
|
||||
safeArea = RectF(rect)
|
||||
coerceValues()
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust zoom scale according to give [scaleFactor].
|
||||
*
|
||||
|
|
@ -152,8 +176,9 @@ class FrameState(private val minZoomScale: Float = 0.5F, private val maxZoomScal
|
|||
return zoomScale / oldScale //Applied scale factor
|
||||
}
|
||||
|
||||
fun resetZoom() {
|
||||
zoomScale = 1F
|
||||
fun setZoom(zoom1: Float, zoom2: Float) {
|
||||
zoomScale1 = zoom1
|
||||
zoomScale2 = zoom2
|
||||
coerceValues()
|
||||
}
|
||||
|
||||
|
|
@ -216,19 +241,24 @@ class FrameState(private val minZoomScale: Float = 0.5F, private val maxZoomScal
|
|||
* Makes sure state values are within constraints.
|
||||
*/
|
||||
private fun coerceValues() {
|
||||
zoomScale = zoomScale.coerceIn(minZoomScale, maxZoomScale)
|
||||
frameX = coercePosition(frameX, vpWidth, fbWidth)
|
||||
frameY = coercePosition(frameY, vpHeight, fbHeight)
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce position value in a direction (horizontal/vertical).
|
||||
*/
|
||||
private fun coercePosition(current: Float, vp: Float, fb: Float): Float {
|
||||
private fun coercePosition(current: Float, safeMin: Float, safeMax: Float, fb: Float): Float {
|
||||
val scaledFb = (fb * scale)
|
||||
val diff = vp - scaledFb
|
||||
val diff = (safeMax - safeMin) - scaledFb
|
||||
|
||||
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.
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
|
@ -78,11 +78,6 @@ 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
|
||||
|
|
@ -90,6 +85,10 @@ 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)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ 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.
|
||||
|
|
@ -31,7 +30,7 @@ class HostKeyFragment : DialogFragment() {
|
|||
val message = """
|
||||
|
|
||||
|Host: ${hostKey.host}
|
||||
|Key type: ${hostKey.algo.toUpperCase(Locale.ENGLISH)}
|
||||
|Key type: ${hostKey.algo.uppercase()}
|
||||
|Key fingerprint:
|
||||
|
|
||||
|${hostKey.getFingerprint()}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
/*
|
||||
* 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()
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ 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
|
||||
|
|
@ -25,6 +26,10 @@ import com.gaurav.avnc.vnc.XKeySymUnicode
|
|||
* 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.
|
||||
|
|
@ -37,14 +42,14 @@ import com.gaurav.avnc.vnc.XKeySymUnicode
|
|||
*
|
||||
* 1. X KeySym - Individual symbols defined by X Windows System
|
||||
* 2. Unicode KeySym - Unicode code points encoded as X KeySym
|
||||
* 2. Legacy X KeySym - Old KeySyms which are now superseded by their Unicode KeySym equivalents
|
||||
* 3. Legacy X KeySym - Old KeySyms which are now superseded by their Unicode KeySym equivalents
|
||||
*
|
||||
*
|
||||
* To decide which one to emit, we look at following things:
|
||||
*
|
||||
* a. Key code of [KeyEvent] (may not be available, e.g. in case of [KeyEvent.ACTION_MULTIPLE])
|
||||
* b. Unicode character of [KeyEvent] (may not be available, e.g. in case of [KeyEvent.KEYCODE_F1])
|
||||
* c. Current [compatMode]
|
||||
* c. Current [cfLegacyKeysym]
|
||||
*
|
||||
*
|
||||
*- [KeyEvent]
|
||||
|
|
@ -78,7 +83,7 @@ import com.gaurav.avnc.vnc.XKeySymUnicode
|
|||
* [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 compatMode: Boolean, prefs: AppPreferences) {
|
||||
class KeyHandler(private val dispatcher: Dispatcher, private val cfLegacyKeysym: Boolean, prefs: AppPreferences) {
|
||||
|
||||
/**
|
||||
* Shortcut to send both up & down events. Useful for Virtual Keys.
|
||||
|
|
@ -110,8 +115,8 @@ class KeyHandler(private val dispatcher: Dispatcher, private val compatMode: Boo
|
|||
@Suppress("DEPRECATION")
|
||||
when (event.action) {
|
||||
|
||||
KeyEvent.ACTION_DOWN -> return emitForKeyEvent(event.keyCode, getUnicodeChar(event), true)
|
||||
KeyEvent.ACTION_UP -> return emitForKeyEvent(event.keyCode, getUnicodeChar(event), false)
|
||||
KeyEvent.ACTION_DOWN -> return emitForKeyEvent(event.keyCode, getUnicodeChar(event), true, event.scanCode)
|
||||
KeyEvent.ACTION_UP -> return emitForKeyEvent(event.keyCode, getUnicodeChar(event), false, event.scanCode)
|
||||
|
||||
KeyEvent.ACTION_MULTIPLE -> {
|
||||
if (event.keyCode == KeyEvent.KEYCODE_UNKNOWN) {
|
||||
|
|
@ -138,10 +143,11 @@ class KeyHandler(private val dispatcher: Dispatcher, private val compatMode: Boo
|
|||
}
|
||||
|
||||
/**
|
||||
* Emits a KeySym for given event details.
|
||||
* Emits an event for given details.
|
||||
* It will call [emitForAndroidKeyCode] or [emitForUnicodeChar] depending on arguments.
|
||||
*/
|
||||
private fun emitForKeyEvent(keyCode: Int, unicodeChar: Int, isDown: Boolean): Boolean {
|
||||
private fun emitForKeyEvent(keyCode: Int, unicodeChar: Int, isDown: Boolean, scanCode: Int = 0): Boolean {
|
||||
val xtCode = if (scanCode == 0) 0 else XTKeyCode.fromAndroidScancode(scanCode)
|
||||
|
||||
if (handleDiacritics(keyCode, unicodeChar, isDown))
|
||||
return true
|
||||
|
|
@ -153,7 +159,7 @@ class KeyHandler(private val dispatcher: Dispatcher, private val compatMode: Boo
|
|||
KeyEvent.KEYCODE_NUMPAD_ENTER,
|
||||
KeyEvent.KEYCODE_SPACE,
|
||||
KeyEvent.KEYCODE_TAB ->
|
||||
return emitForAndroidKeyCode(keyCode, isDown)
|
||||
return emitForAndroidKeyCode(keyCode, isDown, xtCode)
|
||||
}
|
||||
|
||||
// We prefer to use unicodeChar even when keyCode is available because
|
||||
|
|
@ -162,26 +168,26 @@ class KeyHandler(private val dispatcher: Dispatcher, private val compatMode: Boo
|
|||
// it works well with these servers.
|
||||
|
||||
if (unicodeChar != 0)
|
||||
return emitForUnicodeChar(unicodeChar, isDown)
|
||||
return emitForUnicodeChar(unicodeChar, isDown, xtCode)
|
||||
else
|
||||
return emitForAndroidKeyCode(keyCode, isDown)
|
||||
return emitForAndroidKeyCode(keyCode, isDown, xtCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits X KeySym corresponding to [keyCode]
|
||||
*/
|
||||
private fun emitForAndroidKeyCode(keyCode: Int, isDown: Boolean): Boolean {
|
||||
private fun emitForAndroidKeyCode(keyCode: Int, isDown: Boolean, xtCode: Int = 0): Boolean {
|
||||
val keySym = XKeySymAndroid.getKeySymForAndroidKeyCode(keyCode)
|
||||
return emit(keySym, isDown)
|
||||
return emit(keySym, isDown, xtCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits either Unicode KeySym or legacy KeySym for [uChar], depending on [compatMode].
|
||||
* Emits either Unicode KeySym or legacy KeySym for [uChar], depending on [cfLegacyKeysym].
|
||||
*/
|
||||
private fun emitForUnicodeChar(uChar: Int, isDown: Boolean): Boolean {
|
||||
private fun emitForUnicodeChar(uChar: Int, isDown: Boolean, xtCode: Int = 0): Boolean {
|
||||
var uKeySym = 0
|
||||
|
||||
if (compatMode)
|
||||
if (cfLegacyKeysym)
|
||||
uKeySym = XKeySymUnicode.getLegacyKeySymForUnicodeChar(uChar)
|
||||
|
||||
if (uKeySym == 0)
|
||||
|
|
@ -196,7 +202,7 @@ class KeyHandler(private val dispatcher: Dispatcher, private val compatMode: Boo
|
|||
if (shouldFakeShift)
|
||||
emitForAndroidKeyCode(KeyEvent.KEYCODE_SHIFT_LEFT, true)
|
||||
|
||||
emit(uKeySym, isDown)
|
||||
emit(uKeySym, isDown, xtCode)
|
||||
|
||||
if (shouldFakeShift)
|
||||
emitForAndroidKeyCode(KeyEvent.KEYCODE_SHIFT_LEFT, false)
|
||||
|
|
@ -204,14 +210,15 @@ class KeyHandler(private val dispatcher: Dispatcher, private val compatMode: Boo
|
|||
return true
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sends given [keySym] to [dispatcher].
|
||||
* Sends given X key to [dispatcher].
|
||||
*/
|
||||
private fun emit(keySym: Int, isDown: Boolean): Boolean {
|
||||
private fun emit(keySym: Int, isDown: Boolean, xtCode: Int = 0): Boolean {
|
||||
if (keySym == 0)
|
||||
return false
|
||||
|
||||
return dispatcher.onXKeySym(keySym, isDown)
|
||||
return dispatcher.onXKey(keySym, xtCode, isDown)
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -244,7 +251,7 @@ class KeyHandler(private val dispatcher: Dispatcher, private val compatMode: Boo
|
|||
// 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 doesn't
|
||||
// the number keycode (e.g. KEYCODE_NUMPAD_7) first. And if apps don't
|
||||
// handle that, it will fallback to secondary action (e.g. KEYCODE_MOVE_HOME).
|
||||
// So we have to ignore the first events when NumLock is off.
|
||||
if ((keyCode in KeyEvent.KEYCODE_NUMPAD_0..KeyEvent.KEYCODE_NUMPAD_9
|
||||
|
|
@ -340,12 +347,12 @@ class KeyHandler(private val dispatcher: Dispatcher, private val compatMode: Boo
|
|||
private fun toCodePoints(string: String): IntArray {
|
||||
//Handle simple & most probable case
|
||||
if (string.length == 1)
|
||||
return cpCache.apply { this[0] = string[0].toInt() }
|
||||
return cpCache.apply { this[0] = string[0].code }
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
return string.codePoints().toArray()
|
||||
|
||||
//Otherwise, do simple conversion (will be incorrect non-MBP code points)
|
||||
return string.map { it.toInt() }.toIntArray()
|
||||
return string.map { it.code }.toIntArray()
|
||||
}
|
||||
}
|
||||
314
app/src/main/java/com/gaurav/avnc/ui/vnc/LayoutManager.kt
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
170
app/src/main/java/com/gaurav/avnc/ui/vnc/LoginFragment.kt
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/*
|
||||
* 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
|
||||
}
|
||||
|
|
@ -8,9 +8,11 @@
|
|||
|
||||
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
|
||||
|
|
@ -115,17 +117,18 @@ 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)) {
|
||||
if (event.isFromSource(InputDevice.SOURCE_STYLUS) &&
|
||||
event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS) {
|
||||
stylusGestureDetector.onTouchEvent(event)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
inner class StylusGestureListener : GestureDetector.SimpleOnGestureListener() {
|
||||
inner class StylusGestureListener : SimpleOnGestureListener() {
|
||||
private var scrolling = false
|
||||
|
||||
override fun onDown(e: MotionEvent?): Boolean {
|
||||
override fun onDown(e: MotionEvent): Boolean {
|
||||
scrolling = false
|
||||
return true
|
||||
}
|
||||
|
|
@ -145,7 +148,7 @@ class TouchHandler(private val viewModel: VncViewModel, private val dispatcher:
|
|||
dispatcher.onStylusLongPress(e.point())
|
||||
}
|
||||
|
||||
override fun onScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
|
||||
override fun checkedOnScroll(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) {
|
||||
|
||||
|
|
@ -163,25 +166,17 @@ class TouchHandler(private val viewModel: VncViewModel, private val dispatcher:
|
|||
|
||||
|
||||
/****************************************************************************************
|
||||
* Finger Gestures
|
||||
*
|
||||
* In addition to gestures supported by [GestureDetector] & [ScaleGestureDetector],
|
||||
* we use [DragDetector] & [MultiFingerTapDetector] for more gestures.
|
||||
*
|
||||
* Finger Gestures (and everything else beside mouse & stylus)
|
||||
****************************************************************************************/
|
||||
private val scaleDetector = ScaleGestureDetector(viewModel.app, this).apply { isQuickScaleEnabled = false }
|
||||
private val gestureDetector = GestureDetector(viewModel.app, this)
|
||||
private val multiFingerTapDetector = MultiFingerTapDetector()
|
||||
private val dragDetector = DragDetector()
|
||||
private val gestureDetector = GestureDetectorEx(viewModel.app, FingerGestureListener())
|
||||
private val swipeVsScale = SwipeVsScale()
|
||||
private val dragEnabled = viewModel.pref.input.gesture.dragEnabled
|
||||
private val longPressSwipeEnabled = viewModel.pref.input.gesture.longPressSwipeEnabled
|
||||
private val swipeSensitivity = viewModel.pref.input.gesture.swipeSensitivity
|
||||
|
||||
|
||||
private fun handleGestureEvent(event: MotionEvent): Boolean {
|
||||
swipeVsScale.onTouchEvent(event)
|
||||
dragDetector.onTouchEvent(event)
|
||||
multiFingerTapDetector.onTouchEvent(event)
|
||||
scaleDetector.onTouchEvent(event)
|
||||
return gestureDetector.onTouchEvent(event)
|
||||
}
|
||||
|
|
@ -194,163 +189,249 @@ class TouchHandler(private val viewModel: VncViewModel, private val dispatcher:
|
|||
return true
|
||||
}
|
||||
|
||||
override fun onDown(e: MotionEvent) = true
|
||||
private inner class FingerGestureListener : GestureDetectorEx.GestureListenerEx {
|
||||
|
||||
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
|
||||
dispatcher.onTap1(e.point())
|
||||
return true
|
||||
}
|
||||
override fun onSingleTapConfirmed(e: MotionEvent) = dispatcher.onTap1(e.point())
|
||||
override fun onDoubleTapConfirmed(e: MotionEvent) = dispatcher.onDoubleTap(e.point())
|
||||
|
||||
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)
|
||||
override fun onMultiFingerTap(e: MotionEvent, fingerCount: Int) {
|
||||
when (fingerCount) {
|
||||
2 -> dispatcher.onTap2(e.point())
|
||||
// Taps by 3+ fingers are not exposed yet
|
||||
}
|
||||
}
|
||||
|
||||
multiFingerTapDetector.reset()
|
||||
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())
|
||||
}
|
||||
|
||||
override fun onLongPressConfirmed(e: MotionEvent) {
|
||||
if (longPressSwipeEnabled) dispatcher.onLongPress(e.point())
|
||||
}
|
||||
|
||||
|
||||
override fun onScroll(e1: MotionEvent, e2: MotionEvent, dx: Float, dy: Float) {
|
||||
val startPoint = e1.point()
|
||||
val currentPoint = e2.point()
|
||||
val normalizedDx = dx * swipeSensitivity
|
||||
val normalizedDy = dy * swipeSensitivity
|
||||
|
||||
when (e2.pointerCount) {
|
||||
1 -> dispatcher.onSwipe1(startPoint, currentPoint, normalizedDx, normalizedDy)
|
||||
2 -> if (swipeVsScale.shouldSwipe())
|
||||
dispatcher.onSwipe2(startPoint, currentPoint, normalizedDx, normalizedDy)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onScrollAfterLongPress(e1: MotionEvent, e2: MotionEvent, dx: Float, dy: Float) {
|
||||
dispatcher.onLongPressSwipe(e1.point(), e2.point(), dx, dy)
|
||||
}
|
||||
|
||||
override fun onScrollAfterDoubleTap(e1: MotionEvent, e2: MotionEvent, dx: Float, dy: Float) {
|
||||
dispatcher.onDoubleTapSwipe(e1.point(), e2.point(), dx, dy)
|
||||
}
|
||||
|
||||
override fun onFling(velocityX: Float, velocityY: Float) {
|
||||
dispatcher.onFling(velocityX, velocityY)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility class for detecting drag gesture.
|
||||
* Stock [GestureDetector] only detects the most common gestures. But we need to
|
||||
* detect some more gestures to provide maximum flexibility to the user.
|
||||
*
|
||||
* 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].
|
||||
* [GestureDetectorEx] is used to for this purpose. It internally uses stock
|
||||
* [GestureDetector], and some custom event processing to detect more gestures.
|
||||
*/
|
||||
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 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
/**
|
||||
* 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) }
|
||||
|
||||
dispatcher.onDrag(startPoint, e2.point(), -dx, -dy)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
fun onLongPress(e: MotionEvent) {
|
||||
longPressDetected = true
|
||||
startPoint = e.point()
|
||||
}
|
||||
|
||||
fun onTouchEvent(event: MotionEvent) {
|
||||
if (!dragEnabled)
|
||||
return
|
||||
|
||||
scrollDetector.onTouchEvent(event)
|
||||
|
||||
val action = event.actionMasked
|
||||
if (action == MotionEvent.ACTION_UP && longPressDetected && !isDragging)
|
||||
dispatcher.onLongPress(event.point())
|
||||
|
||||
when (action) {
|
||||
MotionEvent.ACTION_UP,
|
||||
MotionEvent.ACTION_POINTER_DOWN,
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
longPressDetected = false
|
||||
isDragging = false
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects 'tapping' by two or more fingers.
|
||||
*
|
||||
* 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 inner class MultiFingerTapDetector {
|
||||
private var startEvent: MotionEvent? = null
|
||||
private var fingerCount = 0
|
||||
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)
|
||||
|
||||
fun onTouchEvent(e: MotionEvent) {
|
||||
when (e.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
maxFingerDown = 1
|
||||
currentDownEvent = MotionEvent.obtain(e)
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_DOWN -> startEvent = MotionEvent.obtain(e)
|
||||
MotionEvent.ACTION_POINTER_DOWN -> fingerCount = max(fingerCount, e.pointerCount)
|
||||
MotionEvent.ACTION_CANCEL -> reset()
|
||||
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_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
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
startEvent?.recycle()
|
||||
startEvent = null
|
||||
fingerCount = 0
|
||||
private fun reset() {
|
||||
longPressDetected = false
|
||||
doubleTapDetected = false
|
||||
scrolling = false
|
||||
maxFingerDown = 0
|
||||
currentDownEvent?.recycle()
|
||||
currentDownEvent = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Swipe vs Scale detector.
|
||||
*
|
||||
|
|
@ -401,8 +482,8 @@ class TouchHandler(private val viewModel: VncViewModel, private val dispatcher:
|
|||
if (detecting) {
|
||||
f1Id = e.getPointerId(0)
|
||||
f2Id = e.getPointerId(1)
|
||||
f1Start.set(e.getX(f1Id), e.getY(f1Id))
|
||||
f2Start.set(e.getX(f2Id), e.getY(f2Id))
|
||||
f1Start.set(e.getX(0), e.getY(0))
|
||||
f2Start.set(e.getX(1), e.getY(1))
|
||||
f1Current.set(f1Start)
|
||||
f2Current.set(f2Start)
|
||||
}
|
||||
|
|
@ -410,8 +491,12 @@ class TouchHandler(private val viewModel: VncViewModel, private val dispatcher:
|
|||
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
if (detecting) {
|
||||
f1Current.set(e.getX(f1Id), e.getY(f1Id))
|
||||
f2Current.set(e.getX(f2Id), e.getY(f2Id))
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,47 +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.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("")
|
||||
}
|
||||
}
|
||||