Compare commits

...
Sign in to create a new pull request.

162 commits

Author SHA1 Message Date
Jérémy Munsch
84d45032fa
Fix e.message nullable after bump upstream
Some checks are pending
Main / build (push) Waiting to run
2023-09-14 15:05:32 +02:00
Jérémy Munsch
41c25ca049
Version 1.0.0 for release
Upgrade and fixes
- Fixes #2 change default pwd
- Fixes #5 change kios info display layout, removes refresh button
- Fixes #4 make ping status more visible
- Fixes #3 Make actual wifi network visible on wifi list
- Fix EventObserver LifeCycle on KioskActivity
- update git ignore
- Change Json schema validate lib + resolve fucking pro guard rules with it
- Fix password validation if password is too weak on server
2023-09-14 14:56:55 +02:00
Vincent BRIDIER
d5dfdbdaaa
Version 1.0.0 for release 2023-09-14 14:55:05 +02:00
Gaurav Ujjwal
66c9ee9095 Release v2.2.3 2023-05-29 21:36:18 +05:30
Gaurav Ujjwal
52bc4cd6b5 Show dynamic gesture button by default 2023-05-29 21:36:18 +05:30
Weblate (bot)
e5d98d7d52
Translations update from Hosted Weblate (#173)
* Updated: Chinese (Traditional)
* Updated: Russian
* Updated: Turkish
* Updated: German

---------

Co-authored-by: babaric dev <superbbruhmoment@gmail.com>
Co-authored-by: Evgeniy Khramov <thejenjagamertjg@gmail.com>
Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Co-authored-by: Ettore Atalan <atalanttore@googlemail.com>
2023-05-29 21:16:02 +05:30
Gaurav Ujjwal
0d994b6fb5 Check FB size before trying to coerce pointer position 2023-05-22 20:54:05 +05:30
Gaurav Ujjwal
37b4df9f4c Allow dragging with middle button 2023-05-22 16:32:40 +05:30
Gaurav Ujjwal
1aedcb6dfa Refactor drag action 2023-05-22 15:07:36 +05:30
Gaurav Ujjwal
60fa2cee20 Fix Long-press-swipe pref entries 2023-05-22 14:57:23 +05:30
Gaurav Ujjwal
ca64c76316 Release v2.2.2 2023-05-17 19:45:49 +05:30
Gaurav Ujjwal
ce5d64214c Update string names 2023-05-17 19:42:13 +05:30
Weblate (bot)
a801f29572
Translations update from Hosted Weblate (#168)
* Updated: French
* Updated: Turkish
* Updated: German
* Updated: Russian
* Updated: Polish
* Updated: Korean

---------

Co-authored-by: Nathan <bonnemainsnathan@gmail.com>
Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Ettore Atalan <atalanttore@googlemail.com>
Co-authored-by: Evgeniy Khramov <thejenjagamertjg@gmail.com>
Co-authored-by: WaldiS <sto@tutanota.de>
Co-authored-by: Hoseok Seo <ddinghoya@gmail.com>
Co-authored-by: Gaurav Ujjwal <gujjwal00@gmail.com>
2023-05-17 19:30:05 +05:30
Gaurav Ujjwal
f2b994774f Fix Option key not working in macOS
Alt key is used to invoke the Option key in macOS. But instead of XK_Alt,
the server expects XK_Meta keysym.

Re: #163
2023-05-17 10:02:11 +05:30
Gaurav Ujjwal
8b0e4f845c Disable update-pausing in background
This still needs some improvements
2023-05-17 10:02:11 +05:30
Gaurav Ujjwal
385e7b8aaf Fix crash caused by incorrect VNC URIs
If user passed 'vnc:something' instead of 'vnc://something', it would
lead to a crash because 'vnc:something' is not a valid VNC URI.
2023-05-17 10:02:11 +05:30
Gaurav Ujjwal
505abaa0b1 Pause framebuffer updates when app is in background 2023-05-17 10:00:15 +05:30
Gaurav Ujjwal
425e25d10f Refactor VncClient state check 2023-05-17 10:00:15 +05:30
Gaurav Ujjwal
d04e4e1a4f Implement resize-to-local-window
Re: #142
2023-05-17 10:00:15 +05:30
Gaurav Ujjwal
8d1eeba474 Enable app shortcuts 2023-04-22 14:34:12 +05:30
Gaurav Ujjwal
7ec479d84a Refactor UriReceiverActivity
- Renamed it to IntentReceiverActivity
- Load profile asynchronously, instead of blocking main thread
- Use splash theme
- Added tests
2023-04-22 09:56:30 +05:30
Gaurav Ujjwal
cbbe1d5f28 Update test libraries 2023-04-21 21:19:09 +05:30
Gaurav Ujjwal
42b8fb8d79 Make SSH passwords optional when creating profile
This affects both login password & private key password. These will be
queried using LoginFragment if not available in profile.

LoginInfo now serves as a generic wrapper for credentials, instead of
being tied to VNC credentials.

Re: #131
2023-04-08 20:35:25 +05:30
Gaurav Ujjwal
8408af806a Fix raw key tests 2023-04-07 15:56:28 +05:30
Gaurav Ujjwal
41b69fd996 Refactor SSH tunnel opening
Make sure LocalPortForwarder is closed even if VncClient.connect() throws an exception
2023-04-04 16:05:57 +05:30
Gaurav Ujjwal
681fd315f1 Refactor encrypted key test 2023-04-04 15:34:13 +05:30
Gaurav Ujjwal
22bad9c320 Refactor login fragment 2023-04-03 16:18:01 +05:30
Gaurav Ujjwal
8261a295a7 Refactor build script
Switch to 'plugins' DSL for plugin specification
2023-04-01 21:58:20 +05:30
Gaurav Ujjwal
a774989053 Update Kotlin version 2023-03-29 23:57:50 +05:30
Gaurav Ujjwal
f248789e31 Release v2.2.1 2023-03-28 10:58:14 +05:30
Weblate (bot)
e336d4d23d
Translations update from Hosted Weblate (#158)
Added new language using Weblate (Portuguese)

Translated using Weblate (Chinese (Traditional))
Translated using Weblate (Polish)
Translated using Weblate (Korean)
Translated using Weblate (Turkish)

---------

Co-authored-by: babaric dev <superbbruhmoment@gmail.com>
Co-authored-by: ssantos <ssantos@web.de>
Co-authored-by: gnu-ewm <gnu.ewm@protonmail.com>
Co-authored-by: Hoseok Seo <ddinghoya@gmail.com>
Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
2023-03-28 09:19:31 +05:30
Gaurav Ujjwal
cb992d6396 Update zoom reset behavior
- Long-press on 'Zoom options' will reset to default zoom
- Long-press on 'Reset zoom' will reset to 100% zoom
2023-03-28 09:00:31 +05:30
Gaurav Ujjwal
65b1144b2e Reset to saved zoom instead of 100%
Re: #108
2023-03-26 22:54:06 +05:30
Gaurav Ujjwal
8169500562 Add test for Meta key shortcut 2023-03-21 19:19:27 +05:30
Gaurav Ujjwal
4ebc1fa547 Add arrows to toggle buttons in toolbar 2023-03-21 19:15:55 +05:30
Gaurav Ujjwal
afd6fa53b7 Use RecyclerView for rendering logs
Using RV improves performance for large logs, avoiding ANR issues.
2023-03-21 19:14:22 +05:30
Gaurav Ujjwal
c50a511743 Update submodule 2023-03-20 21:34:52 +05:30
Gaurav Ujjwal
9bfdc85a8e Add a check for wrong file selection during private key import
Also added some tests for PK import.
2023-03-19 13:35:34 +05:30
Gaurav Ujjwal
59765052b5 Handle possible null InputDevice 2023-03-12 16:51:51 +05:30
Gaurav Ujjwal
af5e6652f2 Release v2.2.0 2023-03-11 20:30:29 +05:30
Gaurav Ujjwal
7e178bac0d Fix Client-to-Server clipboard sharing 2023-03-11 20:30:29 +05:30
Gaurav Ujjwal
fcebf87d98 Ensure frame is re-rendered when safe-area is changed
Otherwise frame is not refreshed immediately when virtual keys are toggled.
2023-03-11 19:18:25 +05:30
Gaurav Ujjwal
53c09d064a Revert "Ignore IDs of imported profiles"
This reverts commit bc0f472163.
2023-03-11 16:50:21 +05:30
Gaurav Ujjwal
836f33cf8a Temporarily disable app shortcuts
It turns out that some launchers (e.g. from MIUI) have weired handling for
app shortcuts, so this feature needs further testing.

Also, current implementation doesn't support API <26.
2023-03-11 16:50:04 +05:30
Weblate (bot)
f58f4d49e7
Translations update from Hosted Weblate (#152)
* Translated using Weblate (French)
* Translated using Weblate (Turkish)
* Translated using Weblate (German)
* Translated using Weblate (Chinese (Traditional))

---------

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Co-authored-by: Ettore Atalan <atalanttore@googlemail.com>
Co-authored-by: babaric dev <superbbruhmoment@gmail.com>
Co-authored-by: Aaron Dewes <aaron.dewes@protonmail.com>
Co-authored-by: Pure Incompetence <inc@mailbox.org>
2023-03-11 16:35:45 +05:30
Gaurav Ujjwal
b0dcdfd78b Improve clipboard handling
- Handle possible errors in getClipboardText()
- Copy/paste asynchronously form VncViewModel
2023-03-11 16:10:47 +05:30
Gaurav Ujjwal
25827423eb Revert "Don't paste text on clipboard when app is in background"
This reverts commit e4feff0488.
2023-03-10 16:51:14 +05:30
Gaurav Ujjwal
e2abfdafcc Enable fling animation only when 1-finger-swipe is set to "pan" 2023-03-10 16:33:34 +05:30
Gaurav Ujjwal
53ac22d7c1 Implement 'Extended Clipboard' support
This enables UTF-8 encoded clipboard sharing.

Re: #121
2023-03-10 15:47:05 +05:30
Gaurav Ujjwal
06e5407a0f Implement 'QEMU Extended Key Event' support
- If available, AVNC will send raw XT scancodes to servers.
- Servers support for extended key event is required for this to work.

Re: #129 & #149
2023-03-10 10:48:44 +05:30
Gaurav Ujjwal
4b118e853d Allow gesture style to be changed from viewer
This adds a toggle to toolbar, which allows user to change gesture style
during an active session.

This toggle is temporarily gated behind 'Viewer -> Show gesture style toggle' pref.
2023-03-06 09:47:46 +05:30
Gaurav Ujjwal
bc0f472163 Ignore IDs of imported profiles
This ensures that IDs of deleted profiles are not reused again, and
we can disable shortcuts of deleted profiles without ambiguity.
2023-03-03 12:24:24 +05:30
Ettore Atalan
01a44963ec Translated using Weblate (German) 2023-03-02 19:52:14 +05:30
babaric dev
502a01c4a3 Translated using Weblate (Chinese (Traditional))
Translated using Weblate (Chinese (Traditional))

Translated using Weblate (Chinese (Traditional))

Translated using Weblate (Chinese (Traditional))

Translated using Weblate (Chinese (Traditional))

Added new language using Weblate (Chinese (Traditional))

Translate-URL: https://hosted.weblate.org/projects/avnc/app/zh_Hant/
2023-03-02 19:52:14 +05:30
Gaurav Ujjwal
183e50f9cb Update submodule 2023-03-02 19:46:41 +05:30
Gaurav Ujjwal
e89bbc5243 Add test for automatic opening of virtual keys 2023-03-01 09:19:02 +05:30
Gaurav Ujjwal
b5fd61433c Use DecorView for manual insets calculation
Insets are calculated in Window's coordinate space. Using DecorView is
much easier and consistent with our implementation on API 30+.

This fixes incorrect inset calculation on screens with display-cutout
when fullscreen mode is disabled.

Re: #139
2023-03-01 09:17:19 +05:30
Gaurav Ujjwal
2b17828cd3 Make FrameView behave like a real editor
It allows Android 12+ to automatically restore IME after switching tasks.
Re: #139
2023-02-28 12:59:32 +05:30
Gaurav Ujjwal
7e2f91e3c4 Implement app shortcuts
Re: #127
2023-02-21 15:34:00 +05:30
Gaurav Ujjwal
68af560709 Allow 'Remote drag' action for one-finger swipe
Re: 137
2023-02-18 08:35:44 +05:30
Gaurav Ujjwal
ae31db2d31 Improve server item layout
- Allow SSH host address to wrap when space is limited
- Refactor hint icons to use a common style
- Update ConstraintLayout library version

Re: #140
2023-02-18 08:35:05 +05:30
Gaurav Ujjwal
4e4f795bb0 Move FrameStateTest to instrumentation tests
Since introduction of safe-area FrameStateTest depends on framework classes
2023-02-10 09:20:35 +05:30
Gaurav Ujjwal
e4feff0488 Don't paste text on clipboard when app is in background
This is an attempt to fix mysterious ANRs being reported on Google Play
2023-02-09 17:11:23 +05:30
Gaurav Ujjwal
d803aa469a Messenger: Allow already scheduled tasks to execute during cleanup
If button-down event is already sent but button-up is still in the queue,
forced shutdown can leave remote server in invalid state. Graceful shutdown
of sender thread reduces the chances of such states.
2023-02-07 16:13:24 +05:30
Gaurav Ujjwal
bb81bec87b Fix delayed-click implementation
- Insert delay only for left-click
- Ignore thread interruption during sleep
2023-02-07 15:42:37 +05:30
Gaurav Ujjwal
3e0c2e9808 Release v2.1.2 2023-02-05 10:09:18 +05:30
Weblate (bot)
5a2daeaaf0
Translations update from Hosted Weblate (#134)
* Added new language using Weblate (Chinese (Traditional))
* Translated using Weblate (Polish)
* Translated using Weblate (Korean)

---------

Co-authored-by: babaric dev <superbbruhmoment@gmail.com>
Co-authored-by: WaldiS <sto@tutanota.de>
Co-authored-by: Hoseok Seo <ddinghoya@gmail.com>
2023-02-05 09:59:23 +05:30
Gaurav Ujjwal
6f6c12965a Exclude corner & cutout insets from safe area
Handling these insets allows the frame to pan out of screen at 100% zoom.
This is unexpected from user's POV.
2023-02-05 09:42:31 +05:30
Gaurav Ujjwal
5308c9da81 Update dependencies 2023-02-03 08:58:13 +05:30
Gaurav Ujjwal
b852387b5e Release v2.1.1 2023-02-02 07:46:49 +05:30
Gaurav Ujjwal
d13efb088d Fix SSH tunnel
'localPort' of ServerSocket was not correctly referenced.
2023-02-02 07:44:48 +05:30
Gaurav Ujjwal
d5755ef0fa Release v2.1.0 2023-02-01 15:59:59 +05:30
Gaurav Ujjwal
875bf8f2a3 Update libjpeg-turbo 2023-02-01 15:43:42 +05:30
Weblate (bot)
4469065c79
Translations update from Hosted Weblate (#132)
* Translated using Weblate (Ukrainian)
* Translated using Weblate (French)
* Translated using Weblate (Turkish)

---------

Co-authored-by: kukuruzka <anton.dan1657@gmail.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
2023-02-01 15:41:19 +05:30
Gaurav Ujjwal
3fab34e7ca Update submodules 2023-01-31 21:57:21 +05:30
Gaurav Ujjwal
62836ec8b7 Disable welcome message
It will probably be used for next major update
2023-01-31 21:57:21 +05:30
Gaurav Ujjwal
77cbb8303f Lint: Use resource wrapper to automatically close socket 2023-01-31 21:57:21 +05:30
Gaurav Ujjwal
dba5c3ebe8 Implement per-server orientation option 2023-01-31 21:57:21 +05:30
Gaurav Ujjwal
bb9dc41f50 Implement ability to delay button-up event 2023-01-31 21:57:21 +05:30
Gaurav Ujjwal
29fb39df00 Upgrade database to v3
- Switched to auto-migrations to handle schema changes across versions
- Added migration test
2023-01-31 21:57:21 +05:30
Gaurav Ujjwal
87af8811fb Add 'Double tap and swipe' gesture
Default action is to drag remote content.
Previous 'Drag' gesture has been renamed to 'Long press and swipe' clarify it.
2023-01-18 19:04:41 +05:30
Gaurav Ujjwal
e109989fc4 Implement Auto-reconnect 2023-01-18 12:13:11 +05:30
Gaurav Ujjwal
ceca58e78d Use simple thread in TestServer instead of coroutines 2023-01-18 11:39:58 +05:30
Gaurav Ujjwal
b4bd5d7584 Remove insets logging 2023-01-18 07:25:54 +05:30
Gaurav Ujjwal
5e4ee94e45 Refactor profile handling in VncActivity
- Use VncViewModel's profile for retrying connection
- Show an error message instead of crashing when profile is missing from intent
2023-01-16 22:54:33 +05:30
Gaurav Ujjwal
e2487d1a2f Increase tappable area of "remember login" checkbox 2023-01-16 22:51:26 +05:30
Gaurav Ujjwal
926d50d371 Synchronize sender thread's execution & shutdown
Makes sure that sender thread can not be used after shutdown.
2023-01-16 22:51:22 +05:30
Gaurav Ujjwal
6cfa44dc94 Re-implemented layout handling, added LayoutManager
- Better support for various insets: cutouts, nav bar, rounded corners
- Introduced 'safe area' concept to to handle partially obstructing insets
2023-01-16 22:49:16 +05:30
Gaurav Ujjwal
a65cb7af26 Use pure black for frame background 2023-01-16 19:56:34 +05:30
Gaurav Ujjwal
5c4e3701d6 Build: Disable use of Jetifier
Doesn't seem to be needed anymore.
2023-01-16 15:39:16 +05:30
Gaurav Ujjwal
7ef81be406 Show SSH host address in server list 2023-01-16 15:18:45 +05:30
Gaurav Ujjwal
24d7e760ba Change 'Super' virtual key to a toggle 2023-01-16 13:59:41 +05:30
Gaurav Ujjwal
9f8e13813b Support 'ConnectionName' parameter in vnc:// URIs 2023-01-16 10:19:25 +05:30
Gaurav Ujjwal
71f84b594b Sync clipboard in 'onWindowFocusChanged()' instead of 'onResume()'
Since Android 10, multiple activities can simultaneously be in resumed state.
Specifically in multi-window mode, onPause()/onResume() are not called when
window focus changes, because activity is never paused.
So now we use onWindowFocusChanged() as a signal to sync clipboard.

Ref: https://source.android.com/docs/core/display/multi_display/multi-resume
GitHub issue: #62 & #120
2023-01-15 10:30:56 +05:30
Gaurav Ujjwal
b66e76cf33 PiP: Guard against possibility of frame size being 0 2023-01-15 09:57:52 +05:30
Gaurav Ujjwal
826f8c157b Release v2.0.4 2022-12-28 07:29:32 +05:30
Gaurav Ujjwal
3624de1757 Make sure FrameView is not visible until connected
This fixes a possible crash caused by unexpected events received in TouchHandler
before actually being connected.
2022-12-28 07:16:25 +05:30
Gaurav Ujjwal
754d44c0c3 Release v2.0.3 2022-12-25 22:19:45 +05:30
Weblate (bot)
96e73237dd
Translations update from Hosted Weblate (#114)
* Translated using Weblate (Chinese (Simplified))
* Translated using Weblate (Polish)

Co-authored-by: Eric <hamburger1024@duck.com>
Co-authored-by: WaldiS <sto@tutanota.de>
2022-12-25 22:17:45 +05:30
Gaurav Ujjwal
f0df38a4d4 Apply possible ANR fix: Attempt 2 2022-12-25 18:02:33 +05:30
Gaurav Ujjwal
3cac561413 Don't access frame state when FrameView is rendered in Android Studio 2022-12-21 20:50:38 +05:30
Gaurav Ujjwal
2eac4cbac7 Keep screen on only while connected 2022-12-20 21:09:05 +05:30
Gaurav Ujjwal
1c2a1009d0 Use PreComputedText for displaying debug logs 2022-12-20 21:08:59 +05:30
Gaurav Ujjwal
6189f4a741 Fix unexpected left-click being sent to server on long-press gesture 2022-12-19 21:26:02 +05:30
Gaurav Ujjwal
f384e0ba59 Add some basic tests for PrefsActivity 2022-12-19 20:33:26 +05:30
Gaurav Ujjwal
e4030d706f Fix 'Inver vertical scrolling' pref visibility 2022-12-19 19:17:32 +05:30
Gaurav Ujjwal
c36e02cca7 Add a warning for missing native library 2022-12-17 21:51:01 +05:30
Gaurav Ujjwal
8cc8085816 Refactor profile instantiation in VncActivity
- Direct reference to ServerProfile instance is removed from VncActivity
- Correct profile instance is provided to VncViewMode constructor via custom factory
- Profile is included in VncActivity's saved state
2022-12-17 21:10:06 +05:30
Gaurav Ujjwal
cc2b5600fd Release v2.0.2 2022-12-14 15:29:12 +05:30
Gaurav Ujjwal
8eae95ddd2 Attempt to fix a possible ANR 2022-12-14 14:37:53 +05:30
Gaurav Ujjwal
ac9a4ba248 Switch to compat WindowInset for fullscreen implementation 2022-12-14 14:37:53 +05:30
Gaurav Ujjwal
666c4d5592 Add workaround for null events received in onScroll() 2022-12-12 09:24:21 +05:30
Gaurav Ujjwal
1a1815cdb5 Refactor TouchHandler: Consolidate fingre gesture detection
Instead of using gesture-specific detectors, now a single consolidated
detector (GestureDetectorEx) is used. It allows us to share code common
to all detectors, and more easily extend gesture detection.

Detection for double-tap-drag gesture is also implemented.
2022-12-12 09:02:37 +05:30
Gaurav Ujjwal
4ffce9d1cb Properly flush output writer during server export
Fixes data not being written to exported file.
`use()` function is used to automatically close & flush the writer.
Reader used for import-file is updated too.

Also added tests for import/export.
2022-12-03 11:24:35 +05:30
Gaurav Ujjwal
577fdd1344 Ensure navigation-bar visibility on API 30 when keyboard is shown 2022-12-02 18:10:03 +05:30
Gaurav Ujjwal
c2e577e905 Release v2.0.1 2022-11-29 07:35:03 +05:30
Weblate (bot)
2fc764d42a
Translations update from Hosted Weblate (#103)
* Translated using Weblate (German)
* Translated using Weblate (Portuguese (Brazil))
* Translated using Weblate (Korean)

Co-authored-by: Ettore Atalan <atalanttore@googlemail.com>
Co-authored-by: m_s_ <mezysinc@mailon.site>
Co-authored-by: Hoseok Seo <ddinghoya@gmail.com>
2022-11-29 07:02:47 +05:30
Gaurav Ujjwal
32c1e18823 Improve support for multi-homed SSH hosts 2022-11-29 00:21:24 +05:30
Gaurav Ujjwal
d21e074fe7 Fix incorrect use of pointer-id in SwipeVsScale
Pointer-id was incorrectly used as pointer-index for getX()/getY().
2022-11-28 22:43:50 +05:30
Gaurav Ujjwal
0793a1f8c9 Refactor TouchHandlerTest: Re-implement event generation & injection
Generated events now matches much more closely with events generated by Android itself.
With this refactoring, brittleness of touch tests is almost gone.
2022-11-27 21:51:28 +05:30
Gaurav Ujjwal
3a5cdea968 Tighten stylus detection
Check tool-type in addition to event source
2022-11-15 17:21:39 +05:30
Gaurav Ujjwal
d1b7186036 Release v2 2022-11-09 23:44:52 +05:30
Weblate (bot)
453f923f69
Translations update from Hosted Weblate (#96)
* Translated using Weblate (Turkish)
* Translated using Weblate (Norwegian Bokmål)
* Translated using Weblate (Korean)
* Translated using Weblate (French)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: Hoseok Seo <ddinghoya@gmail.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: 이정희 <daemul72@gmail.com>
2022-11-09 23:22:56 +05:30
Gaurav Ujjwal
f7d8a4cf9e Improve tests
- Disable discovery for DiscoveryListTest to avoid timeout on progress bar
- Reduce flakiness of touch tests
- Refactor prefs usage
2022-11-09 22:42:01 +05:30
Gaurav Ujjwal
95e8d60fc1 Update screenshots 2022-11-09 22:23:50 +05:30
Gaurav Ujjwal
62579d30ae Update funding info
Removed LiberaPay because of limited support in my region.
2022-11-07 23:53:23 +05:30
Gaurav Ujjwal
b0c3e40876 Update submodule 2022-11-07 23:42:42 +05:30
Gaurav Ujjwal
9bc5d7fd5c Refactor AppPreferences 2022-11-07 23:03:16 +05:30
Gaurav Ujjwal
fdf7b35a53 Rename 'Natural scrolling' pref to 'Invert vertical scrolling' 2022-11-07 22:58:19 +05:30
Gaurav Ujjwal
1ba5374a8c Update metadata 2022-11-07 21:53:57 +05:30
Gaurav Ujjwal
7935ed9cb2 Avoid optimizing-out prefs member in App
New versions of R8 seem to inline the `prefs` member, which allows it to be
garbage-collected, breaking the theme selection.
2022-11-07 07:08:15 +05:30
Gaurav Ujjwal
e7007cce4f Add a welcome screen for v2 2022-11-06 12:01:07 +05:30
Gaurav Ujjwal
581572b6d0 Add virtual function keys 2022-11-04 10:26:49 +05:30
Gaurav Ujjwal
d871c3a35e Update Zoom Reset icon 2022-11-04 09:35:49 +05:30
Gaurav Ujjwal
c238fba911 Turn on per-orientation zoom by default 2022-11-02 21:57:26 +05:30
Gaurav Ujjwal
7712640d7b Remove translation for string alias 2022-11-01 10:19:17 +05:30
Gaurav Ujjwal
97d7809ff9 Remove usage of deprecated Char.toInt() 2022-11-01 09:42:20 +05:30
Gaurav Ujjwal
a6c87749c4 Fix broken tests for Discovery List 2022-10-31 23:04:13 +05:30
Gaurav Ujjwal
d70bec02bf Fix CMake version selection 2022-10-31 22:44:05 +05:30
Gaurav Ujjwal
a668486214 Update dependencies + submodules 2022-10-31 22:43:37 +05:30
Gaurav Ujjwal
5113b182c6 Fix D-pad navigation in urlbar activity 2022-10-30 15:50:39 +05:30
Gaurav Ujjwal
7e82923e19 Add debug tools for touch and key input testing 2022-10-30 12:52:04 +05:30
Gaurav Ujjwal
df37ba0a9f Use Kotlin coroutines for Dao methods 2022-10-28 18:20:29 +05:30
Eshagh shahidani
fdc090030d Translations from Hosted Weblate
Added new language using Weblate (Korean)
Translated using Weblate (Persian)
Translated using Weblate (Portuguese (Brazil))

Co-authored-by: Hoseok Seo <ddinghoya@gmail.com>
Co-authored-by: Eshagh shahidani <eshagh094@gmail.com>
Co-authored-by: m_s_ <m_s_@fastmy.info>
2022-10-26 16:38:53 +05:30
Gaurav Ujjwal
9e1ba4009c Move experimental cutout pref to Viewer section
Experimental pref section is now empty and has been removed.
2022-10-26 16:38:53 +05:30
Gaurav Ujjwal
e70bcc070a Typo-fix 2022-10-26 16:38:53 +05:30
Gaurav Ujjwal
6daa5f8ba3 Refactor security type in in Editor UI 2022-10-26 16:38:53 +05:30
Gaurav Ujjwal
ea62c5d54e Improve D-pad navigation in HomeActivity
- Make sure ViewPager doesn't steal the focus during navigation
- Hide RecyclerViews when server lists are empty

Related issue #73
2022-10-26 16:38:53 +05:30
Gaurav Ujjwal
468c73a43c Upgrade build system
- Update target API to 33
- Update Gradle & AGP
- Update CMake
- Update dependencies

Building AVNC now requires Java 11, and minimum version of Android Studio
is Chipmunk.

Fixed some warnings surfaced after upgrade.
2022-10-26 16:38:53 +05:30
Gaurav Ujjwal
dc57295ebb Misc. cleanup 2022-10-26 00:01:00 +05:30
Gaurav Ujjwal
5332c2c24c Stabilize 'Full immersive mode' option
Now, full immersive mode will be used automatically when 'Fullscreen'
viewer option is enabled.
2022-10-26 00:01:00 +05:30
Gaurav Ujjwal
faf1cb57e7 Stabilize 'Swipe to close toolbar' option 2022-10-26 00:01:00 +05:30
Gaurav Ujjwal
26b1f02bb9 Refactor login auto-completion
- Removed the preference to enable/disable it. The pref was added during
  early development of AVNC, and I was not fully sure about this feature.
  The implementation was a bit hacky, and I just wanted users to be able
  to disable it. Now, it will always be enabled.

- Fix the dropdown arrow to appear only when suggestions are available.

- Disable suggestions when saved servers are locked.
2022-10-26 00:01:00 +05:30
Gaurav Ujjwal
b73f6bd7b8 Add an experimental option to draw behind display cutout
GitHub issue: #91
2022-10-26 00:01:00 +05:30
Gaurav Ujjwal
c715fdfec6 Add per-orientation zoom levels & make default zoom configurable
- Users can enable per-orientation zoom to maintain separate zoom levels
in each orientation
- During a session, current zoom can be saved as default for the server

GitHub issue #77
2022-10-26 00:01:00 +05:30
Gaurav Ujjwal
a487e4dd94 Add 'Super' virtual key 2022-10-25 22:23:36 +05:30
Gaurav Ujjwal
31148fdcd3 Implement repeatable virtual keys 2022-10-25 22:23:36 +05:30
Gaurav Ujjwal
cb84cb2712 Add device-lock for saved servers
GitHub issue: #51
2022-10-25 22:23:36 +05:30
Gaurav Ujjwal
75e0d4324a Refactor biometric auth to new utility class DeviceAuthPrompt
DeviceAuthPrompt can now be re-used by upcoming feature of requiring
biometric authentication before connecting to a server.

- Add a workaround for broken fingerprint dialog
- Remove redundant message about auth status
2022-10-25 22:23:36 +05:30
Gaurav Ujjwal
4dbfaf4a71 Re-implement gesture-style selection
- Gesture-style can now also be selected on per-host basis.
- The two gesture styles are now named 'Touchscreen' & 'Touchpad'.
  Earlier these were 'Direct touch On/Off'.

GitHub Issue: #86
2022-10-25 22:23:36 +05:30
Gaurav Ujjwal
2e1a87b752 Implement image quality controls
Github issues: #69, #87
2022-10-21 22:52:01 +05:30
Gaurav Ujjwal
611a34b8b2 Extend database to v2 2022-10-21 10:02:20 +05:30
237 changed files with 10553 additions and 2042 deletions

2
.github/FUNDING.yml vendored
View file

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

View file

@ -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

View file

@ -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
View file

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

View file

@ -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>

View file

@ -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/)

View file

@ -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)
###############################################################################

View file

@ -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")
}

Binary file not shown.

Binary file not shown.

View file

@ -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(...);
}

View file

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

View file

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

View file

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

View file

@ -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() }
}
}

View file

@ -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) }

View file

@ -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) {

View file

@ -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)
}
}
}

View file

@ -10,8 +10,8 @@ package com.gaurav.avnc.ui.home
import android.app.Activity
import android.app.Instrumentation
import androidx.core.content.edit
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.swipeLeft
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
@ -23,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

View file

@ -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()
}
}

View file

@ -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")))
}
}

View file

@ -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)
}
}

View file

@ -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())
}
}

View file

@ -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()

View file

@ -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()
}
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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))
}

View file

@ -10,12 +10,12 @@ package com.gaurav.avnc.ui.vnc
import android.content.Context
import android.view.inputmethod.InputMethodManager
import androidx.core.content.edit
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onIdle
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.contrib.DrawerActions
import androidx.test.espresso.matcher.ViewMatchers.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)
}
}

View file

@ -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")
}
}

View file

@ -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)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

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

View file

@ -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>

View 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"]
}

View file

@ -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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View file

@ -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)
}
}

View file

@ -95,7 +95,7 @@ import com.gaurav.avnc.vnc.VncClient
* - Connection to VNC server is managed by [VncViewModel], using [VncClient].
* - [VncClient] is a wrapper around native `rfbClient` from LibVNCClient.
*
* - [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.
* -

View file

@ -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
}
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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()
}

View file

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

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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() {

View file

@ -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 {

View 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()
}
}

View 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())
}
}
}

View file

@ -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"
}
}

View file

@ -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()
})
}
}

View file

@ -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()
})
}
}

View 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)
}
}

View file

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

View file

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

View file

@ -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()
})
}
}

View file

@ -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())
}
}

View 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
}
}

View file

@ -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")
}
}
}

View 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
}
)
}
}
}

View file

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

View file

@ -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
}
}

View file

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

View file

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

View file

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

View file

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

View 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)
}
}

View file

@ -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!"
}
}
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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)

View file

@ -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.
}
}

View file

@ -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)

View file

@ -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()}

View file

@ -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()
}

View file

@ -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()
}
}

View 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)
}
}

View 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
}

View file

@ -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))
}
}
}
}

View file

@ -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("")
}
}

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