Compare commits

..

187 Commits

Author SHA1 Message Date
Jonathan Nobels f8f2ee029a
android: fix all linter warnings and treat warnings as errors (#521)
#Updates tailscale/corp#22284

Fixes and/or explicitly suppresses all linter warnings and
we will now fail the build if new warnings are introduced.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
3 days ago
kari-ts 08ae018468
android: send Android logs to logz (#515)
TSLog sends log messages to Android's logcat and Tailscale's logger
Libtailscale wrapper is a Kotlin wrapper that allows us to get around the problems with mocking a native library

Fixes tailscale/corp#23191

Signed-off-by: kari-ts <kari@tailscale.com>
4 days ago
Brad Fitzpatrick f26a828cbd Makefile: use "tailscale_go" build tag when using Tailscale's Go toolchain
Updates tailscale/tailscale#13527

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
1 week ago
kari-ts 9731afd44c
android: use PackageManager to determine install AppSourceChecker (#517)
We were using MaybeGoogle to determine whether the app was installed from the Play Store, but this has not worked since the refactor.
Fixes tailscale/tailscale#13442
Updates tailscale/corp#23283

Signed-off-by: kari-ts <kari@tailscale.com>
1 week ago
kari-ts 9654bb5d9d
android: include hex in LoginQRView (#502)
Updates tailscale/tailscale#13277

Signed-off-by: kari-ts <kari@tailscale.com>
1 week ago
kari-ts 2ec7304092
android: use onSuccess parameter in setWantRunning (#516)
Previously we were never actually invoking this parameter
We previously weren't setting vpnActive after closing IPNService

Updates tailscale/corp#22284

Signed-off-by: kari-ts <kari@tailscale.com>
1 week ago
kari-ts 22de0cdb7e
android: make custom url check case-insensitive (#513)
Fixes tailscale/corp#23210

Signed-off-by: kari-ts <kari@tailscale.com>
Co-authored-by: Jonathan Nobels <jnobels@gmail.com>
1 week ago
kari-ts fc8ccc0057
go/toolchain: use ed9dc37b2b000f376a3e819cbb159e2c17a2dac6 (#514)
Updates #cleanup

Signed-off-by: kari-ts <kari@tailscale.com>
1 week ago
Jonathan Nobels 0b2a04b475
android: bump OSS to 1.75.11 (#512)
android: bumping OSS

OSS and Version updated to 1.75.11-t8b962f23d-gf07d419a125

The toolchain hash is being incorrectly by bumpOSS.
Reverting it back to the correct value for 1.74/1.75

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2 weeks ago
kari-ts 9987dbc592
android: only update DNS configs on LinkProperties changes (#511)
We were updating DNS configs when capabilities changed, without LinkProperties having been filled in. Because onAvailable always happened first, LinkProperties were created with default value, and onCapabilitiesChanged sent a DNS update using those LinkProperties.
This change only updates DNS configs on LinkProperties, which is the last update sent on a network change.

Updates tailscale/tailscale#13173

Signed-off-by: kari-ts <kari@tailscale.com>
2 weeks ago
kari-ts 8b91b0ff0a
android: bumping OSS (#510)
OSS and Version updated to 1.75.6-tf572286bf-g2fcb080aa67

Signed-off-by: kari-ts <kari@tailscale.com>
2 weeks ago
James Tucker 2fcb080aa6 Makefile: ensure go.toolchain.rev is included in bumposs
Updates #501

Signed-off-by: James Tucker <james@tailscale.com>
2 weeks ago
James Tucker 9e09fad087 Makefile: update go.toolchain.rev atomically
Updates #501

Signed-off-by: James Tucker <james@tailscale.com>
2 weeks ago
James Tucker 204173d10c Makefile: use explicit path to command invocations
Make does not respect PATH updates made inside the Makefile for program
lookup in invocations. This can be worked around a number of ways, but
differences between the Linux gmake versions in CI, and the macOS gmake
versions on developer machines constrain us.

Updates #501

Signed-off-by: James Tucker <james@tailscale.com>
2 weeks ago
James Tucker b3a7f7f2ae tool/go: update to correct toolchain as needed
Ensure that the target revision is loaded from this repository, not from
the working directory.
Update go to the target revision if the marker file does not match the
target.

Updates #501

Signed-off-by: James Tucker <james@tailscale.com>
2 weeks ago
James Tucker 209045d4f7 Makefile: remove go toolchain version marker in clean as well
The go wrapper script in tool/go assumes that .extracted is
representative of the state of the toolchain directory, so it must be
removed when the toolchain directory is removed.

Updates #501

Signed-off-by: James Tucker <james@tailscale.com>
2 weeks ago
James Tucker 7888447f3f Makefile: disable GOTOOLCHAIN from dynamic switching
Go has a new build facility that can utilize other toolchains if a
module says so, but we manage the toolchain in our own way, so disable
it.

Updates #501

Signed-off-by: James Tucker <james@tailscale.com>
2 weeks ago
James Tucker 72c410465c Makefile: add command to start emulator
This emulator command starts an emulator and keeps running in the
foreground so as to avoid creating zombies.

Updates #343

Co-authored-by: kari@tailscale.com
Signed-off-by: James Tucker <james@tailscale.com>
2 weeks ago
Andrea Gottardo 001e79546c
android: bump OSS to 1.75.3 + update toolchain (#501)
OSS and Version updated to 1.75.3-tafec2d41b-gffbc556cde8

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2 weeks ago
Andrea Gottardo ffbc556cde
android: bumping OSS to 1.75.2 (#500)
OSS and Version updated to 1.75.2-t93f61aa4c-ge195def5e23

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2 weeks ago
Jonathan Nobels e195def5e2
android: fix clean build (#499)
updates tailscale/corp#17686

'make clean' will now purge the cached toolchain to ensure you're using the
right go version when switching branches.  make clean is run in CI before
building anything and the docker container name is updated to pick that up.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2 weeks ago
Andrew Dunham aaecc62e1c android: rework NetworkChangeCallback to track all networks
Instead of just tracking our default network, track all of them and
decide upon each change which is the "best" option.

Updates tailscale/tailscale#13173

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
2 weeks ago
Brad Fitzpatrick 33f79deb3a tool/go: fix typo in comment
Updates #cleanup

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2 weeks ago
kari-ts 28712da8d0
android: fix BuildConfig infinite loop (#495)
Rather than create a Go struct that is set by Android, have Go call into Android to fetch build BuildConfig
Updates tailscale/tailscale#13431

Signed-off-by: kari-ts <kari@tailscale.com>
2 weeks ago
Andrew Dunham 45567146f4 android, libtailscale: pass BuildConfig to Go code; use for DNS config
This commit wires up a method to allow the Tailscale Go backend to
obtain the build configuration, and then adds a new build configuration
to the build to control whether we fall back to the Google public DNS
servers if we can't determine the platform's DNS configuration.

This replaces the previous "IsPlayVersion" / "MaybeGoogle" check for
whether to use the DNS servers as fallbacks, to allow users to decide
this independently of what version of the Android app this is.

Updates tailscale/tailscale#13431

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
2 weeks ago
kari-ts 283e1ebcd8
android: fix network callback race (#493)
ConnectivityManager doesn't make guarantees about the order of network updates. Only use network updates for currently active network.
Also, use registerDefaultNetworkCallback so that we are only listening for default networks.

Updates tailscale/tailscale#13173

Signed-off-by: kari-ts <kari@tailscale.com>
2 weeks ago
Jonathan Nobels 9f87446ab6
android: bumping OSS to 1.73.114 (#492)
OSS Updated to 1.73.114
Version 1.73.114-t0970615b1-gab7ab737364

updates #cleanup

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
3 weeks ago
Jonathan Nobels ab7ab73736
android: fix versioning and bump oss (#490)
* android: update docker image names for go 1.23

updates #cleanup

We need to regenerate the docker images, we'll
denote the new ones with a go1.23 extension.

This also sets the TS_USE_TOOLCHAIN flag so
we're using the corp toolchain which fixes some
versioning script issues.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>

* android: bumping OSS

OSS and Version updated to 1.73.104-te7b5e8c8c-g161457b99b5

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>

---------

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
3 weeks ago
Anton Tolchanov fb8a4f51dc Makefile: fix docker-shell command line
- Fix volume mounting (positional argument to `-v`)
- Correct the make target name in README

Updates tailscale/corp#19670

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
3 weeks ago
Anton Tolchanov 095dae1195 android: exclude MDM classes from ProGuard optimizations
Updates tailscale/corp#22797

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
4 weeks ago
Andrea Gottardo 19581721cf
android: bump OSS to 1.73.73, use Go 1.23 (#485)
Updates #cleanup

OSS and Version updated to 1.73.73

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
4 weeks ago
kari-ts 18e4b176c6
android: fix missing '}' issue (#487)
also run linter

Updates #cleanup

Signed-off-by: kari-ts <kari@tailscale.com>
4 weeks ago
kari-ts 77eaadb360
android: fix missing imports (#486)
android: make clipboard values clickable and focusable

also, use Column isntead of LazyColumn since the Tailnet lock view is a short list and doesn't require lazy rendering

Fixes tailscale/corp#21737

Signed-off-by: kari-ts <kari@tailscale.com>
Signed-off-by: kari-ts <135075563+kari-ts@users.noreply.github.com>
4 weeks ago
yin kaisheng a9ff204ae4
android: fix Hostname type in MaskedPrefs, it should be String type (#482) 4 weeks ago
kari-ts b4ca226eb7
android: make clipboard values clickable and focusable (#483)
also, use Column isntead of LazyColumn since the Tailnet lock view is a short list and doesn't require lazy rendering

Fixes tailscale/corp#21737

Signed-off-by: kari-ts <kari@tailscale.com>
4 weeks ago
kari-ts d94125e767
android: make settings button focusable and clickable (#484)
Fixes tailscale/corp#22717

Signed-off-by: kari-ts <kari@tailscale.com>
4 weeks ago
kari-ts eae8789628
android: move string into correct place (#481)
Move MDM auth key strings into the MDM strings blcok

Updates #cleanup

Signed-off-by: kari-ts <kari@tailscale.com>
1 month ago
kari-ts 29e3c187c2
android: stop tailscaled when VPN has been revoked (#480)
-add new Ipn UI state 'Stopping' to handle the case where the VPN is no longer active and a request to stop Tailscale has been issued (but is not complete yet) and use for optimistic UI
-when VPN has been revoked, stop tailscaled and set the state to Stopping
-this fixes the race condition where when we tell tailscaled to stop, stopping races against the netmap state updating as a result of the VPN being revoked
-add isActive state and use instead of isPrepared for UI showing whether we are connected - we were previously using isPrepared as a proxy for connection, but sometimes the VPN has been prepared but is not active (eg when VPN permissions have been given and VPN has been connected previously, but has been revoked)
-refactor network callbacks into its own class for readability

Fixes tailscale/tailscale#12850

Signed-off-by: kari-ts <kari@tailscale.com>
1 month ago
Josh Vocal 40090f179b
android: Fix search not filtering machines from input (#478)
android: Fix search not filtering text input

Fixes tailscale/tailscale#13218

* Filtering machines in the textfield works since the flow is now reachable
* Updating the health icon works since the flow is now reachable

Signed-off-by: Josh Vocal <joshvocal@gmail.com>
1 month ago
Jonathan Nobels 502eada21a
makefile: add tag_release recipe (#474)
updates #cleanup

The tag_release recipe was integrated with bumposs, but it's
still needed to manually tag branches such as the release
branch.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
1 month ago
Josh Vocal cdbd062426
android: Add Voicemail apps to Android Split Tunneling settings by de… (#479)
android: Add Voicemail apps to Android Split Tunneling settings by default

Updates tailscale/tailscale#13199

Signed-off-by: Josh Vocal <joshvocal@gmail.com>
1 month ago
Josh Vocal 26e5e796fa
android: Allow notification dismissed via swipe on Android 13 (#477)
Allow notification dismissed via swipe on Android 13

Signed-off-by: Josh Vocal <joshvocal@gmail.com>
1 month ago
Andrea Gottardo 8648c2ef27
mdm: add AuthKey piping (#476)
Updates tailscale/tailscale#1572

This PR defines the AuthKey system policy in the Android codebase, allowing the code in OSS (see tailscale/tailscale#13061) to pick up any value defined by an MDM solution via managed app configuration. It also adds the new key to the `app_restrictions.xml`.

OSS and Version updated to 1.73.13-taf3d3c433-g536e1adcc42

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
1 month ago
kari-ts 1a41ab3b66
android: check if other VPN is active (#475)
Detect when another VPN is active and launch dialog giving user the option to navigate to settings to disable.
Update state string and toggle to require successful VPN preparation

To do in a follow-up: monitor VPN connection, and if Tailscale VPN disconnects due to another VPN connecting, update toggle and text
Updates tailscale/tailscale#12850

Signed-off-by: kari-ts <kari@tailscale.com>
1 month ago
kari-ts 10a4350c02
android: prepare VPN when quick tile is clicked (#473)
Currently, the VPN is prepared when MainActivity is launched. If Tailscale is enabled by a quick tile, the VPN is not prepared.
This change creates an application scoped view model and moves the VPN prep to the application class so that it is not dependent on MainActivity.

Fixes tailscale/tailscale#12489

Signed-off-by: kari-ts <kari@tailscale.com>
1 month ago
Andrea Gottardo 4830d8826e
android: fix paddings and headers of Taildrop destination picker (#465)
Updates tailscale/corp#22362

First round of polish for the Taildrop device picker, to use more consistent metrics and SectionDivider resembling the rest of the app. We'll follow up with device icons like the ones we have on iOS in a later PR.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
1 month ago
Jonathan Nobels 20a5beab3e
android: bump OSS (#472)
OSS and Version updated to 1.73.0-t1e8f8ee5f-ga843c93669f

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
1 month ago
Andrea Gottardo a843c93669
Revert "VPNServiceBuilder: document excludeRoute not supported on pre-33 API" (#471) 1 month ago
kari-ts fcfb997fde
Revert "android: prepare VPN when quick tile is clicked" (#470)
Revert "android: prepare VPN when quick tile is clicked (#451)"

This reverts commit c6f3239b1b.
1 month ago
kari-ts c6f3239b1b
android: prepare VPN when quick tile is clicked (#451)
Currently, the VPN is prepared when MainActivity is launched. If Tailscale is enabled by a quick tile, the VPN is not prepared.
This change creates an application scoped view model and moves the VPN prep to the application class so that it is not dependent on MainActivity.

Fixes tailscale/tailscale#12489

Signed-off-by: kari-ts <kari@tailscale.com>
1 month ago
Jonathan Nobels e6fc832494
android: bumping OSS (#469)
OSS and Version updated to 1.71.135-tccf091e4a-g7e5e0f25cf6

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
1 month ago
Andrea Gottardo 7e5e0f25cf
VPNServiceBuilder: document excludeRoute not supported on pre-33 API (#467)
VPNServiceBuilder: document excludeRoute not supported on pre-33 API level

Updates #cleanup
Updates tailscale/tailscale#13106

Our code in VPNServiceBuilder attempts to call excludeRoute regardless of API level. However, it requires a device on Android API level 33 or newer. Let's document and log this while we plan a proper workaround.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
1 month ago
Percy Wegmann c1b957cc5f taildrop: use a random filename if real filename cannot be determined
Also pull in latest oss to avoid crashing if sharing fails.

Updates tailscale/corp#22357

Signed-off-by: Percy Wegmann <percy@tailscale.com>
1 month ago
Jonathan Nobels 716152b57d
makefile: add bump_version_code recipe back (#464)
updates tailscale/corp#21644

The docker build sill requires the recipe for bumping
the version code by one before we run the second
androidTV build.  This was removed, which breaks the
build.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2 months ago
Andrea Gottardo 338c13b6b5
android: add HealthView (#458)
Updates tailscale/tailscale#4136

This PR adds a proper health warnings viewer for the Android client, like we already do on iOS and macOS. A subtile info.circle or exclamation mark icon is displayed next to the connection status when one or more warnings are found. A detail view provides visibility into the full list.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2 months ago
Andrea Gottardo 403aa092c4
android: bumping OSS (#463)
OSS and Version updated to 1.71.72-t1ed958fe2-g2a32ed1f301

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2 months ago
Nick Khyl 2a32ed1f30
libtailscale, mdm: allow syspolicy to subscribe to policy change notifications (#462)
In preparation for upcoming syspolicy improvements, we'd like to allow subscriptions
to policy change notifications via the syspolicyHandler.RegisterChangeCallback.
The registered callbacks are invoked whenever MDMSettings.update is called.

Updates tailscale/tailscale#12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2 months ago
Nick Khyl 8767fbd8d8
mdm: improve handling and returning of not configured policy settings (#461)
We should distinguish between unconfigured policy settings and those configured with the default values.
In the first case, the syspolicyHandler should return syspolicy.ErrNoSuchKey instead of the default value,
while in the latter case, it should return the actual setting value, even if that value happens to be the default
value such as "user-decides". This distinction should also be reflected in the "Current MDM settings" view.

In this PR, we update MDMSetting.flow to hold both the value to be used by the app and a flag indicating
whether the policy setting is configured or not. If the policy setting is not configured, the value is the default
value for the setting type. We then use this new flag to decide whether to throw a NoSuchKeyException from
the Kotlin-side of the syspolicyHandler implementation and how to display the policy setting in the
"Current MDM settings" view.

Additionally, we update the MDMSettings.update and MDMSetting.setFrom methods to avoid calling
app.getEncryptedPrefs (and reading/decrypting the prefs) for every defined MDM setting.

Updates tailscale/tailscale#12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2 months ago
Nick Khyl 946afb6c33 libtailscale, android: translate NoSuchKeyException as syspolicy.ErrNoSuchKey
Currently, NoSuchKeyException gets translated by gomobile to a Go error with "no such key" as the text.
It is imperative for syspolicy.Handler implementations to return syspolicy.ErrNoSuchKey if a policy setting
is not configured, so this PR adds translation for errors that do not already wrap syspolicy.ErrNoSuchKey,
but have "no such key" as the text.

Updates tailscale/tailscale#12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2 months ago
Nick Khyl 101c9dd121 mdm: return values rather than the constant names for the setting enums
We stringify setting values with toString before returning them from the syspolicyHandler.
For enum setting types, such as AlwaysNeverUserDecides and ShowHide, the default toString
implementation returns the enum constant names, such as Always and UserDecides. However,
the Go backend requires us to return "always", "never", and "user-decides" values, exactly, and
falls back to the default value (e.g., "user-decides") if it receives anything else from the app.

This PR overrides the toString methods on both enums to return the required values rather than
the constant names.

Updates tailscale/tailscale#12687

Signed-off-by: Nick Khyl <nickk@tailscale.com>
2 months ago
Andrea Gottardo ea0c1e960d
android: remove Google Stadia from hardcoded exclusions list (#457) 2 months ago
Jonathan Nobels 76ab7eab92
makefile: fix bumposs to properly set version and tag (#456)
Updates #cleanup

bumposs was not properly setting the version.  Split the tasks
into separate recipes.  Simply calling make bumposs should
now generate a single commit with the proper versioning
in build.gradle.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2 months ago
Jonathan Nobels cb916676a4
android: bumping OSS (#455)
OSS and Version updated to 1.71.22-t855da4777-g32e48dc78e7

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2 months ago
Jonathan Nobels 32e48dc78e
makefile: consolidate bump/tag (#453)
Updates #cleanup

Bumping oss and tagging the release are now combined into a single step.   make bumposs will
now bump to the latest OSS, tag the commit and update the version number in build.gradle in
a single step.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2 months ago
Jonathan Nobels 23454e9bc6
android: bump oss and version code (#452)
Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2 months ago
Andrea Gottardo 1465b2a67f
ui: add Mullvad info view (#450)
Updates tailscale/tailscale#9421

Adds a view to highlight Mullvad support when it is not enabled.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2 months ago
Andrea Gottardo b9917c8647
android: bump OSS to 1.71.x; update dependencies (#449)
Fixes #cleanup

Bumps OSS, updates dependencies and enables `android.nonTransitiveRClass` to improve build times.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2 months ago
Jonathan Nobels 6deb61a20e
android/docker: combine CMD invocations into a single line (#448)
android/docker: combine CMD into a single line

 updates tailscale/corp#21644

docker ignores all but the last CMD invocation.  These
have to be combined into a single line.  The clean is
redundant.  We run a clean on the mounted directory
before we kick off make docker-run-build.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2 months ago
Jonathan Nobels b9477c64a8
android/gradle: separate release and release_tv (#447)
updates tailscale/corp#21644

release_tv should init with the release target or it doesn't
build the right thing.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2 months ago
Jonathan Nobels 2f59feef20
android/makefile: add tv-specific build variant (#445)
android/makefile: add tv-specific build

 updates tailscale/corp#21644

This will build a second tailscale-release-tv.aab with the leanback flag set
suitable for submission for android-tv.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2 months ago
Andrea Gottardo c4a1dec8eb
mdm: support split tunneling configuration via syspolicy (#441)
Updates tailscale/tailscale#6912

Adds two new Android-only MDM policies: IncludedPackageNames and ExcludedPackageNames. These are comma-separated string values that contain Android package names to configure app-based split tunneling programmatically.

If ExcludedPackageNames is non-empty, Tailscale will exclude the given apps from the VPN tunnel.

If IncludedPackageNames is non-empty, Tailscale will configure the VPN tunnel to only route the given apps via Tailscale.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2 months ago
Jonathan Nobels 65a025007f
android: bump version code (#446)
Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2 months ago
Andrea Gottardo ca91191cc6
ui: only show high severity warnings in-app for 1.70 (#444)
As discussed with @barnstar, let's hide health messages within the app's main screen unless they are high severity. Low and mid-severity messages will be re-added in a more subtle, later iteration with a dedicated health messages view.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
3 months ago
Jonathan Nobels 26b4635c11
android: clean up build warnings (#443)
#cleanup

Removed a host of noisy deprecation and unchecked
cast warnings.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
3 months ago
Jonathan Nobels 66fa3c41a6
docs/makefile: update docker build instructions (#442)
#cleanup

Updated the notes on refreshing docker build containers
Removed the jarsign output from the build as it contains
the jks password.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
3 months ago
Jonathan Nobels dfda774dc0
makefile: reuse image and remove container for builds (#440)
Updates tailscale/corp#19670

Build optimization to reuse the existing build image and to
remove the individual containers post build.   Image name
is parameterized.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
3 months ago
Jonathan Nobels 2a8d07c5f6
android: bump version code (#439)
Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
3 months ago
Andrea Gottardo 9b24888c4c
android: implement app split tunneling support (#435)
Updates tailscale/tailscale#6912

Adds UI and models that provide the ability to add/remove apps which should be excluded from going through the VPN tunnel.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
3 months ago
Andrea Gottardo a120eb2fe1
ipn: update dependencies (#432)
Updates project dependencies.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
3 months ago
Andrea Gottardo b3a74986ac
health: only display system notifications for high severity warnings,… (#436)
health: only display system notifications for high severity warnings, show low severity notifications in-app

Updates tailscale/tailscale#4136

This PR brings the Android health system in line with recent macOS/iOS changes. Only high severity notifications will now trigger a system notification; meanwhile all notifications are now displayed in the app home screen, like we do on iOS. The "warming-up" Warnable is observed to prevent spurious notifications from appearing while the app has just launched.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
3 months ago
Andrea Gottardo 840a31d74e
android: bump version to 1.69.75 (230) (#434)
android: bump version code

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
3 months ago
Andrea Gottardo b6cacdfd6a
go.mod: bump OSS to 20240625185613 (#433)
Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
3 months ago
Andrea Gottardo d702d2dab8
ui: add sheet to ping devices and see relay status (#431)
This PR adds the ability to ping other devices in your tailnet from the Android app, similarly to the current functionality on iOS. The ping view displays the current latency value, a chart with latency over time, and whether you are using a direct/relayed connection.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
3 months ago
Jonathan Nobels 811641f538
android/ui: remove switch and status label on TV before login (#430)
updates tailscale/corp#20930

More fixes.  Google reviewers were unhappy that
there was a non-actionable label for AndroidTV when
before login had happened, so that have been removed.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
3 months ago
Andrea Gottardo 9ae30c06bf
repo: add .DS_Store to .gitignore (#427)
See title :)

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
3 months ago
Andrea Gottardo 793a83fdc6
android: bump version code to 228 (#429)
android: bump version code

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
3 months ago
Andrea Gottardo ea928ca971
ui: deliver health notifications to user (#426)
Updates tailscale/tailscale#4136

This PR adds support for notifying the user when health warnings are sent down coming from LocalAPI. We remove duplicates and debounce updates; then deliver a notification for each health warning are they are sent down. Just like on macOS, notifications are removed when a Warnable becomes healthy again.

Notifications are delivered on a separate notification channel, so they can be disabled if needed.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
3 months ago
Andrea Gottardo 8dc1a13f77
android: bump OSS to 20240619155934 (#428)
Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
3 months ago
Jonathan Nobels 196944d168
android/ui: open login screen on toggle (#425)
updates tailscale/corp#20930

To address review concerns regarding the toggle
being unresponsive with the d-pad.  We'll now open
the QR login screen on android TV if you toggle the
VPN when we're in the NeedsLogin state.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
3 months ago
Jonathan Nobels 0ff6be6345
android/ui: fix AndroidTV navigation issues (#424)
updates tailscale/corp#20930

This addresses several issues with AndroidTV navigation:

The search bar is removed until we have a better solution
for D-pad navigation.   This should be addressed when we
switch to a less-customized component.   Full replacement of
the search functionality is beyond the scope of this change.

The back button will now automatically request the focus
on AndroidTV devices by default so there is always at
least one element focussed.

Views with clipboard support are disabled since this
was not functional (nothing was getting copied to
the clipboard).

View with embedded links are removed since these
require touch support and a browser.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
3 months ago
Jonathan Nobels 634d51c20b
android/ui: support searching for node by IP address (#423)
fixes tailscale/corp#20846

Adds searching by IP to android, matching the existing
iOS behavior.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
4 months ago
Fred Silberberg 864cc35bd4
android/ui: implement USE_EXIT_NODE intent (#142)
Fixes tailscale/tailscale#8143. 

Map friendly labels from intent extras to tailscale node IDs, with empty string or not specifying the exitNode intent extra as the "no exit node" action. When an error is encountered, we will push a notification with a friendly message to the status notification channel. The tasker syntax I tested with locally is:

Action: `com.tailscale.ipn.USE_EXIT_NODE`
Package: `com.tailscale.ipn`
Class: `com.tailscale.ipn.IPNReceiver`
Target: Broadcast Receiver
Extra: `exitNode:exitNodeLabelOrEmpty`
Extra: `allowLanAccess:trueOrFalse`

Signed-off-by: Fredric Silberberg <fred@silberberg.xyz>

* Extract constant strings to resources for later localization.

Signed-off-by: Fredric Silberberg <fred@silberberg.xyz>

---------

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
4 months ago
Jonathan Nobels 23805e9d00
android/ui: remove Notifier initialization on ShareIntent (#422)
fixes tailscale/corp#12431

The share extension was initializing it's own Notifier.  It
does not need to, it simply needs to ensure the shared
app instance has been initialized and a suitable notifier
instance will already be running.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
4 months ago
Jonathan Nobels 5b121c1876
android: bump oss to 1.69 (#421)
Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
4 months ago
Jonathan Nobels 80864fec12
android/makefile: make keystore path a parameter (#420)
Updates tailscale/corp#19670

This makes the keystore path a paramater so it's not
expected to be at the repo root, allowing the builder to
leave it where ever it lhappens to get written.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
4 months ago
Jonathan Nobels ef21753763
android: bump version code (#419)
Bumping for 1.67 testing

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
4 months ago
Jonathan Nobels 0e82e54ffb
android: bump version code (#418)
Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
4 months ago
Jonathan Nobels 64fca2a712
android: bump OSS (#417)
Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
4 months ago
Jonathan Nobels a74e30d4e2
android/docker: update makefile and dockerfile for build automation (#394)
android/docker: update makefile for build automation

Updates tailsale/corp#19670

Added a dockerfile to run the full release build in addition to the
shell environment.

The build will now look for JKS_PASSWORD in the environment for
completing the signing step without user interaction.

Several smaller recipes added to the makefile for building the
docker builder image, running and cleaning it up independently
to make debugging issues quicker.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
4 months ago
Jonathan Nobels 2788cf7ee5
android/ui: fix exit node picker (#416)
fixes tailscale/corp#20547

Corrects some regressions with selection of exit nodes.
We'll now display flag country: name instead of the
raw mullvad node nam and selecting an exit node properly
respects the forced exit node ID from MDM so the
right fields are disabled/hidden.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
4 months ago
kari-ts d7a87e868c
bump oss and version code (#415)
* bump version

* go.mod: update for 223

Signed-off-by: kari-ts <kari@tailscale.com>

* android: bump version code

Signed-off-by: kari-ts <kari@tailscale.com>

---------

Signed-off-by: kari-ts <kari@tailscale.com>
4 months ago
kari-ts 15da8f3797
android: add MDM info to exit node picker and banner (#414)
android: add MDM information in exit node banner and picker

Updates tailscale/corp#19122

Signed-off-by: kari-ts <kari@tailscale.com>
4 months ago
kari-ts 8f62f0da79
IpnViewModel: fix NPE (#413)
Fixes tailscale/tailscale#12281

Signed-off-by: kari-ts <kari@tailscale.com>
4 months ago
kari-ts cbc47791ad
android: make disconnected notification non-foreground (#391)
android: make disconnected notification a non-foreground notification

Follow-up to https://github.com/tailscale/tailscale-android/pull/389
Only use foreground notifications when VPN was started as a foreground service.

Updates #cleanup

Signed-off-by: kari-ts <kari@tailscale.com>
4 months ago
kari-ts a6fd8a8093
android: start VPN after preparing VPN (#412)
https://github.com/tailscale/tailscale-android/pull/398 introduced a bug where we were not calling startVPN after getting (or confirming) VPN.prepare permissions
This resulted in the VPN not being turned on after logging in for the first time

Updates tailscale/tailscale#12148

Signed-off-by: kari-ts <kari@tailscale.com>
4 months ago
kari-ts 0df6c61eee
ExitNodePicker: don't allow run as exit node while using exit node (#411)
Updates tailscale/corp#19122

Signed-off-by: kari-ts <kari@tailscale.com>
4 months ago
Andrea Gottardo 75db9e64c8
gradle: update to 8.6 (#405) 4 months ago
kari-ts e826a173aa
android: enable proguard (#399)
Signed-off-by: kari-ts <kari@tailscale.com>
4 months ago
kari-ts a05829b3c0
android: exit node banner ui improvements (#408)
-show if device is running as an exit node
-show exit node connection status

Updates tailscale/corp#19122

Follow up will include:
-make exit node picker recompose when exit node connection status changes
-prevent user from running as exit node if it is using an exit node and vice versa instead of silently failing
-add explanation box for MDM offline state

Signed-off-by: kari-ts <kari@tailscale.com>
4 months ago
kari-ts 72f35cd318
ExitNodePicker: recompose when connection status changes (#410)
Updates tailscale/corp#19122

Signed-off-by: kari-ts <kari@tailscale.com>
4 months ago
kari-ts 4fa86dbf03
App: tap on notification brings up main view (#407)
Updates tailscale/tailscale#10104

Signed-off-by: kari-ts <kari@tailscale.com>
4 months ago
Jonathan Nobels 77c2d924ee
android/ui: unhide accounts if VPN is prepared (#406)
Updates tailscale/tailscale#12148

There was a small bug where we weren't rechecking the
vpn permissions to the FUS would never show.  We'll
now do that in the view model base case on initialization.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
4 months ago
Jonathan Nobels b37492a547
android/ui: use compose getValue syntactic sugar consistently (#367)
Updates #cleanup

"by stateFlow" is syntactic sugar for" = stateFlow.value" and is more
idiomatic.  There should be no functional difference here.  Just\
changed where it can be for consistency.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
4 months ago
kari-ts 999c6f2357
Notifier: init app if uninitialized (#404)
Fixes tailscale/corp#20087

Signed-off-by: kari-ts <kari@tailscale.com>
4 months ago
Andrea Gottardo 006b1e6852
values: cleanup unused resources (#403)
Removes some unused strings and drawables. Sets some strings as not-translatable for future localization efforts.
4 months ago
kari-ts 32e29c4efd
android: hide Accounts if VPN not prepared (#402)
Updates tailscale/tailscale#12148

Signed-off-by: kari-ts <kari@tailscale.com>
4 months ago
kari-ts 9aa3a840de
bump version code and OSS (#401)
* go.mod: update for 220

Signed-off-by: kari-ts <kari@tailscale.com>

* android: bump version code

Signed-off-by: kari-ts <kari@tailscale.com>

---------

Signed-off-by: kari-ts <kari@tailscale.com>
4 months ago
kari-ts 0ff47f7ab5
android: fix import (#400)
Updates #cleanup

Signed-off-by: kari-ts <kari@tailscale.com>
4 months ago
kari-ts 12ad295706
android: fix connect VPN permissions (#398)
-show VPN connection permissions after intro screen
-make toggle state and main view take VPN preparedness into consideration

Fixes tailscale/tailscale#12148

Signed-off-by: kari-ts <kari@tailscale.com>
4 months ago
kari-ts d842ccde22
MainViewModel: treat NoState -> Starting as starting (#396)
Toggle should be on when user transitions from NoState -> Starting, but not when the user is in NoState. This change uses a placeholder string "--" when the user is in NoState.

Fixes tailscale/corp#19961

Signed-off-by: kari-ts <kari@tailscale.com>
4 months ago
Andrea Gottardo cbcc773b98
Update README with Play Store beta testing track, Amazon link, F-Droid info (#397)
Update README with Play Store beta testing track, Amazon link, F-Droid clarifications

Signed-off-by: Andrea Gottardo <andrea@tailscale.com>
5 months ago
Andrea Gottardo cbc0035dfe
ui: add descriptions to notification channels (#395)
* ui: add descriptions to notification channels

Provide descriptions that will be displayed in the Android system notification settings to describe the purpose of each notification channel to the user.

* Use IMPORTANCE_HIGH for start_vpn_channel
5 months ago
kari-ts c47ead9412
android: bump version code (#393)
Signed-off-by: kari-ts <kari@tailscale.com>
5 months ago
Percy Wegmann 46cdbb7b9b
android: set wantRunning to true when started from Always On VPN (#392)
This way, even if the VPN wasn't previously manually enabled, it'll still turn on after reboot

Updates #cleanup

Signed-off-by: Percy Wegmann <percy@tailscale.com>
5 months ago
kari-ts 5476288100
bump oss and version num for 217 (#390)
* go.mod: update for 217

Signed-off-by: kari-ts <kari@tailscale.com>

* android: bump version code

Signed-off-by: kari-ts <kari@tailscale.com>

---------

Signed-off-by: kari-ts <kari@tailscale.com>
5 months ago
kari-ts a3b356a81c
bump oss and increase version for 216 (#387)
* go.mod: update for 215

Signed-off-by: kari-ts <kari@tailscale.com>

* android: bump version code

Signed-off-by: kari-ts <kari@tailscale.com>

---------

Signed-off-by: kari-ts <kari@tailscale.com>
5 months ago
Percy Wegmann 411d7b2597
android: make IPNService a foreground service (#389)
* android: make IPNService a foreground service

Prevents BackgroundServiceStartNotAllowedException.

Updates #cleanup

Signed-off-by: Percy Wegmann <percy@tailscale.com>

* Use system exempted foreground service type

---------

Signed-off-by: Percy Wegmann <percy@tailscale.com>
Co-authored-by: kari-ts <kari@tailscale.com>
5 months ago
Percy Wegmann 59a88ffbab android: only consider backend ready once LocalBackend.Start() has finished
This prevents spurious crashes from a nullpointer when we attempt to call
localapi while app.localAPIHandler is still null.

Updates #cleanup

Signed-off-by: Percy Wegmann <percy@tailscale.com>
5 months ago
kari-ts f684bf696d
android: fix quick settings tile status (#377)
* android: fix quick settings tile

https://github.com/tailscale/tailscale-android/pull/358 updated the Quick Settings tile to only depend on ipn state.
This was only partially correct in the sense that we made changes to only check for whether the state was > stopped
and not whether Tailscale was on.

This checks for two states, whether Tailscale is on, and whether the tile is ready to be used. The former requires
ipn state to be >= Starting, and the latter checks whether ipn state is > RequiresMachineAuth. Tile readiness determines
whether an intent is to open MainActivity or whether an intent to connect/disconnect VPN is sent. Whether Tailscale is on
or off determines whether the tile status is active or not.

We lazily initialize App to avoid starting Tailscale when unnecessary - for example, when viewing the QuickSettings tile, there's no need to start Tailscale's backend.
We also persistently store a flag indicating whether VPN can be started by quick settings tile: this allows us to start the VPN from the quick settings tile even when the
application was previously stopped.

Updates tailscale/tailscale#11920

Co-authored-by: kari-ts <kari@tailscale.com>
Co-authored-by: Percy Wegmann <percy@tailscale.com>

* android: simplify IPNService lifecycle

Reserves use of IPNReceiver only for external requests to start the VPN.

Updates tailscale/corp#19860

Signed-off-by: Percy Wegmann <percy@tailscale.com>

* Revert "android: temporarily remove quick settings tile"

This reverts commit edb3f5b0c5.

Signed-off-by: Percy Wegmann <percy@tailscale.com>

---------

Signed-off-by: Percy Wegmann <percy@tailscale.com>
Co-authored-by: Percy Wegmann <percy@tailscale.com>
5 months ago
Percy Wegmann 698fb868a7 android: only navigate to main if navController is initialized
Updates #cleanup

Signed-off-by: Percy Wegmann <percy@tailscale.com>
5 months ago
Andrea Gottardo 82c17a4d1d
drawables: add disabled notification icon (#384)
Adds a disabled state for the notification icon, and uses it where needed. Also switches to using vector-based icons instead of PNGs.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
5 months ago
Jonathan Nobels b615eb38b4
android/ui: fix theming for the exit node picker button (#382)
Fixes tailscale/corp#19881

Exit node picker button is now grey-200 in light mode and grey-700 in
dark mode for the disabled state.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
5 months ago
Andrea Gottardo 24d6cc7a08
metadata: update images for F-Droid (#381)
Adds new screenshots for F-Droid, provides an app icon and feature graphic.

Signed-off-by: Andrea Gottardo <andrea@tailscale.com>
5 months ago
kari-ts ec1dc8b0be
bump oss and version code for 213 (#376)
go.mod: update for 213



android: bump version code

Signed-off-by: kari-ts <kari@tailscale.com>
5 months ago
Percy Wegmann edb3f5b0c5 android: temporarily remove quick settings tile
This is a workaround for tailscale/corp#19860 until the root cause
can be sorted out.

Updates tailscale/corp#19860

Signed-off-by: Percy Wegmann <percy@tailscale.com>
5 months ago
kari-ts 7f66c373ea
bump oss and version number for 210 (#373)
* go.mod: update for 210

Signed-off-by: kari-ts <kari@tailscale.com>

* android: bump version code

Signed-off-by: kari-ts <kari@tailscale.com>

* android: bump version code

Signed-off-by: kari-ts <kari@tailscale.com>

---------

Signed-off-by: kari-ts <kari@tailscale.com>
5 months ago
kari-ts 2d7d6e1357
IpnViewModel: reset vpn state before login/reauth (#366)
Fixes ENG-3479

Clear routes when switching profiles and reauthenticating. This fixes an issue where previously set routes/DNS configs caused actions to fail.

Signed-off-by: kari-ts <kari@tailscale.com>
5 months ago
Jonathan Nobels 45fd2e0661
android/ui: fix infinite recursion in custom login (#371)
Updates tailscale/tailscale#11731

The MDM logic was infinitely recursing.  The custom control url login
handler now does what the other functions do and calls into login
with a set of pre-populated prefs.  If MDM specifies a custom login
server, we force that, regardless of anything the user specified.

This behaviour matches macOS.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
5 months ago
Percy Wegmann 31b0ec8865 android: use EditPrefs instead of passing UpdatePrefs to start
This allows us to precisely set the options we need during login
and avoid wiping away defaults like AllowSingleHosts.

Updates tailscale/tailscale#11731

Signed-off-by: Percy Wegmann <percy@tailscale.com>
5 months ago
Will Norris 9703d48f1a go.mod: run go mod tidy, add github action
This is just a direct copy of the action we run in corp, minus the
self-hosted runner.

Updates #cleanup

Signed-off-by: Will Norris <will@tailscale.com>
5 months ago
Jonathan Nobels 17ad0c8cc0
android/ui: add mdm key expiry notification window (#365)
Updates tailscale/corp#19743

Adjust the key expiry window and it's related notification based
on the keyExpiry MDM setting.  Default remains 24 hours.  Logic
moved to the viewModel.

unitTest package added.  It's a start!

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
5 months ago
Jonathan Nobels a2471d38cb
android/ui: add mdm hooks (#364)
Updates tailscale/corp#19743

Adds the hooks for the various MDM settings applicable to Android with
the exception of the keyExpirationNotice which we'll handle separately.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
5 months ago
kari-ts e6f6d35a99
android: bump oss and fix missing net/interfaces (#363)
Update net/interfaces to netmon per https://github.com/tailscale/tailscale/pull/11901

Signed-off-by: kari-ts <kari@tailscale.com>
5 months ago
kari-ts 5e3236260f
android: add persistent notification with VPN status (#362)
-When connected, tapping on the notification disconnects
-When disconnected, tapping on the notification connects
-Navigate to system notifications instead of app info when tapping on 'Notifications'
-Clean up unused notification channel and methods

Fixes tailscale/tailscale#10104

Signed-off-by: kari-ts <kari@tailscale.com>
5 months ago
kari-ts d330726ba1
android: fix Quick Settings tailscale (#358)
-Get rid of unused stopVPN() function
-Get rid of unused ACTION_STOP_VPN intent handling; this is redundant with DISCONNECT_VPN intent
-Tile active state should only depend on ipn state, and not the results of editing the prefs with wantRunning set. It should be active iff ipn.State > Stopped

Fixes tailscale/tailscale#11920

Signed-off-by: kari-ts <kari@tailscale.com>
5 months ago
Andrea Gottardo 0c0853a962
mdm: improve logs when keys are undefined (#361)
Let's make these log lines a bit less scary when MDM is not being used to enforce a value.

Signed-off-by: Andrea Gottardo <andrea@tailscale.com>
5 months ago
James Tucker 3f864b28c7
Makefile: clean up the Makefile (#354)
The Makefile has been through a lot of change in recent iterations and
is overdue another cleaning.

- Variables still in use are moved to the variable definition area at
  the top of the file
- Added three distinct sections separated by comment: android build, go
  build and utility tasks.
- Relocated tasks into one of those three sections as appropriate
- Removed intermediate phony targets from the APK builds, as they're
  just making the build harder to trace file wise.
- Shifted from a central PHONY definition to incrementally defined
  dependencies to ensure every non-file task is correctly marked.
- Switch the final APK moves to use install -C to avoid updating the
  target files unless actually necessary.
- Add some missing dependencies on some tasks, centralized the gradle
  dependencies into a single task for easier re-use.
- Remove some single-use variable definitions.
- Don't promote /bin to high in $PATH when $JAVA_HOME is un-set.

Updates #cleanup

Signed-off-by: James Tucker <james@tailscale.com>
5 months ago
kari-ts 22c129ee1c
android: accessibility fixes (#359)
Updates tailscale/corp#18976

Signed-off-by: kari-ts <kari@tailscale.com>
5 months ago
Andrea Gottardo 427e2d29b4
ipn: provide subtitle in QuickToggleService (#357)
Fixes ENG-3443

Provides a "Connected" / "Not connected" subtitle in the Tailscale quick tile.

Also drops unnecessary SDK version checks in App.kt.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
5 months ago
kari-ts 1c0aef5418
android: bump OSS and version code for 208 (#355)
* go.mod: update for 1.65.9

Signed-off-by: kari-ts <kari@tailscale.com>

* android: bump version code

Signed-off-by: kari-ts <kari@tailscale.com>

---------

Signed-off-by: kari-ts <kari@tailscale.com>
5 months ago
kari-ts 39628be8a6
libtailscale, android: fix allow LAN access (#324)
-Exclude local routes in VPNServiceBuilder
-Maybe update TUN on new state update where state >= starting
-Clean up updateTUN

Updates tailscale/corp#18984
Updates tailscale/corp#18202

Signed-off-by: kari-ts <kari@tailscale.com>
5 months ago
Brad Fitzpatrick 9dda2cc470 go.mod: bump oss, plumb new health.Tracker
Updates tailscale/tailscale#11874

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
5 months ago
kari-ts a6bc2244b6
android: pass interface name to go (#340)
Use Android API to pass interface name to Tailscale on network updates

Fixes tailscale/corp#19215

Signed-off-by: kari-ts <kari@tailscale.com>
5 months ago
kari-ts 24dd83090c
QuickToggleService: use undeprecated startActivityAndCollapse (#351)
startActivityAndCollapse(Intent) is deprecated for >= API 34, use startActivityAndCollapse(PendingIntent) instead

Fixes tailscale/corp#19546

Signed-off-by: kari-ts <kari@tailscale.com>
5 months ago
kari-ts ad3b6a5a64
android: add content labels (#352)
Address content labeling warnings surfaced by pre-launch report

Updates tailscale/corp#18976

Signed-off-by: kari-ts <kari@tailscale.com>
5 months ago
Percy Wegmann 16fa0e9b9e pull latest OSS
Updates tailscale/corp#19332

Signed-off-by: Percy Wegmann <percy@tailscale.com>
5 months ago
Andrea Gottardo 88b0af2c9b
mdm: add string array support in Android syspolicy_handler (#349)
Updates tailscale/corp#19459

Allows the Go backend to read string array values stored in the Android RestrictionsManager.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
5 months ago
Andrea Gottardo 7119424e32
libtailscale: don't log syspolicy.ErrNoSuchKey (#348)
There is no value in logging when syspolicy.ErrNoSuchKey is returning from the syspolicy handler, so we just shouldn't. The `failed to get string value` error message was very likely to lead to confusion over its meaning.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
5 months ago
Jonathan Nobels b06342629f
android/ui: fix exit node selection navigation (#344)
fixes tailscale/corp#19297

Selecting an exit node should navigate the user back home.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
5 months ago
Percy Wegmann 07d04ca750 android: pull latest OSS
Updates tailscale/corp#16827

Signed-off-by: Percy Wegmann <percy@tailscale.com>
5 months ago
Percy Wegmann 057e25c23d android: pull latest OSS
Updates tailscale/corp#16827

Signed-off-by: Percy Wegmann <percy@tailscale.com>
5 months ago
Will Norris a54ebf75ef .github/licenses: remove gioui from license list
The android app no longer uses gioui.

Updates tailscale/corp#5780

Signed-off-by: Will Norris <will@tailscale.com>
5 months ago
Jonathan Nobels f4d2a277a5
android/ui: add support for remembering the last used exit node (#305)
Updates ENG-2911

Disabling an exit node is now temporary and you can re-enable it without re-selecting it from the picker.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
5 months ago
kari-ts 75e2d8983b
Revert "android: pass interface name to go" (#339)
Revert "android: pass interface name to go (#336)"

This reverts commit bbb3c86fa8.
5 months ago
kari-ts bbb3c86fa8
android: pass interface name to go (#336)
Use Android API to pass interface name to Tailscale on network updates

Fixes tailscale/corp#19215

Signed-off-by: kari-ts <kari@tailscale.com>
5 months ago
Percy Wegmann bc8985126d android: enable Taildrive
Updates tailscale/corp#16827

Signed-off-by: Percy Wegmann <percy@tailscale.com>
5 months ago
Brad Fitzpatrick eb8d731a04 README.md: update for new app
s/IPNActivity/MainActivity/ for the new app rewrite.

And remove the Google Sign-In part, now that it just uses the browser
instead of Google Services token generation on the device.

Updates #cleanup

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
5 months ago
kari-ts 81acaef5b7
android: rip android_legacy (#335)
Updates #cleanup
Signed-off-by: kari-ts <kari@tailscale.com>
5 months ago
kari-ts 19177df1e2
bump OSS and version code for 207 (#334)
* bump OSS and version code for 207

Signed-off-by: kari-ts <kari@tailscale.com>

* android: bump version code

Signed-off-by: kari-ts <kari@tailscale.com>

---------

Signed-off-by: kari-ts <kari@tailscale.com>
5 months ago
Praneet Loke 6197cb9576
android_legacy: Fixes tailscale/tailscale#10104 (#178)
Fixes persistent VPN status bar notification in the legacy gio code base.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
5 months ago
kari-ts 253c116f9b
MainView: fix toggle animation (#333)
Use persistent isOn state across recompositions

Updates tailscale/corp#18202
Fixes tailscale/corp#19194

Signed-off-by: kari-ts <kari@tailscale.com>
5 months ago
Jonathan Nobels 1c3af6713c
android/ui: support login via auth key (#331)
updates ENG-3269

Adds support for joining a tailnet with an auth key in the UI.

Refactors some of the look to put the different custom login options in
on their own screens instead of the menu itself.

Moves the login flow logic to the base class for the viewModel where
it belongs.  removes some vestigial code.

There is no failure feedback for invalid auth keys or broken
control servers.  That will require some fixes to provide better feedback
from localAPI/notifier, but the feature is otherwise fully operational.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
5 months ago
kari-ts 39d1d0b3c3
ui: use 'tlpub' prefix for Tailnet lock key (#332)
Fixes tailscale/tailscale#11708
Updates tailscale/corp#18202

Signed-off-by: kari-ts <kari@tailscale.com>
6 months ago
Andrea Gottardo 56da7b66d0
android: bump OSS and bump version code to 206 (#329)
Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
6 months ago
kari-ts f95428f7fa
android: bump version code for unstable release (#328)
* go.mod: update for 1.65.1

Signed-off-by: kari-ts <kari@tailscale.com>

* android: bump version code

Signed-off-by: kari-ts <kari@tailscale.com>

---------

Signed-off-by: kari-ts <kari@tailscale.com>
6 months ago
Percy Wegmann 0c58841350 android/ui: support receiving Taildrop files directly to Downloads folder on Android 10
Closes tailscale/tailscale#11705

Signed-off-by: Percy Wegmann <percy@tailscale.com>
6 months ago
Andrea Gottardo 8a7148c085
ui: allow copying version number by tapping on it (#326)
Fixes tailscale/corp#19171

This came up in beta users feedback. We should let people copy their current version number, it makes it easier to report what build they're running when filing a bug.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
6 months ago
Jonathan Nobels 372af99c53
android/ui: improve dev QOL by adding support for compose previews (#313)
Updates corp#19117

This adds @Previews for many of the primary views.  We can expand upon this
over time to include different data sets, states, etc.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
6 months ago
Andrea Gottardo a73025b36f
mdm: throw ErrNoSuchKey when a value not defined in Android syspolicy handler (#325) 6 months ago
Andrea Gottardo 4d86c1a6f6
ui: don't show key expiry warning if key doesn't expire (#320) 6 months ago
Andrea Gottardo a1d97baeb0
Update README.md (#323) 6 months ago
Matt Drollette 9533db44b7
ui: fix missing character in date format string for years in strings.xml (#321)
Fix missing character in date format string

Signed-off-by: Matt Drollette <matt@drollette.com>
6 months ago
Andrea Gottardo 44ac22c29d
ui: dark mode improvements (#322)
Applies dark mode improvements from session w/ Ale

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
6 months ago
kari-ts 5ad25262ad
go.mod: update for 1.65.0 (#319)
android: bump version code

Signed-off-by: kari-ts <kari@tailscale.com>
6 months ago
Jonathan Nobels be6364ca95
android/ui: handle NeedsMachineAuth state (#317)
Fixes tailscale/corp#19119

Adds a variation on the ConnectView to render a header and explainer
text for the NeedsMachineAuth state.  A button to take you directly
to the admin page is presented if you are an admin.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
6 months ago
kari-ts 3e32e97261
Makefile: clean up legacy builds (#316)
-Remove legacy builds
-Update version number and name using last unstable

Updates tailscale/corp#18202
Fixes tailscale/corp#19001

Signed-off-by: kari-ts <kari@tailscale.com>
6 months ago
Andrea Gottardo 164a243b77
ui: reintroduce dark mode theme (#315) 6 months ago

@ -18,4 +18,3 @@ Client][]. See also the dependencies in the [Tailscale CLI][].
- [{{.Name}}](https://pkg.go.dev/{{.Name}}) ([{{.LicenseName}}]({{.LicenseURL}})) - [{{.Name}}](https://pkg.go.dev/{{.Name}}) ([{{.LicenseName}}]({{.LicenseURL}}))
{{- end }} {{- end }}
- [tailscale.com](https://pkg.go.dev/tailscale.com) ([BSD-3-Clause](https://github.com/tailscale/tailscale/blob/HEAD/LICENSE)) - [tailscale.com](https://pkg.go.dev/tailscale.com) ([BSD-3-Clause](https://github.com/tailscale/tailscale/blob/HEAD/LICENSE))
- [Gio UI](https://gioui.org/) ([MIT License](https://git.sr.ht/~eliasnaur/gio/tree/main/item/LICENSE))

@ -28,7 +28,7 @@ jobs:
java-version: '17' java-version: '17'
- name: Build APKs - name: Build APKs
run: make tailscale-new-debug.apk run: make tailscale-debug.apk
- name: Run tests - name: Run tests
run: make test run: make test

@ -1,31 +0,0 @@
name: Build Legacy Debug APK
on:
push:
branches:
- main
pull_request:
branches:
- '*'
jobs:
build:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[ci skip]')"
steps:
- name: Check out code
uses: actions/checkout@v3
- uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'
- name: Switch to Java 17 # Note: 17 is pre-installed on ubuntu-latest
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: Build Legacy APK
run: make tailscale-debug.apk

@ -0,0 +1,36 @@
name: go mod tidy
on:
push:
branches:
- main
- "release-branch/*"
pull_request:
branches:
- "*"
concurrency:
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
check-go-mod-tidy:
runs-on: [ubuntu-latest]
timeout-minutes: 8
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
cache: false
go-version-file: go.mod
- name: Check 'go mod tidy' is clean
run: |
./tool/go mod tidy
echo
echo
git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go mod tidy'."; exit 1)

9
.gitignore vendored

@ -19,12 +19,8 @@ android/local.properties
.idea .idea
# Output files from the Makefile: # Output files from the Makefile:
tailscale-debug.apk *.apk
tailscale-release.aab *.aab
tailscale-fdroid.apk
tailscale-new-fdroid.apk
tailscale-new-debug.apk
tailscale-test.apk
# Signing key # Signing key
tailscale.jks tailscale.jks
@ -41,3 +37,4 @@ tailscale.jks
libtailscale.aar libtailscale.aar
libtailscale-sources.jar libtailscale-sources.jar
.DS_Store

@ -2,24 +2,33 @@
# Use of this source code is governed by a BSD-style # Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file. # license that can be found in the LICENSE file.
## For signed release build JKS_PASSWORD must be set to the password for the jks keystore
## and JKS_PATH must be set to the path to the jks keystore.
# The docker image to use for the build environment. Changing this
# will force a rebuild of the docker image. If there is an existing image
# with this name, it will be used.
#
# The convention here is tailscale-android-build-amd64-<date>
DOCKER_IMAGE=tailscale-android-build-amd64-120924
export TS_USE_TOOLCHAIN=1
DEBUG_APK=tailscale-debug.apk DEBUG_APK=tailscale-debug.apk
RELEASE_AAB=tailscale-release.aab RELEASE_AAB=tailscale-release.aab
APPID=com.tailscale.ipn RELEASE_TV_AAB=tailscale-tv-release.aab
AAR=android_legacy/libs/ipn.aar LIBTAILSCALE=android/libs/libtailscale.aar
KEYSTORE=tailscale.jks
KEYSTORE_ALIAS=tailscale
TAILSCALE_VERSION=$(shell ./version/tailscale-version.sh 200) TAILSCALE_VERSION=$(shell ./version/tailscale-version.sh 200)
OUR_VERSION=$(shell git describe --dirty --exclude "*" --always --abbrev=200) OUR_VERSION=$(shell git describe --dirty --exclude "*" --always --abbrev=200)
TAILSCALE_VERSION_ABBREV=$(shell ./version/tailscale-version.sh 11) TAILSCALE_VERSION_ABBREV=$(shell ./version/tailscale-version.sh 11)
OUR_VERSION_ABBREV=$(shell git describe --dirty --exclude "*" --always --abbrev=11) OUR_VERSION_ABBREV=$(shell git describe --exclude "*" --always --abbrev=11)
VERSION_LONG=$(TAILSCALE_VERSION_ABBREV)-g$(OUR_VERSION_ABBREV) VERSION_LONG=$(TAILSCALE_VERSION_ABBREV)-g$(OUR_VERSION_ABBREV)
# Extract the long version build.gradle's versionName and strip quotes. # Extract the long version build.gradle's versionName and strip quotes.
VERSIONNAME=$(patsubst "%",%,$(lastword $(shell grep versionName android_legacy/build.gradle))) VERSIONNAME=$(patsubst "%",%,$(lastword $(shell grep versionName android/build.gradle)))
# Extract the x.y.z part for the short version. # Extract the x.y.z part for the short version.
VERSIONNAME_SHORT=$(shell echo $(VERSIONNAME) | cut -d - -f 1) VERSIONNAME_SHORT=$(shell echo $(VERSIONNAME) | cut -d - -f 1)
TAILSCALE_COMMIT=$(shell echo $(TAILSCALE_VERSION) | cut -d - -f 2 | cut -d t -f 2) TAILSCALE_COMMIT=$(shell echo $(TAILSCALE_VERSION) | cut -d - -f 2 | cut -d t -f 2)
# Extract the version code from build.gradle. # Extract the version code from build.gradle.
VERSIONCODE=$(lastword $(shell grep versionCode android_legacy/build.gradle)) VERSIONCODE=$(lastword $(shell grep versionCode android/build.gradle))
VERSIONCODE_PLUSONE=$(shell expr $(VERSIONCODE) + 1) VERSIONCODE_PLUSONE=$(shell expr $(VERSIONCODE) + 1)
VERSION_LDFLAGS=-X tailscale.com/version.longStamp=$(VERSIONNAME) -X tailscale.com/version.shortStamp=$(VERSIONNAME_SHORT) -X tailscale.com/version.gitCommitStamp=$(TAILSCALE_COMMIT) -X tailscale.com/version.extraGitCommitStamp=$(OUR_VERSION) VERSION_LDFLAGS=-X tailscale.com/version.longStamp=$(VERSIONNAME) -X tailscale.com/version.shortStamp=$(VERSIONNAME_SHORT) -X tailscale.com/version.gitCommitStamp=$(TAILSCALE_COMMIT) -X tailscale.com/version.extraGitCommitStamp=$(OUR_VERSION)
FULL_LDFLAGS=$(VERSION_LDFLAGS) -w FULL_LDFLAGS=$(VERSION_LDFLAGS) -w
@ -59,7 +68,26 @@ export JAVA_HOME ?= $(shell find "$(ANDROID_STUDIO_ROOT)/jbr" "$(ANDROID_STUDIO_
# If JAVA_HOME is still unset, remove it, because SDK tools go into a CPU spin if it is set and empty. # If JAVA_HOME is still unset, remove it, because SDK tools go into a CPU spin if it is set and empty.
ifeq ($(JAVA_HOME),) ifeq ($(JAVA_HOME),)
unexport JAVA_HOME unexport JAVA_HOME
else
export PATH := $(JAVA_HOME)/bin:$(PATH)
endif
AVD_BASE_IMAGE := "system-images;android-33;google_apis;"
export HOST_ARCH=$(shell uname -m)
ifeq ($(HOST_ARCH),aarch64)
AVD_IMAGE := "$(AVD_BASE_IMAGE)arm64-v8a"
else ifeq ($(HOST_ARCH),arm64)
AVD_IMAGE := "$(AVD_BASE_IMAGE)arm64-v8a"
else
AVD_IMAGE := "$(AVD_BASE_IMAGE)x86_64"
endif endif
AVD ?= tailscale-$(HOST_ARCH)
export AVD_IMAGE
export AVD
# Use our toolchain or the one that is specified, do not perform dynamic toolchain switching.
GOTOOLCHAIN=local
export GOTOOLCHAIN
# TOOLCHAINDIR is set by fdoid CI and used by tool/* scripts. # TOOLCHAINDIR is set by fdoid CI and used by tool/* scripts.
TOOLCHAINDIR ?= TOOLCHAINDIR ?=
@ -68,11 +96,81 @@ export TOOLCHAINDIR
GOBIN ?= $(PWD)/android/build/go/bin GOBIN ?= $(PWD)/android/build/go/bin
export GOBIN export GOBIN
export PATH := $(PWD)/tool:$(GOBIN):$(JAVA_HOME)/bin:$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$(PATH) export PATH := $(PWD)/tool:$(GOBIN):$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$(PATH)
export GOROOT := # Unset export GOROOT := # Unset
all: tailscale-new-debug.apk test $(DEBUG_APK) tailscale-fdroid.apk ## Build and test everything #
# Android Builds:
#
.PHONY: apk
apk: $(DEBUG_APK) ## Build the debug APK
.PHONY: tailscale-debug
tailscale-debug: $(DEBUG_APK) ## Build the debug APK
# Builds the release AAB and signs it (phone/tablet/chromeOS variant)
.PHONY: release
release: update-version jarsign-env $(RELEASE_AAB) ## Build the release AAB
@jarsigner -sigalg SHA256withRSA -digestalg SHA-256 -keystore $(JKS_PATH) -storepass $(JKS_PASSWORD) $(RELEASE_AAB) tailscale
# Builds the release AAB and signs it (androidTV variant)
.PHONY: release-tv
release-tv: update-version jarsign-env $(RELEASE_TV_AAB) ## Build the release AAB
@jarsigner -sigalg SHA256withRSA -digestalg SHA-256 -keystore $(JKS_PATH) -storepass $(JKS_PASSWORD) $(RELEASE_TV_AAB) tailscale
# gradle-dependencies groups together the android sources and libtailscale needed to assemble tests/debug/release builds.
.PHONY: gradle-dependencies
gradle-dependencies: $(shell find android -type f -not -path "android/build/*" -not -path '*/.*') $(LIBTAILSCALE)
$(DEBUG_APK): gradle-dependencies
(cd android && ./gradlew test assembleDebug)
install -C android/build/outputs/apk/debug/android-debug.apk $@
$(RELEASE_AAB): gradle-dependencies
@echo "Building release AAB"
(cd android && ./gradlew test bundleRelease)
install -C ./android/build/outputs/bundle/release/android-release.aab $@
$(RELEASE_TV_AAB): gradle-dependencies
@echo "Building TV release AAB"
(cd android && ./gradlew test bundleRelease_tv)
install -C ./android/build/outputs/bundle/release_tv/android-release_tv.aab $@
tailscale-test.apk: gradle-dependencies
(cd android && ./gradlew assembleApplicationTestAndroidTest)
install -C ./android/build/outputs/apk/androidTest/applicationTest/android-applicationTest-androidTest.apk $@
#
# Go Builds:
#
android/libs:
mkdir -p android/libs
$(GOBIN)/gomobile: $(GOBIN)/gobind go.mod go.sum
./tool/go install golang.org/x/mobile/cmd/gomobile
$(GOBIN)/gobind: go.mod go.sum
./tool/go install golang.org/x/mobile/cmd/gobind
$(LIBTAILSCALE): Makefile android/libs $(shell find libtailscale -name *.go) go.mod go.sum $(GOBIN)/gomobile
$(GOBIN)/gomobile bind -target android -androidapi 26 \
-tags "$$(./build-tags.sh)" \
-ldflags "$(FULL_LDFLAGS)" \
-o $@ ./libtailscale
.PHONY: libtailscale
libtailscale: $(LIBTAILSCALE) ## Build the libtailscale AAR
#
# Utility tasks:
#
.PHONY: all
all: test $(DEBUG_APK) ## Build and test everything
.PHONY: env
env: env:
@echo PATH=$(PATH) @echo PATH=$(PATH)
@echo ANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT) @echo ANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT)
@ -80,17 +178,53 @@ env:
@echo ANDROID_STUDIO_ROOT=$(ANDROID_STUDIO_ROOT) @echo ANDROID_STUDIO_ROOT=$(ANDROID_STUDIO_ROOT)
@echo JAVA_HOME=$(JAVA_HOME) @echo JAVA_HOME=$(JAVA_HOME)
@echo TOOLCHAINDIR=$(TOOLCHAINDIR) @echo TOOLCHAINDIR=$(TOOLCHAINDIR)
@echo AVD_IMAGE="$(AVD_IMAGE)"
# Ensure that JKS_PATH and JKS_PASSWORD are set before we attempt a build
# that requires signing.
.PHONY: jarsign-env
jarsign-env:
ifeq ($(JKS_PATH),)
$(error JKS_PATH is not set. export JKS_PATH=/path/to/tailcale.jks)
endif
ifeq ($(JKS_PASSWORD),)
$(error JKS_PASSWORD is not set. export JKS_PASSWORD=passwordForTailcale.jks)
endif
ifeq ($(wildcard $(JKS_PATH)),)
$(error JKS_PATH does not point to a file)
endif
@echo "keystore path set to $(JKS_PATH)"
.PHONY: androidpath
androidpath:
@echo "export ANDROID_HOME=$(ANDROID_HOME)"
@echo "export ANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT)"
@echo 'export PATH=$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$$PATH'
.PHONY: tag_release
tag_release: ## Tag the current commit with the current version
git tag -a "$(VERSION_LONG)" -m "OSS and Version updated to ${VERSION_LONG}"
.PHONY: bumposs ## Bump to the latest oss and update teh versions.
bumposs: update-oss update-version
git commit -sm "android: bumping OSS" -m "OSS and Version updated to ${VERSION_LONG}" go.toolchain.rev android/build.gradle go.mod go.sum
git tag -a "$(VERSION_LONG)" -m "OSS and Version updated to ${VERSION_LONG}"
.PHONY: bump_version_code
bump_version_code: ## Bump the version code in build.gradle
sed -i'.bak' 's/versionCode .*/versionCode $(VERSIONCODE_PLUSONE)/' android/build.gradle && rm android/build.gradle.bak
tag_release: ## Tag a release .PHONY: update-version
sed -i'.bak' 's/versionCode $(VERSIONCODE)/versionCode $(VERSIONCODE_PLUSONE)/' android_legacy/build.gradle && rm android_legacy/build.gradle.bak update-version: ## Update the version in build.gradle
sed -i'.bak' 's/versionName .*/versionName "$(VERSION_LONG)"/' android_legacy/build.gradle && rm android_legacy/build.gradle.bak sed -i'.bak' 's/versionName .*/versionName "$(VERSION_LONG)"/' android/build.gradle && rm android/build.gradle.bak
git commit -sm "android: bump version code" android_legacy/build.gradle
git tag -a "$(VERSION_LONG)"
bumposs: ## Update the tailscale.com go module .PHONY: update-oss
GOPROXY=direct go get tailscale.com@main update-oss: ## Update the tailscale.com go module and update the version in build.gradle
go run tailscale.com/cmd/printdep --go > go.toolchain.rev GOPROXY=direct ./tool/go get tailscale.com@main
go mod tidy -compat=1.22 ./tool/go run tailscale.com/cmd/printdep --go > go.toolchain.rev.new
mv go.toolchain.rev.new go.toolchain.rev
./tool/go mod tidy -compat=1.23
# Get the commandline tools package, this provides (among other things) the sdkmanager binary. # Get the commandline tools package, this provides (among other things) the sdkmanager binary.
$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager: $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager:
@ -103,6 +237,7 @@ $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager:
mv $(ANDROID_HOME)/tmp/cmdline-tools $(ANDROID_HOME)/cmdline-tools/latest mv $(ANDROID_HOME)/tmp/cmdline-tools $(ANDROID_HOME)/cmdline-tools/latest
rm -rf $(ANDROID_HOME)/tmp rm -rf $(ANDROID_HOME)/tmp
.PHONY: androidsdk
androidsdk: $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager ## Install the set of Android SDK packages we need. androidsdk: $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager ## Install the set of Android SDK packages we need.
yes | $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null yes | $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null
$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --update $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --update
@ -111,6 +246,7 @@ androidsdk: $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager ## Install the s
# Normally in make you would simply take a dependency on the task that provides # Normally in make you would simply take a dependency on the task that provides
# the binaries, however users may have a decision to make as to whether they # the binaries, however users may have a decision to make as to whether they
# want to install an SDK or use the one from an Android Studio installation. # want to install an SDK or use the one from an Android Studio installation.
.PHONY: checkandroidsdk
checkandroidsdk: ## Check that Android SDK is installed checkandroidsdk: ## Check that Android SDK is installed
@$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --list_installed | grep -q 'ndk' || (\ @$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --list_installed | grep -q 'ndk' || (\
echo -e "\n\tERROR: Android SDK not installed.\n\ echo -e "\n\tERROR: Android SDK not installed.\n\
@ -118,100 +254,74 @@ checkandroidsdk: ## Check that Android SDK is installed
\tANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT)\n\n\ \tANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT)\n\n\
See README.md for instructions on how to install the prerequisites.\n"; exit 1) See README.md for instructions on how to install the prerequisites.\n"; exit 1)
androidpath: .PHONY: test
@echo "export ANDROID_HOME=$(ANDROID_HOME)" test: gradle-dependencies ## Run the Android tests
@echo "export ANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT)"
@echo 'export PATH=$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$$PATH'
$(AAR): checkandroidsdk
@mkdir -p android_legacy/libs && \
go run gioui.org/cmd/gogio \
-ldflags "$(VERSION_LDFLAGS)" \
-buildmode archive -target android -appid $(APPID) -tags novulkan,tailscale_go -o $@ github.com/tailscale/tailscale-android/cmd/tailscale
# tailscale-debug.apk builds a debuggable APK with the Google Play SDK.
$(DEBUG_APK): $(AAR)
(cd android_legacy && ./gradlew test assemblePlayDebug)
mv android_legacy/build/outputs/apk/play/debug/android_legacy-play-debug.apk $@
# tailscale-fdroid.apk builds a non-Google Play SDK, without the Google bits.
# This is effectively what the F-Droid build definition produces.
# This is useful for testing on e.g. Amazon Fire Stick devices.
tailscale-fdroid.apk: $(AAR)
(cd android_legacy && ./gradlew test assembleFdroidDebug)
mv android_legacy/build/outputs/apk/fdroid/debug/android_legacy-fdroid-debug.apk $@
$(RELEASE_AAB): $(AAR)
(cd android_legacy && ./gradlew test bundlePlayRelease)
mv ./android_legacy/build/outputs/bundle/playRelease/android_legacy-play-release.aab $@
release: $(RELEASE_AAB) ## Build the release AAB
jarsigner -sigalg SHA256withRSA -digestalg SHA-256 -keystore $(KEYSTORE) $(RELEASE_AAB) $(KEYSTORE_ALIAS)
apk: $(DEBUG_APK) ## Build the debug APK
LIBTAILSCALE=android/libs/libtailscale.aar
LIBTAILSCALE_SOURCES=$(shell find libtailscale -name *.go) go.mod go.sum
android/libs:
mkdir -p android/libs
$(GOBIN)/gomobile: $(GOBIN)/gobind go.mod go.sum
go install golang.org/x/mobile/cmd/gomobile
$(GOBIN)/gobind: go.mod go.sum
go install golang.org/x/mobile/cmd/gobind
# TODO: the version names used below parse the legacy version information,
# though hopefully they won't substantially differ for now.
$(LIBTAILSCALE): Makefile android/libs $(LIBTAILSCALE_SOURCES) $(GOBIN)/gomobile
gomobile bind -target android -androidapi 26 \
-ldflags "$(FULL_LDFLAGS)" \
-o $@ ./libtailscale
libtailscale: $(LIBTAILSCALE)
ANDROID_SOURCES=$(shell find android -type f -not -path "android/build/*" -not -path '*/.*')
DEBUG_INTERMEDIARY = android/build/outputs/apk/debug/android-debug.apk
$(DEBUG_INTERMEDIARY): $(ANDROID_SOURCES) $(LIBTAILSCALE)
cd android && ./gradlew test assembleDebug
tailscale-new-debug.apk: $(DEBUG_INTERMEDIARY)
(cd android && ./gradlew test assembleDebug)
mv $(DEBUG_INTERMEDIARY) $@
tailscale-new-debug: tailscale-new-debug.apk ## Build the new debug APK
ANDROID_TEST_INTERMEDIARY=./android/build/outputs/apk/androidTest/applicationTest/android-applicationTest-androidTest.apk
$(ANDROID_TEST_INTERMEDIARY): $(ANDROID_SOURCES) $(LIBTAILSCALE)
cd android && ./gradlew assembleApplicationTestAndroidTest
tailscale-test.apk: $(ANDROID_TEST_INTERMEDIARY)
mv $(ANDROID_TEST_INTERMEDIARY) $@
test: $(LIBTAILSCALE) ## Run the Android tests
(cd android && ./gradlew test) (cd android && ./gradlew test)
install: tailscale-new-debug.apk ## Install the debug APK on a connected device .PHONY: emulator
emulator: ## Start an android emulator instance
@echo "Checking installed SDK packages..."
@if ! $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --list_installed | grep -q "$(AVD_IMAGE)"; then \
echo "$(AVD_IMAGE) not found, installing..."; \
$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager "$(AVD_IMAGE)"; \
fi
@echo "Checking if AVD exists..."
@if ! $(ANDROID_HOME)/cmdline-tools/latest/bin/avdmanager list avd | grep -q "$(AVD)"; then \
echo "AVD $(AVD) not found, creating..."; \
$(ANDROID_HOME)/cmdline-tools/latest/bin/avdmanager create avd -n "$(AVD)" -k "$(AVD_IMAGE)"; \
fi
@echo "Starting emulator..."
@$(ANDROID_HOME)/emulator/emulator -avd "$(AVD)" -logcat-output /dev/stdout -netdelay none -netspeed full
.PHONY: install
install: $(DEBUG_APK) ## Install the debug APK on a connected device
adb install -r $< adb install -r $<
.PHONY: run
run: install ## Run the debug APK on a connected device run: install ## Run the debug APK on a connected device
adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.IPNActivity adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.MainActivity
dockershell: ## Run a shell in the Docker build container .PHONY: docker-build-image
docker build -t tailscale-android . docker-build-image: ## Builds the docker image for the android build environment if it does not exist
docker run -v $(CURDIR):/build/tailscale-android -it --rm tailscale-android @echo "Checking if docker image $(DOCKER_IMAGE) already exists..."
@if ! docker images $(DOCKER_IMAGE) -q | grep -q . ; then \
clean: ## Remove build artifacts echo "Image does not exist. Building..."; \
-rm -rf android/build android_legacy/build $(DEBUG_APK) $(RELEASE_AAB) $(AAR) $(LIBTAILSCALE) android/libs tailscale-fdroid.apk *.apk docker build -f docker/DockerFile.amd64-build -t $(DOCKER_IMAGE) .; \
fi
.PHONY: docker-run-build
docker-run-build: clean jarsign-env docker-build-image ## Runs the docker image for the android build environment and builds release
@docker run --rm -v $(CURDIR):/build/tailscale-android --env JKS_PASSWORD=$(JKS_PASSWORD) --env JKS_PATH=$(JKS_PATH) $(DOCKER_IMAGE)
.PHONY: docker-remove-build-image
docker-remove-build-image: ## Removes the current docker build image
docker rmi --force $(DOCKER_IMAGE)
.PHONY: docker-all ## Makes a fresh docker environment, builds docker and cleans up. For CI.
docker-all: docker-build-image docker-run-build $(DOCKER_IMAGE)
.PHONY: docker-shell
docker-shell: ## Builds a docker image with the android build env and opens a shell
docker build -f docker/DockerFile.amd64-shell -t tailscale-android-shell-amd64 .
docker run --rm -v $(CURDIR):/build/tailscale-android -it tailscale-android-shell-amd64
.PHONY: docker-remove-shell-image
docker-remove-shell-image: ## Removes all docker shell image
docker rmi --force tailscale-android-shell-amd64
.PHONY: clean
clean: ## Remove build artifacts. Does not purge docker build envs. Use dockerRemoveEnv for that.
clean: ## Remove build artifacts. Does not purge docker build envs. Use dockerRemoveEnv for that.
@echo "Cleaning up old build artifacts"
-rm -rf android/build $(DEBUG_APK) $(RELEASE_AAB) $(RELEASE_TV_AAB) $(LIBTAILSCALE) android/libs *.apk *.aab
@echo "Cleaning cached toolchain"
-rm -rf $(HOME)/.cache/tailscale-go{,.extracted}
-pkill -f gradle -pkill -f gradle
.PHONY: help
help: ## Show this help help: ## Show this help
@echo "\nSpecify a command. The choices are:\n" @echo "\nSpecify a command. The choices are:\n"
@grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' ${MAKEFILE_LIST} | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-20s\033[m %s\n", $$1, $$2}' @grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' ${MAKEFILE_LIST} | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-20s\033[m %s\n", $$1, $$2}'
@echo "" @echo ""
.PHONY: all clean install android_legacy/lib $(DEBUG_APK) $(RELEASE_AAB) $(AAR) release bump_version dockershell lib tailscale-new-debug help
.DEFAULT_GOAL := help .DEFAULT_GOAL := help

@ -10,13 +10,19 @@ This repository contains the open source Tailscale Android client.
## Using ## Using
[<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.tailscale.ipn/)
[<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png" [<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png"
alt="Get it on Google Play" alt="Get it on Google Play"
height="80">](https://play.google.com/store/apps/details?id=com.tailscale.ipn) height="80">](https://play.google.com/store/apps/details?id=com.tailscale.ipn)
Help us test new features and bug-fixes before they ship to all users! A [beta testing track](https://play.google.com/apps/testing/com.tailscale.ipn) is available on the Play Store.
#### Amazon Appstore
The app can be downloaded from the [Amazon Appstore](https://www.amazon.com/dp/B0D38TRB3N) for Amazon Fire tablets and Fire TV devices.
#### F-Droid
The [F-Droid](https://f-droid.org/packages/com.tailscale.ipn/) project builds the source code in this repository and maintains independently-built APKs. Note that F-Droid builds are not released, updated, or verified by the Tailscale team.
## Preparing a build environment ## Preparing a build environment
@ -57,9 +63,15 @@ and XML files in Android Studio. Enable "Format on Save".
If you wish to avoid installing software on your host system, a Docker based development strategy is available, you can build and start a shell with: If you wish to avoid installing software on your host system, a Docker based development strategy is available, you can build and start a shell with:
```sh ```sh
make dockershell make docker-shell
``` ```
Several other makefile recipes are available for setting up the proper build environment and running builds.
Note that the docker makefile recipes s will preserve the image and remove container on completion.
If changes are made to the build environment or toolchain, cached docker images may need to be rebuilt.
The docker build image name is parameterized in the makefile and changing it provides a simple means to do this.
### Nix ### Nix
If you have Nix 2.4 or later installed, a Nix development environment can If you have Nix 2.4 or later installed, a Nix development environment can
@ -87,38 +99,6 @@ release candidate builds (currently Go 1.14) in module mode. It might
work in earlier Go versions or in GOPATH mode, but we're making no work in earlier Go versions or in GOPATH mode, but we're making no
effort to keep those working. effort to keep those working.
## Google Sign-In
Google Sign-In support relies on configuring a [Google API Console
project](https://developers.google.com/identity/sign-in/android/start-integrating)
with the app identifier and [signing key
hashes](https://developers.google.com/android/guides/client-auth).
The official release uses the app identifier `com.tailscale.ipn`;
custom builds should use a different identifier.
## Running in the Android emulator
By default, the android emulator uses an older version of OpenGL ES,
which results in a black screen when opening the Tailscale app. To fix
this, with the emulator running:
- Open the three-dots menu to access emulator settings
- To to `Settings > Advanced`
- Set "OpenGL ES API level" to "Renderer maximum (up to OpenGL ES 3.1)"
- Close the emulator.
- In Android Studio's emulator view (that lists all your emulated
devices), hit the down arrow by the virtual device and select "Cold
boot now" to restart the emulator from scratch.
The Tailscale app should now render correctly.
Additionally, there seems to be a bug that prevents using the
system-level Google sign-in option (the one that pops up a
system-level UI to select your Google account). You can work around
this by selecting "Other" at the sign-in screen, and then selecting
Google from the next screen.
## Developing on a Fire Stick TV ## Developing on a Fire Stick TV
On the Fire Stick: On the Fire Stick:
@ -129,7 +109,7 @@ Then some useful commands:
``` ```
adb connect 10.2.200.213:5555 adb connect 10.2.200.213:5555
adb install -r tailscale-fdroid.apk adb install -r tailscale-fdroid.apk
adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.IPNActivity adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.MainActivity
adb shell pm uninstall com.tailscale.ipn adb shell pm uninstall com.tailscale.ipn
``` ```
@ -151,8 +131,8 @@ Origin](https://en.wikipedia.org/wiki/Developer_Certificate_of_Origin)
## About Us ## About Us
We are apenwarr, bradfitz, crawshaw, danderson, dfcarney, We are [Tailscale](https://tailscale.com). See
from Tailscale Inc. https://tailscale.com/company for more about us and what we're
You can learn more about us from [our website](https://tailscale.com). building.
WireGuard is a registered trademark of Jason A. Donenfeld. WireGuard is a registered trademark of Jason A. Donenfeld.

@ -11,7 +11,7 @@ buildscript {
} }
} }
dependencies { dependencies {
classpath "com.android.tools.build:gradle:8.1.4" classpath 'com.android.tools.build:gradle:8.5.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
classpath("com.ncorti.ktfmt.gradle:plugin:0.17.0") classpath("com.ncorti.ktfmt.gradle:plugin:0.17.0")
@ -37,16 +37,36 @@ android {
defaultConfig { defaultConfig {
minSdkVersion 26 minSdkVersion 26
targetSdkVersion 34 targetSdkVersion 34
versionCode 198 versionCode 241
versionName "1.59.53-t0f042b981-g1017015de26" versionName "1.75.11-t8b962f23d-gf07d419a125"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// This setting, which defaults to 'true', will cause Tailscale to fall
// back to the Google DNS servers if it cannot determine what the
// operating system's DNS configuration is.
//
// Set it to false either here or in your local.properties file to
// disable this behaviour.
buildConfigField "boolean", "USE_GOOGLE_DNS_FALLBACK", getLocalProperty("tailscale.useGoogleDnsFallback", "true")
} }
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_17 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17
} }
lintOptions {
warningsAsErrors true
}
kotlinOptions {
jvmTarget = "17"
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures { buildFeatures {
buildConfig true
compose true compose true
} }
composeOptions { composeOptions {
@ -58,9 +78,28 @@ android {
buildTypes { buildTypes {
applicationTest { applicationTest {
initWith debug initWith debug
buildConfigField "String", "GITHUB_USERNAME", "\"" + getLocalProperty("githubUsername")+"\"" manifestPlaceholders.leanbackRequired = false
buildConfigField "String", "GITHUB_PASSWORD", "\"" + getLocalProperty("githubPassword")+"\"" buildConfigField "String", "GITHUB_USERNAME", "\"" + getLocalProperty("githubUsername", "")+"\""
buildConfigField "String", "GITHUB_2FA_SECRET", "\"" + getLocalProperty("github2FASecret")+"\"" buildConfigField "String", "GITHUB_PASSWORD", "\"" + getLocalProperty("githubPassword", "")+"\""
buildConfigField "String", "GITHUB_2FA_SECRET", "\"" + getLocalProperty("github2FASecret", "")+"\""
}
debug {
manifestPlaceholders.leanbackRequired = false
}
release {
manifestPlaceholders.leanbackRequired = false
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile(
'proguard-android-optimize.txt'),
'proguard-rules.pro'
}
release_tv {
initWith release
manifestPlaceholders.leanbackRequired = true
} }
} }
@ -69,64 +108,76 @@ android {
dependencies { dependencies {
// Android dependencies. // Android dependencies.
implementation "androidx.core:core:1.12.0" implementation "androidx.core:core:1.13.1"
implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.core:core-ktx:1.13.1'
implementation "androidx.browser:browser:1.8.0" implementation "androidx.browser:browser:1.8.0"
implementation "androidx.security:security-crypto:1.1.0-alpha06" implementation "androidx.security:security-crypto:1.1.0-alpha06"
implementation "androidx.work:work-runtime:2.9.0" implementation "androidx.work:work-runtime:2.9.0"
// Kotlin dependencies. // Kotlin dependencies.
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1"
runtimeOnly "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0" implementation 'junit:junit:4.13.2'
runtimeOnly "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
// Compose dependencies. // Compose dependencies.
def composeBom = platform('androidx.compose:compose-bom:2023.06.01') def composeBom = platform('androidx.compose:compose-bom:2024.06.00')
implementation composeBom implementation composeBom
androidTestImplementation composeBom
implementation 'androidx.compose.material3:material3:1.2.1' implementation 'androidx.compose.material3:material3:1.2.1'
implementation 'androidx.compose.material:material-icons-core:1.6.3' implementation 'androidx.compose.material:material-icons-core:1.6.8'
implementation "androidx.compose.ui:ui:1.6.3" implementation "androidx.compose.ui:ui:1.6.8"
implementation "androidx.compose.ui:ui-tooling:1.6.3" implementation "androidx.compose.ui:ui-tooling:1.6.8"
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3'
implementation 'androidx.activity:activity-compose:1.8.2' implementation 'androidx.activity:activity-compose:1.9.0'
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version" implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version" implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version"
implementation "androidx.core:core-splashscreen:1.1.0-alpha02" implementation "androidx.core:core-splashscreen:1.1.0-rc01"
// Navigation dependencies. // Navigation dependencies.
def nav_version = "2.7.7" def nav_version = "2.7.7"
implementation "androidx.navigation:navigation-compose:$nav_version" implementation "androidx.navigation:navigation-compose:$nav_version"
implementation "androidx.navigation:navigation-ui:$nav_version" implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
// Supporting libraries. // Supporting libraries.
implementation("io.coil-kt:coil-compose:2.6.0") implementation("io.coil-kt:coil-compose:2.6.0")
implementation("com.google.zxing:core:3.5.1") implementation("com.google.zxing:core:3.5.1")
implementation("com.patrykandpatrick.vico:compose:1.15.0")
implementation("com.patrykandpatrick.vico:compose-m3:1.15.0")
// Tailscale dependencies. // Tailscale dependencies.
implementation ':libtailscale@aar' implementation ':libtailscale@aar'
// Tests // Integration Tests
testImplementation 'junit:junit:4.13.2' androidTestImplementation composeBom
androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test:runner:1.6.1'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5' androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1'
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
implementation 'androidx.test.uiautomator:uiautomator:2.3.0' implementation 'androidx.test.uiautomator:uiautomator:2.3.0'
// Authentication only for tests // Authentication only for tests
androidTestImplementation 'dev.turingcomplete:kotlin-onetimepassword:2.4.0' androidTestImplementation 'dev.turingcomplete:kotlin-onetimepassword:2.4.0'
androidTestImplementation 'commons-codec:commons-codec:1.16.1' androidTestImplementation 'commons-codec:commons-codec:1.16.1'
// Unit Tests
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:5.4.0'
testImplementation 'org.mockito:mockito-inline:5.2.0'
testImplementation 'org.mockito.kotlin:mockito-kotlin:5.4.0'
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.compose.ui:ui-tooling-preview")
} }
def getLocalProperty(key) { def getLocalProperty(key, defaultValue) {
try { try {
Properties properties = new Properties() Properties properties = new Properties()
properties.load(project.file('local.properties').newDataInputStream()) properties.load(project.file('local.properties').newDataInputStream())
return properties.getProperty(key); return properties.getProperty(key) ?: defaultValue
} catch(Throwable ignored) { } catch(Throwable ignored) {
return "" return defaultValue
} }
} }

@ -1,5 +1,5 @@
android.defaults.buildfeatures.buildconfig=true android.defaults.buildfeatures.buildconfig=true
android.nonFinalResIds=false android.nonFinalResIds=false
android.nonTransitiveRClass=false android.nonTransitiveRClass=true
android.useAndroidX=true android.useAndroidX=true
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionSha256Sum=38f66cd6eef217b4c35855bb11ea4e9fbc53594ccccb5fb82dfd317ef8c2c5a3 distributionSha256Sum=544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

@ -0,0 +1,25 @@
# Keep all classes with native methods
-keepclasseswithmembernames class * {
native <methods>;
}
# Keep the classes with syspolicy MDM keys, some of which
# get used only by the Go backend.
-keep class com.tailscale.ipn.mdm.** { *; }
# Keep specific classes from Tink library
-keep class com.google.crypto.tink.** { *; }
# Ignore warnings about missing Error Prone annotations
-dontwarn com.google.errorprone.annotations.**
# Keep Error Prone annotations if referenced
-keep class com.google.errorprone.annotations.** { *; }
# Keep Google HTTP Client classes
-keep class com.google.api.client.http.** { *; }
-dontwarn com.google.api.client.http.**
# Keep Joda-Time classes
-keep class org.joda.time.** { *; }
-dontwarn org.joda.time.**

@ -1,15 +1,20 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" /> android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" /> <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<!-- Disable input emulation on ChromeOS --> <!-- Disable input emulation on ChromeOS -->
<uses-feature <uses-feature
@ -19,7 +24,7 @@
<!-- Signal support for Android TV --> <!-- Signal support for Android TV -->
<uses-feature <uses-feature
android:name="android.software.leanback" android:name="android.software.leanback"
android:required="false" /> android:required="${leanbackRequired}" />
<uses-feature <uses-feature
android:name="android.hardware.touchscreen" android:name="android.hardware.touchscreen"
android:required="false" /> android:required="false" />
@ -30,6 +35,7 @@
android:banner="@drawable/tv_banner" android:banner="@drawable/tv_banner"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="Tailscale" android:label="Tailscale"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.App.SplashScreen"> android:theme="@style/Theme.App.SplashScreen">
<activity <activity
@ -55,6 +61,7 @@
android:exported="true" android:exported="true"
android:label="@string/app_name" android:label="@string/app_name"
android:launchMode="singleTask" android:launchMode="singleTask"
android:theme="@style/Theme.MaterialComponents.DayNight.NoActionBar"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
@ -82,19 +89,20 @@
</intent-filter> </intent-filter>
</activity> </activity>
<receiver <receiver
android:name="IPNReceiver" android:name="IPNReceiver"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="com.tailscale.ipn.CONNECT_VPN" /> <action android:name="com.tailscale.ipn.CONNECT_VPN" />
<action android:name="com.tailscale.ipn.DISCONNECT_VPN" /> <action android:name="com.tailscale.ipn.DISCONNECT_VPN" />
<action android:name="com.tailscale.ipn.USE_EXIT_NODE" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<service <service
android:name=".IPNService" android:name=".IPNService"
android:exported="false" android:exported="false"
android:foregroundServiceType="systemExempted"
android:permission="android.permission.BIND_VPN_SERVICE"> android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter> <intent-filter>
<action android:name="android.net.VpnService" /> <action android:name="android.net.VpnService" />

@ -3,98 +3,84 @@
package com.tailscale.ipn package com.tailscale.ipn
import android.Manifest import android.Manifest
import android.app.Activity
import android.app.Application import android.app.Application
import android.app.DownloadManager import android.app.Notification
import android.app.Fragment
import android.app.FragmentTransaction
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.PendingIntent import android.app.PendingIntent
import android.app.UiModeManager
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.Configuration
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.Uri
import android.net.VpnService
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.MediaStore
import android.provider.Settings
import android.util.Log import android.util.Log
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.localapi.Request import com.tailscale.ipn.ui.localapi.Request
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.HealthNotifier
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import libtailscale.Libtailscale import libtailscale.Libtailscale
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.net.InetAddress
import java.net.NetworkInterface import java.net.NetworkInterface
import java.security.GeneralSecurityException import java.security.GeneralSecurityException
import java.util.Locale import java.util.Locale
class App : Application(), libtailscale.AppContext { class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
companion object { companion object {
const val STATUS_CHANNEL_ID = "tailscale-status"
const val STATUS_NOTIFICATION_ID = 1
const val NOTIFY_CHANNEL_ID = "tailscale-notify"
const val NOTIFY_NOTIFICATION_ID = 2
private const val PEER_TAG = "peer"
private const val FILE_CHANNEL_ID = "tailscale-files" private const val FILE_CHANNEL_ID = "tailscale-files"
private const val FILE_NOTIFICATION_ID = 3
private const val TAG = "App" private const val TAG = "App"
private val networkConnectivityRequest = private lateinit var appInstance: App
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
.build()
lateinit var appInstance: App
@JvmStatic
fun startActivityForResult(act: Activity, intent: Intent?, request: Int) {
val f: Fragment = act.fragmentManager.findFragmentByTag(PEER_TAG)
f.startActivityForResult(intent, request)
}
/**
* Initializes the app (if necessary) and returns the singleton app instance. Always use this
* function to obtain an App reference to make sure the app initializes.
*/
@JvmStatic @JvmStatic
fun getApplication(): App { fun get(): App {
appInstance.initOnce()
return appInstance return appInstance
} }
} }
val dns = DnsConfig() val dns = DnsConfig()
var autoConnect = false
var vpnReady = false
private lateinit var connectivityManager: ConnectivityManager private lateinit var connectivityManager: ConnectivityManager
private lateinit var app: libtailscale.Application private lateinit var app: libtailscale.Application
override val viewModelStore: ViewModelStore
get() = appViewModelStore
private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() }
var healthNotifier: HealthNotifier? = null
override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString
override fun isPlayVersion(): Boolean = MaybeGoogle.isGoogle() override fun getInstallSource(): String = AppSourceChecker.getInstallSource(this)
override fun shouldUseGoogleDNSFallback(): Boolean = BuildConfig.USE_GOOGLE_DNS_FALLBACK
override fun log(s: String, s1: String) { override fun log(s: String, s1: String) {
Log.d(s, s1) Log.d(s, s1)
@ -102,6 +88,42 @@ class App : Application(), libtailscale.AppContext {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
createNotificationChannel(
STATUS_CHANNEL_ID,
getString(R.string.vpn_status),
getString(R.string.optional_notifications_which_display_the_status_of_the_vpn_tunnel),
NotificationManagerCompat.IMPORTANCE_MIN)
createNotificationChannel(
FILE_CHANNEL_ID,
getString(R.string.taildrop_file_transfers),
getString(R.string.notifications_delivered_when_a_file_is_received_using_taildrop),
NotificationManagerCompat.IMPORTANCE_DEFAULT)
createNotificationChannel(
HealthNotifier.HEALTH_CHANNEL_ID,
getString(R.string.health_channel_name),
getString(R.string.health_channel_description),
NotificationManagerCompat.IMPORTANCE_HIGH)
appInstance = this
setUnprotectedInstance(this)
}
override fun onTerminate() {
super.onTerminate()
Notifier.stop()
notificationManager.cancelAll()
applicationScope.cancel()
viewModelStore.clear()
}
private var isInitialized = false
@Synchronized
private fun initOnce() {
if (isInitialized) {
return
}
isInitialized = true
val dataDir = this.filesDir.absolutePath val dataDir = this.filesDir.absolutePath
// Set this to enable direct mode for taildrop whereby downloads will be saved directly // Set this to enable direct mode for taildrop whereby downloads will be saved directly
@ -109,90 +131,45 @@ class App : Application(), libtailscale.AppContext {
// an app local directory "Taildrop" if we cannot create that. This mode does not support // an app local directory "Taildrop" if we cannot create that. This mode does not support
// user notifications for incoming files. // user notifications for incoming files.
val directFileDir = this.prepareDownloadsFolder() val directFileDir = this.prepareDownloadsFolder()
app = Libtailscale.start(dataDir, directFileDir.absolutePath, this) app = Libtailscale.start(dataDir, directFileDir.absolutePath, this)
Request.setApp(app) Request.setApp(app)
Notifier.setApp(app) Notifier.setApp(app)
Notifier.start(applicationScope) Notifier.start(applicationScope)
healthNotifier = HealthNotifier(Notifier.health, applicationScope)
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
setAndRegisterNetworkCallbacks() NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
createNotificationChannel( initViewModels()
NOTIFY_CHANNEL_ID, "Notifications", NotificationManagerCompat.IMPORTANCE_DEFAULT)
createNotificationChannel(
STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW)
createNotificationChannel(
FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT)
appInstance = this
applicationScope.launch { applicationScope.launch {
Notifier.tileReady.collect { isTileReady -> setTileReady(isTileReady) } Notifier.state.collect { state ->
val ableToStartVPN = state > Ipn.State.NeedsMachineAuth
// If VPN is stopped, show a disconnected notification. If it is running as a foregrround
// service, IPNService will show a connected notification.
if (state == Ipn.State.Stopped) {
notifyStatus(false)
}
val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running
updateConnStatus(ableToStartVPN)
QuickToggleService.setVPNRunning(vpnRunning)
}
} }
} }
override fun onTerminate() { private fun initViewModels() {
super.onTerminate() vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java)
Notifier.stop()
applicationScope.cancel()
} }
fun setWantRunning(wantRunning: Boolean) { fun setWantRunning(wantRunning: Boolean, onSuccess: (() -> Unit)? = null) {
val callback: (Result<Ipn.Prefs>) -> Unit = { result -> val callback: (Result<Ipn.Prefs>) -> Unit = { result ->
result.fold( result.fold(
onSuccess = { _ -> setTileStatus(wantRunning) }, onSuccess = { onSuccess?.invoke() },
onFailure = { error -> onFailure = { error ->
Log.d("TAG", "Set want running: failed to update preferences: ${error.message}") TSLog.d("TAG", "Set want running: failed to update preferences: ${error.message}")
}) })
} }
Client(applicationScope) Client(applicationScope)
.editPrefs(Ipn.MaskedPrefs().apply { WantRunning = wantRunning }, callback) .editPrefs(Ipn.MaskedPrefs().apply { WantRunning = wantRunning }, callback)
} }
// requestNetwork attempts to find the best network that matches the passed NetworkRequest. It is
// possible that this might return an unusuable network, eg a captive portal.
private fun setAndRegisterNetworkCallbacks() {
connectivityManager.requestNetwork(
networkConnectivityRequest,
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
super.onAvailable(network)
val sb = StringBuilder()
val linkProperties: LinkProperties? = connectivityManager.getLinkProperties(network)
val dnsList: MutableList<InetAddress> = linkProperties?.dnsServers ?: mutableListOf()
for (ip in dnsList) {
sb.append(ip.hostAddress).append(" ")
}
val searchDomains: String? = linkProperties?.domains
if (searchDomains != null) {
sb.append("\n")
sb.append(searchDomains)
}
if (dns.updateDNSFromNetwork(sb.toString())) {
Libtailscale.onDnsConfigChanged()
}
}
override fun onLost(network: Network) {
super.onLost(network)
if (dns.updateDNSFromNetwork("")) {
Libtailscale.onDnsConfigChanged()
}
}
})
}
fun startVPN() {
val intent = Intent(this, IPNService::class.java)
intent.setAction(IPNService.ACTION_REQUEST_VPN)
startService(intent)
}
fun stopVPN() {
val intent = Intent(this, IPNService::class.java)
intent.setAction(IPNService.ACTION_STOP_VPN)
startService(intent)
}
// encryptToPref a byte array of data using the Jetpack Security // encryptToPref a byte array of data using the Jetpack Security
// library and writes it to a global encrypted preference store. // library and writes it to a global encrypted preference store.
@Throws(IOException::class, GeneralSecurityException::class) @Throws(IOException::class, GeneralSecurityException::class)
@ -219,30 +196,15 @@ class App : Application(), libtailscale.AppContext {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM) EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
} }
fun setTileReady(ready: Boolean) { /*
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { * setAbleToStartVPN remembers whether or not we're able to start the VPN
return * by storing this in a shared preference. This allows us to check this
} * value without needing a fully initialized instance of the application.
QuickToggleService.setReady(this, ready) */
Log.d("App", "Set Tile Ready: $ready $autoConnect") private fun updateConnStatus(ableToStartVPN: Boolean) {
vpnReady = ready setAbleToStartVPN(ableToStartVPN)
if (ready && autoConnect) { QuickToggleService.updateTile()
startVPN() TSLog.d("App", "Set Tile Ready: $ableToStartVPN")
}
}
fun setTileStatus(status: Boolean) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return
}
QuickToggleService.setStatus(this, status)
}
fun getHostname(): String {
val userConfiguredDeviceName = getUserConfiguredDeviceName()
if (!userConfiguredDeviceName.isNullOrEmpty()) return userConfiguredDeviceName
return modelName
} }
override fun getModelName(): String { override fun getModelName(): String {
@ -258,138 +220,10 @@ class App : Application(), libtailscale.AppContext {
override fun getOSVersion(): String = Build.VERSION.RELEASE override fun getOSVersion(): String = Build.VERSION.RELEASE
// get user defined nickname from Settings
// returns null if not available
private fun getUserConfiguredDeviceName(): String? {
val nameFromSystemDevice = Settings.Secure.getString(contentResolver, "device_name")
if (!nameFromSystemDevice.isNullOrEmpty()) return nameFromSystemDevice
return null
}
// attachPeer adds a Peer fragment for tracking the Activity
// lifecycle.
fun attachPeer(act: Activity) {
act.runOnUiThread(
Runnable {
val ft: FragmentTransaction = act.fragmentManager.beginTransaction()
ft.add(Peer(), PEER_TAG)
ft.commit()
act.fragmentManager.executePendingTransactions()
})
}
override fun isChromeOS(): Boolean { override fun isChromeOS(): Boolean {
return packageManager.hasSystemFeature("android.hardware.type.pc") return packageManager.hasSystemFeature("android.hardware.type.pc")
} }
fun prepareVPN(act: Activity, reqCode: Int) {
act.runOnUiThread(
Runnable {
val intent: Intent? = VpnService.prepare(act)
if (intent == null) {
startVPN()
} else {
startActivityForResult(act, intent, reqCode)
}
})
}
fun showURL(act: Activity, url: String?) {
act.runOnUiThread(
Runnable {
val builder: CustomTabsIntent.Builder = CustomTabsIntent.Builder()
val headerColor = -0xb69b6b
builder.setToolbarColor(headerColor)
val intent: CustomTabsIntent = builder.build()
intent.launchUrl(act, Uri.parse(url))
})
}
@get:Throws(Exception::class)
val packageCertificate: ByteArray?
// getPackageSignatureFingerprint returns the first package signing certificate, if any.
get() {
val info: PackageInfo
info = packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES)
for (signature in info.signatures) {
return signature.toByteArray()
}
return null
}
@Throws(IOException::class)
fun insertMedia(name: String?, mimeType: String): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver: ContentResolver = contentResolver
val contentValues = ContentValues()
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name)
if ("" != mimeType) {
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
}
val root: Uri = MediaStore.Files.getContentUri("external")
resolver.insert(root, contentValues).toString()
} else {
val dir: File = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
dir.mkdirs()
val f = File(dir, name)
Uri.fromFile(f).toString()
}
}
@Throws(IOException::class)
fun openUri(uri: String?, mode: String?): Int? {
val resolver: ContentResolver = contentResolver
return mode?.let { resolver.openFileDescriptor(Uri.parse(uri), it)?.detachFd() }
}
fun deleteUri(uri: String?) {
val resolver: ContentResolver = contentResolver
resolver.delete(Uri.parse(uri), null, null)
}
fun notifyFile(uri: String?, msg: String?) {
val viewIntent: Intent
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
viewIntent = Intent(Intent.ACTION_VIEW, Uri.parse(uri))
} else {
// uri is a file:// which is not allowed to be shared outside the app.
viewIntent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS)
}
val pending: PendingIntent =
PendingIntent.getActivity(this, 0, viewIntent, PendingIntent.FLAG_UPDATE_CURRENT)
val builder: NotificationCompat.Builder =
NotificationCompat.Builder(this, FILE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("File received")
.setContentText(msg)
.setContentIntent(pending)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
val nm: NotificationManagerCompat = NotificationManagerCompat.from(this)
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED) {
// TODO: Consider calling
// ActivityCompat#requestPermissions
// here to request the missing permissions, and then overriding
// public void onRequestPermissionsResult(int requestCode, String[] permissions,
// int[] grantResults)
// to handle the case where the user grants the permission. See the documentation
// for ActivityCompat#requestPermissions for more details.
return
}
nm.notify(FILE_NOTIFICATION_ID, builder.build())
}
fun createNotificationChannel(id: String?, name: String?, importance: Int) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val channel = NotificationChannel(id, name, importance)
val nm: NotificationManagerCompat = NotificationManagerCompat.from(this)
nm.createNotificationChannel(channel)
}
override fun getInterfacesAsString(): String { override fun getInterfacesAsString(): String {
val interfaces: ArrayList<NetworkInterface> = val interfaces: ArrayList<NetworkInterface> =
java.util.Collections.list(NetworkInterface.getNetworkInterfaces()) java.util.Collections.list(NetworkInterface.getNetworkInterfaces())
@ -425,12 +259,7 @@ class App : Application(), libtailscale.AppContext {
return sb.toString() return sb.toString()
} }
fun isTV(): Boolean { private fun prepareDownloadsFolder(): File {
val mm = getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
return mm.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
}
fun prepareDownloadsFolder(): File {
var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
try { try {
@ -438,14 +267,14 @@ class App : Application(), libtailscale.AppContext {
downloads.mkdirs() downloads.mkdirs()
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to create downloads folder: $e") TSLog.e(TAG, "Failed to create downloads folder: $e")
downloads = File(this.filesDir, "Taildrop") downloads = File(this.filesDir, "Taildrop")
try { try {
if (!downloads.exists()) { if (!downloads.exists()) {
downloads.mkdirs() downloads.mkdirs()
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to create Taildrop folder: $e") TSLog.e(TAG, "Failed to create Taildrop folder: $e")
downloads = File("") downloads = File("")
} }
} }
@ -453,17 +282,256 @@ class App : Application(), libtailscale.AppContext {
return downloads return downloads
} }
@Throws(IOException::class, GeneralSecurityException::class) @Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyBooleanValue(key: String): Boolean { override fun getSyspolicyBooleanValue(key: String): Boolean {
return getSyspolicyStringValue(key) == "true" return getSyspolicyStringValue(key) == "true"
} }
@Throws(IOException::class, GeneralSecurityException::class) @Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyStringValue(key: String): String { override fun getSyspolicyStringValue(key: String): String {
return MDMSettings.allSettingsByKey[key]?.flow?.value?.toString() val setting = MDMSettings.allSettingsByKey[key]?.flow?.value
?: run { if (setting?.isSet != true) {
Log.d("MDM", "$key is not defined on Android. Returning empty.") throw MDMSettings.NoSuchKeyException()
"" }
return setting.value?.toString() ?: ""
}
@Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyStringArrayJSONValue(key: String): String {
val setting = MDMSettings.allSettingsByKey[key]?.flow?.value
if (setting?.isSet != true) {
throw MDMSettings.NoSuchKeyException()
}
try {
val list = setting.value as? List<*>
return Json.encodeToString(list)
} catch (e: Exception) {
TSLog.d("MDM", "$key value cannot be serialized to JSON. Throwing NoSuchKeyException.")
throw MDMSettings.NoSuchKeyException()
}
}
fun notifyPolicyChanged() {
app.notifyPolicyChanged()
}
}
/**
* UninitializedApp contains all of the methods of App that can be used without having to initialize
* the Go backend. This is useful when you want to access functions on the App without creating side
* effects from starting the Go backend (such as launching the VPN).
*/
open class UninitializedApp : Application() {
companion object {
const val TAG = "UninitializedApp"
const val STATUS_NOTIFICATION_ID = 1
const val STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID = 2
const val STATUS_CHANNEL_ID = "tailscale-status"
// Key for shared preference that tracks whether or not we're able to start
// the VPN (i.e. we're logged in and machine is authorized).
private const val ABLE_TO_START_VPN_KEY = "ableToStartVPN"
private const val DISALLOWED_APPS_KEY = "disallowedApps"
// File for shared preferences that are not encrypted.
private const val UNENCRYPTED_PREFERENCES = "unencrypted"
private lateinit var appInstance: UninitializedApp
lateinit var notificationManager: NotificationManagerCompat
lateinit var vpnViewModel: VpnViewModel
@JvmStatic
fun get(): UninitializedApp {
return appInstance
}
}
protected fun setUnprotectedInstance(instance: UninitializedApp) {
appInstance = instance
}
protected fun setAbleToStartVPN(rdy: Boolean) {
getUnencryptedPrefs().edit().putBoolean(ABLE_TO_START_VPN_KEY, rdy).apply()
}
/** This function can be called without initializing the App. */
fun isAbleToStartVPN(): Boolean {
return getUnencryptedPrefs().getBoolean(ABLE_TO_START_VPN_KEY, false)
}
private fun getUnencryptedPrefs(): SharedPreferences {
return getSharedPreferences(UNENCRYPTED_PREFERENCES, MODE_PRIVATE)
}
fun startVPN() {
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_START_VPN }
try {
startForegroundService(intent)
} catch (foregroundServiceStartException: IllegalStateException) {
TSLog.e(
TAG,
"startVPN hit ForegroundServiceStartNotAllowedException in startForegroundService(): $foregroundServiceStartException")
} catch (securityException: SecurityException) {
TSLog.e(TAG, "startVPN hit SecurityException in startForegroundService(): $securityException")
} catch (e: Exception) {
TSLog.e(TAG, "startVPN hit exception in startForegroundService(): $e")
}
}
fun stopVPN() {
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_STOP_VPN }
try {
startService(intent)
} catch (illegalStateException: IllegalStateException) {
TSLog.e(TAG, "stopVPN hit IllegalStateException in startService(): $illegalStateException")
} catch (e: Exception) {
TSLog.e(TAG, "stopVPN hit exception in startService(): $e")
}
}
// Calls stopVPN() followed by startVPN() to restart the VPN.
fun restartVPN() {
stopVPN()
startVPN()
}
fun createNotificationChannel(id: String, name: String, description: String, importance: Int) {
val channel = NotificationChannel(id, name, importance)
channel.description = description
notificationManager = NotificationManagerCompat.from(this)
notificationManager.createNotificationChannel(channel)
}
fun notifyStatus(vpnRunning: Boolean) {
notifyStatus(buildStatusNotification(vpnRunning))
}
fun notifyStatus(notification: Notification) {
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED) {
// TODO: Consider calling
// ActivityCompat#requestPermissions
// here to request the missing permissions, and then overriding
// public void onRequestPermissionsResult(int requestCode, String[] permissions,
// int[] grantResults)
// to handle the case where the user grants the permission. See the documentation
// for ActivityCompat#requestPermissions for more details.
return
} }
notificationManager.notify(STATUS_NOTIFICATION_ID, notification)
} }
fun buildStatusNotification(vpnRunning: Boolean): Notification {
val message = getString(if (vpnRunning) R.string.connected else R.string.not_connected)
val icon = if (vpnRunning) R.drawable.ic_notification else R.drawable.ic_notification_disabled
val action =
if (vpnRunning) IPNReceiver.INTENT_DISCONNECT_VPN else IPNReceiver.INTENT_CONNECT_VPN
val actionLabel = getString(if (vpnRunning) R.string.disconnect else R.string.connect)
val buttonIntent = Intent(this, IPNReceiver::class.java).apply { this.action = action }
val pendingButtonIntent: PendingIntent =
PendingIntent.getBroadcast(
this,
0,
buttonIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val intent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent: PendingIntent =
PendingIntent.getActivity(
this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
return NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
.setSmallIcon(icon)
.setContentTitle("Tailscale")
.setContentText(message)
.setAutoCancel(!vpnRunning)
.setOnlyAlertOnce(!vpnRunning)
.setOngoing(vpnRunning)
.setSilent(true)
.setOngoing(false)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.addAction(NotificationCompat.Action.Builder(0, actionLabel, pendingButtonIntent).build())
.setContentIntent(pendingIntent)
.build()
}
fun addUserDisallowedPackageName(packageName: String) {
if (packageName.isEmpty()) {
TSLog.e(TAG, "addUserDisallowedPackageName called with empty packageName")
return
}
getUnencryptedPrefs()
.edit()
.putStringSet(
DISALLOWED_APPS_KEY, disallowedPackageNames().toMutableSet().union(setOf(packageName)))
.apply()
this.restartVPN()
}
fun removeUserDisallowedPackageName(packageName: String) {
if (packageName.isEmpty()) {
TSLog.e(TAG, "removeUserDisallowedPackageName called with empty packageName")
return
}
getUnencryptedPrefs()
.edit()
.putStringSet(
DISALLOWED_APPS_KEY,
disallowedPackageNames().toMutableSet().subtract(setOf(packageName)))
.apply()
this.restartVPN()
}
fun disallowedPackageNames(): List<String> {
val mdmDisallowed =
MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
if (mdmDisallowed.isNotEmpty()) {
TSLog.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed")
return builtInDisallowedPackageNames + mdmDisallowed
}
val userDisallowed =
getUnencryptedPrefs().getStringSet(DISALLOWED_APPS_KEY, emptySet())?.toList() ?: emptyList()
return builtInDisallowedPackageNames + userDisallowed
}
fun getAppScopedViewModel(): VpnViewModel {
return vpnViewModel
}
val builtInDisallowedPackageNames: List<String> =
listOf(
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
"com.google.android.apps.messaging",
// Android Auto https://github.com/tailscale/tailscale/issues/3828
"com.google.android.projection.gearhead",
// GoPro https://github.com/tailscale/tailscale/issues/2554
"com.gopro.smarty",
// Sonos https://github.com/tailscale/tailscale/issues/2548
"com.sonos.acr",
"com.sonos.acr2",
// Google Chromecast https://github.com/tailscale/tailscale/issues/3636
"com.google.android.apps.chromecast.app",
// Voicemail https://github.com/tailscale/tailscale/issues/13199
"com.samsung.attvvm",
"com.att.mobile.android.vvm",
"com.tmobile.vvm.application",
"com.metropcs.service.vvm",
"com.mizmowireless.vvm",
"com.vna.service.vvm",
"com.dish.vvm",
"com.comcast.modesto.vvm.client",
)
} }

@ -0,0 +1,35 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.content.Context
import android.os.Build
import android.util.Log
object AppSourceChecker {
const val TAG = "AppSourceChecker"
fun getInstallSource(context: Context): String {
val packageManager = context.packageManager
val packageName = context.packageName
Log.d(TAG, "Package name: $packageName")
val installerPackageName =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
packageManager.getInstallSourceInfo(packageName).installingPackageName
} else {
@Suppress("deprecation") packageManager.getInstallerPackageName(packageName)
}
Log.d(TAG, "Installer package name: $installerPackageName")
return when (installerPackageName) {
"com.android.vending" -> "googleplay"
"org.fdroid.fdroid" -> "fdroid"
"com.amazon.venezia" -> "amazon"
null -> "unknown"
else -> "unknown($installerPackageName)"
}
}
}

@ -6,17 +6,23 @@ package com.tailscale.ipn;
import android.content.BroadcastReceiver; import android.content.BroadcastReceiver;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import androidx.work.Data;
import androidx.work.OneTimeWorkRequest; import androidx.work.OneTimeWorkRequest;
import androidx.work.WorkManager; import androidx.work.WorkManager;
import java.util.Objects; import java.util.Objects;
/**
* IPNReceiver allows external applications to start the VPN.
*/
public class IPNReceiver extends BroadcastReceiver { public class IPNReceiver extends BroadcastReceiver {
public static final String INTENT_CONNECT_VPN = "com.tailscale.ipn.CONNECT_VPN"; public static final String INTENT_CONNECT_VPN = "com.tailscale.ipn.CONNECT_VPN";
public static final String INTENT_DISCONNECT_VPN = "com.tailscale.ipn.DISCONNECT_VPN"; public static final String INTENT_DISCONNECT_VPN = "com.tailscale.ipn.DISCONNECT_VPN";
private static final String INTENT_USE_EXIT_NODE = "com.tailscale.ipn.USE_EXIT_NODE";
@Override @Override
public void onReceive(Context context, Intent intent) { public void onReceive(Context context, Intent intent) {
WorkManager workManager = WorkManager.getInstance(context); WorkManager workManager = WorkManager.getInstance(context);
@ -27,5 +33,13 @@ public class IPNReceiver extends BroadcastReceiver {
} else if (Objects.equals(intent.getAction(), INTENT_DISCONNECT_VPN)) { } else if (Objects.equals(intent.getAction(), INTENT_DISCONNECT_VPN)) {
workManager.enqueue(new OneTimeWorkRequest.Builder(StopVPNWorker.class).build()); workManager.enqueue(new OneTimeWorkRequest.Builder(StopVPNWorker.class).build());
} }
else if (Objects.equals(intent.getAction(), INTENT_USE_EXIT_NODE)) {
String exitNode = intent.getStringExtra("exitNode");
boolean allowLanAccess = intent.getBooleanExtra("allowLanAccess", false);
Data.Builder workData = new Data.Builder();
workData.putString(UseExitNodeWorker.EXIT_NODE_NAME, exitNode);
workData.putBoolean(UseExitNodeWorker.ALLOW_LAN_ACCESS, allowLanAccess);
workManager.enqueue(new OneTimeWorkRequest.Builder(UseExitNodeWorker.class).setInputData(workData.build()).build());
}
} }
} }

@ -8,47 +8,73 @@ import android.content.pm.PackageManager
import android.net.VpnService import android.net.VpnService
import android.os.Build import android.os.Build
import android.system.OsConstants import android.system.OsConstants
import androidx.core.app.NotificationCompat import com.tailscale.ipn.mdm.MDMSettings
import androidx.core.app.NotificationManagerCompat import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.util.TSLog
import libtailscale.Libtailscale import libtailscale.Libtailscale
import java.util.UUID import java.util.UUID
open class IPNService : VpnService(), libtailscale.IPNService { open class IPNService : VpnService(), libtailscale.IPNService {
private val TAG = "IPNService"
private val randomID: String = UUID.randomUUID().toString() private val randomID: String = UUID.randomUUID().toString()
private lateinit var app: App
override fun id(): String { override fun id(): String {
return randomID return randomID
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun updateVpnStatus(status: Boolean) {
if (intent != null && ACTION_STOP_VPN == intent.action) { app.getAppScopedViewModel().setVpnActive(status)
(applicationContext as App).autoConnect = false }
override fun onCreate() {
super.onCreate()
// grab app to make sure it initializes
app = App.get()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
when (intent?.action) {
ACTION_STOP_VPN -> {
app.setWantRunning(false)
close() close()
return START_NOT_STICKY START_NOT_STICKY
} }
val app = applicationContext as App ACTION_START_VPN -> {
if (intent != null && "android.net.VpnService" == intent.action) { showForegroundNotification()
// Start VPN and connect to it due to Always-on VPN app.setWantRunning(true)
val i = Intent(IPNReceiver.INTENT_CONNECT_VPN)
i.setPackage(packageName)
i.setClass(applicationContext, IPNReceiver::class.java)
sendBroadcast(i)
Libtailscale.requestVPN(this) Libtailscale.requestVPN(this)
START_STICKY
}
"android.net.VpnService" -> {
// This means we were started by Android due to Always On VPN.
// We show a non-foreground notification because we weren't
// started as a foreground service.
app.notifyStatus(true)
app.setWantRunning(true) app.setWantRunning(true)
return START_STICKY Libtailscale.requestVPN(this)
START_STICKY
} }
else -> {
// This means that we were restarted after the service was killed
// (potentially due to OOM).
if (UninitializedApp.get().isAbleToStartVPN()) {
showForegroundNotification()
App.get()
Libtailscale.requestVPN(this) Libtailscale.requestVPN(this)
if (app.vpnReady && app.autoConnect) { START_STICKY
app.setWantRunning(true) } else {
START_NOT_STICKY
}
} }
return START_STICKY
} }
override public fun close() { override fun close() {
stopForeground(true) app.setWantRunning(false) { updateVpnStatus(false) }
Notifier.setState(Ipn.State.Stopping)
stopForeground(STOP_FOREGROUND_REMOVE)
Libtailscale.serviceDisconnect(this) Libtailscale.serviceDisconnect(this)
val app = applicationContext as App
app.setWantRunning(false)
} }
override fun onDestroy() { override fun onDestroy() {
@ -61,6 +87,20 @@ open class IPNService : VpnService(), libtailscale.IPNService {
super.onRevoke() super.onRevoke()
} }
private fun setVpnPrepared(isPrepared: Boolean) {
app.getAppScopedViewModel().setVpnPrepared(isPrepared)
}
private fun showForegroundNotification() {
try {
startForeground(
UninitializedApp.STATUS_NOTIFICATION_ID,
UninitializedApp.get().buildStatusNotification(true))
} catch (e: Exception) {
TSLog.e(TAG, "Failed to start foreground service: $e")
}
}
private fun configIntent(): PendingIntent { private fun configIntent(): PendingIntent {
return PendingIntent.getActivity( return PendingIntent.getActivity(
this, this,
@ -72,7 +112,9 @@ open class IPNService : VpnService(), libtailscale.IPNService {
private fun disallowApp(b: Builder, name: String) { private fun disallowApp(b: Builder, name: String) {
try { try {
b.addDisallowedApplication(name) b.addDisallowedApplication(name)
} catch (e: PackageManager.NameNotFoundException) {} } catch (e: PackageManager.NameNotFoundException) {
TSLog.d(TAG, "Failed to add disallowed application: $e")
}
} }
override fun newBuilder(): VPNServiceBuilder { override fun newBuilder(): VPNServiceBuilder {
@ -81,59 +123,36 @@ open class IPNService : VpnService(), libtailscale.IPNService {
.setConfigureIntent(configIntent()) .setConfigureIntent(configIntent())
.allowFamily(OsConstants.AF_INET) .allowFamily(OsConstants.AF_INET)
.allowFamily(OsConstants.AF_INET6) .allowFamily(OsConstants.AF_INET6)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
b.setMetered(false) // Inherit the metered status from the underlying networks. b.setMetered(false) // Inherit the metered status from the underlying networks.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) }
b.setUnderlyingNetworks(null) // Use all available networks. b.setUnderlyingNetworks(null) // Use all available networks.
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322 val includedPackages: List<String> =
disallowApp(b, "com.google.android.apps.messaging") MDMSettings.includedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
if (includedPackages.isNotEmpty()) {
// Stadia https://github.com/tailscale/tailscale/issues/3460 // If an admin defined a list of packages that are exclusively allowed to be used via
disallowApp(b, "com.google.stadia.android") // Tailscale,
// then only allow those apps.
// Android Auto https://github.com/tailscale/tailscale/issues/3828 for (packageName in includedPackages) {
disallowApp(b, "com.google.android.projection.gearhead") TSLog.d(TAG, "Including app: $packageName")
b.addAllowedApplication(packageName)
// GoPro https://github.com/tailscale/tailscale/issues/2554 }
disallowApp(b, "com.gopro.smarty") } else {
// Otherwise, prevent certain apps from getting their traffic + DNS routed via Tailscale:
// Sonos https://github.com/tailscale/tailscale/issues/2548 // - any app that the user manually disallowed in the GUI
disallowApp(b, "com.sonos.acr") // - any app that we disallowed via hard-coding
disallowApp(b, "com.sonos.acr2") for (disallowedPackageName in UninitializedApp.get().disallowedPackageNames()) {
TSLog.d(TAG, "Disallowing app: $disallowedPackageName")
// Google Chromecast https://github.com/tailscale/tailscale/issues/3636 disallowApp(b, disallowedPackageName)
disallowApp(b, "com.google.android.apps.chromecast.app")
return VPNServiceBuilder(b)
} }
fun notify(title: String?, message: String?) {
val builder: NotificationCompat.Builder =
NotificationCompat.Builder(this, App.NOTIFY_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(message)
.setContentIntent(configIntent())
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
val nm: NotificationManagerCompat = NotificationManagerCompat.from(this)
nm.notify(App.NOTIFY_NOTIFICATION_ID, builder.build())
} }
fun updateStatusNotification(title: String?, message: String?) { return VPNServiceBuilder(b)
val builder: NotificationCompat.Builder =
NotificationCompat.Builder(this, App.STATUS_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(message)
.setContentIntent(configIntent())
.setPriority(NotificationCompat.PRIORITY_LOW)
startForeground(App.STATUS_NOTIFICATION_ID, builder.build())
} }
companion object { companion object {
const val ACTION_REQUEST_VPN = "com.tailscale.ipn.REQUEST_VPN" const val ACTION_START_VPN = "com.tailscale.ipn.START_VPN"
const val ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN" const val ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN"
} }
} }

@ -5,17 +5,17 @@ package com.tailscale.ipn
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.app.AlertDialog
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.RestrictionsManager import android.content.RestrictionsManager
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE import android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE
import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK
import android.net.Uri import android.net.ConnectivityManager
import android.net.VpnService import android.net.NetworkCapabilities
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
@ -32,6 +32,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavType import androidx.navigation.NavType
@ -39,7 +40,6 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.tailscale.ipn.Peer.RequestCodes
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
@ -51,22 +51,33 @@ import com.tailscale.ipn.ui.view.AboutView
import com.tailscale.ipn.ui.view.BugReportView import com.tailscale.ipn.ui.view.BugReportView
import com.tailscale.ipn.ui.view.DNSSettingsView import com.tailscale.ipn.ui.view.DNSSettingsView
import com.tailscale.ipn.ui.view.ExitNodePicker import com.tailscale.ipn.ui.view.ExitNodePicker
import com.tailscale.ipn.ui.view.HealthView
import com.tailscale.ipn.ui.view.IntroView import com.tailscale.ipn.ui.view.IntroView
import com.tailscale.ipn.ui.view.LoginQRView import com.tailscale.ipn.ui.view.LoginQRView
import com.tailscale.ipn.ui.view.LoginWithAuthKeyView
import com.tailscale.ipn.ui.view.LoginWithCustomControlURLView
import com.tailscale.ipn.ui.view.MDMSettingsDebugView import com.tailscale.ipn.ui.view.MDMSettingsDebugView
import com.tailscale.ipn.ui.view.MainView import com.tailscale.ipn.ui.view.MainView
import com.tailscale.ipn.ui.view.MainViewNavigation import com.tailscale.ipn.ui.view.MainViewNavigation
import com.tailscale.ipn.ui.view.ManagedByView import com.tailscale.ipn.ui.view.ManagedByView
import com.tailscale.ipn.ui.view.MullvadExitNodePicker import com.tailscale.ipn.ui.view.MullvadExitNodePicker
import com.tailscale.ipn.ui.view.MullvadExitNodePickerList import com.tailscale.ipn.ui.view.MullvadExitNodePickerList
import com.tailscale.ipn.ui.view.MullvadInfoView
import com.tailscale.ipn.ui.view.PeerDetails import com.tailscale.ipn.ui.view.PeerDetails
import com.tailscale.ipn.ui.view.PermissionsView import com.tailscale.ipn.ui.view.PermissionsView
import com.tailscale.ipn.ui.view.RunExitNodeView import com.tailscale.ipn.ui.view.RunExitNodeView
import com.tailscale.ipn.ui.view.SettingsView import com.tailscale.ipn.ui.view.SettingsView
import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView
import com.tailscale.ipn.ui.view.TailnetLockSetupView import com.tailscale.ipn.ui.view.TailnetLockSetupView
import com.tailscale.ipn.ui.view.UserSwitcherNav
import com.tailscale.ipn.ui.view.UserSwitcherView import com.tailscale.ipn.ui.view.UserSwitcherView
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.MainViewModelFactory
import com.tailscale.ipn.ui.viewModel.PingViewModel
import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -74,8 +85,14 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var requestVpnPermission: ActivityResultLauncher<Unit>
private lateinit var navController: NavHostController private lateinit var navController: NavHostController
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
private val viewModel: MainViewModel by lazy {
val app = App.get()
vpnViewModel = app.getAppScopedViewModel()
ViewModelProvider(this, MainViewModelFactory(vpnViewModel)).get(MainViewModel::class.java)
}
private lateinit var vpnViewModel: VpnViewModel
companion object { companion object {
private const val TAG = "Main Activity" private const val TAG = "Main Activity"
@ -96,6 +113,10 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// grab app to make sure it initializes
App.get()
vpnViewModel = ViewModelProvider(App.get()).get(VpnViewModel::class.java)
// (jonathan) TODO: Force the app to be portrait on small screens until we have // (jonathan) TODO: Force the app to be portrait on small screens until we have
// proper landscape layout support // proper landscape layout support
if (!isLandscapeCapable()) { if (!isLandscapeCapable()) {
@ -104,6 +125,24 @@ class MainActivity : ComponentActivity() {
installSplashScreen() installSplashScreen()
vpnPermissionLauncher =
registerForActivityResult(VpnPermissionContract()) { granted ->
if (granted) {
TSLog.d("VpnPermission", "VPN permission granted")
vpnViewModel.setVpnPrepared(true)
App.get().startVPN()
} else {
if (isAnotherVpnActive(this)) {
TSLog.d("VpnPermission", "Another VPN is likely active")
showOtherVPNConflictDialog()
} else {
TSLog.d("VpnPermission", "Permission was denied by the user")
vpnViewModel.setVpnPrepared(false)
}
}
}
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher)
setContent { setContent {
AppTheme { AppTheme {
navController = rememberNavController() navController = rememberNavController()
@ -135,13 +174,14 @@ class MainActivity : ComponentActivity() {
navController.navigate("peerDetails/${it.StableID}") navController.navigate("peerDetails/${it.StableID}")
}, },
onNavigateToExitNodes = { navController.navigate("exitNodes") }, onNavigateToExitNodes = { navController.navigate("exitNodes") },
) onNavigateToHealth = { navController.navigate("health") })
val settingsNav = val settingsNav =
SettingsNav( SettingsNav(
onNavigateToBugReport = { navController.navigate("bugReport") }, onNavigateToBugReport = { navController.navigate("bugReport") },
onNavigateToAbout = { navController.navigate("about") }, onNavigateToAbout = { navController.navigate("about") },
onNavigateToDNSSettings = { navController.navigate("dnsSettings") }, onNavigateToDNSSettings = { navController.navigate("dnsSettings") },
onNavigateToSplitTunneling = { navController.navigate("splitTunneling") },
onNavigateToTailnetLock = { navController.navigate("tailnetLock") }, onNavigateToTailnetLock = { navController.navigate("tailnetLock") },
onNavigateToMDMSettings = { navController.navigate("mdmSettings") }, onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") }, onNavigateToManagedBy = { navController.navigate("managedBy") },
@ -157,16 +197,28 @@ class MainActivity : ComponentActivity() {
}, },
onNavigateBackToExitNodes = backTo("exitNodes"), onNavigateBackToExitNodes = backTo("exitNodes"),
onNavigateToMullvad = { navController.navigate("mullvad") }, onNavigateToMullvad = { navController.navigate("mullvad") },
onNavigateToMullvadInfo = { navController.navigate("mullvad_info") },
onNavigateBackToMullvad = backTo("mullvad"), onNavigateBackToMullvad = backTo("mullvad"),
onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") }, onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") },
onNavigateToRunAsExitNode = { navController.navigate("runExitNode") }) onNavigateToRunAsExitNode = { navController.navigate("runExitNode") })
val userSwitcherNav =
UserSwitcherNav(
backToSettings = backTo("settings"),
onNavigateHome = backTo("main"),
onNavigateCustomControl = {
navController.navigate("loginWithCustomControl")
},
onNavigateToAuthKey = { navController.navigate("loginWithAuthKey") })
composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) { composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) {
MainView(navigation = mainViewNav) MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel)
} }
composable("settings") { SettingsView(settingsNav) } composable("settings") { SettingsView(settingsNav) }
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) } composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
composable("health") { HealthView(backTo("main")) }
composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) } composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) }
composable("mullvad_info") { MullvadInfoView(exitNodePickerNav) }
composable( composable(
"mullvad/{countryCode}", "mullvad/{countryCode}",
arguments = arguments =
@ -178,23 +230,32 @@ class MainActivity : ComponentActivity() {
composable( composable(
"peerDetails/{nodeId}", "peerDetails/{nodeId}",
arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) { arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) {
PeerDetails(backTo("main"), it.arguments?.getString("nodeId") ?: "") PeerDetails(
backTo("main"),
it.arguments?.getString("nodeId") ?: "",
PingViewModel())
} }
composable("bugReport") { BugReportView(backTo("settings")) } composable("bugReport") { BugReportView(backTo("settings")) }
composable("dnsSettings") { DNSSettingsView(backTo("settings")) } composable("dnsSettings") { DNSSettingsView(backTo("settings")) }
composable("splitTunneling") { SplitTunnelAppPickerView(backTo("settings")) }
composable("tailnetLock") { TailnetLockSetupView(backTo("settings")) } composable("tailnetLock") { TailnetLockSetupView(backTo("settings")) }
composable("about") { AboutView(backTo("settings")) } composable("about") { AboutView(backTo("settings")) }
composable("mdmSettings") { MDMSettingsDebugView(backTo("settings")) } composable("mdmSettings") { MDMSettingsDebugView(backTo("settings")) }
composable("managedBy") { ManagedByView(backTo("settings")) } composable("managedBy") { ManagedByView(backTo("settings")) }
composable("userSwitcher") { composable("userSwitcher") { UserSwitcherView(userSwitcherNav) }
UserSwitcherView(backTo("settings"), backTo("main"))
}
composable("permissions") { composable("permissions") {
PermissionsView(backTo("settings"), ::openApplicationSettings) PermissionsView(backTo("settings"), ::openApplicationSettings)
} }
composable("intro", exitTransition = { fadeOut(animationSpec = tween(150)) }) { composable("intro", exitTransition = { fadeOut(animationSpec = tween(150)) }) {
IntroView(backTo("main")) IntroView(backTo("main"))
} }
composable("loginWithAuthKey") {
LoginWithAuthKeyView(onNavigateHome = backTo("main"), backTo("userSwitcher"))
}
composable("loginWithCustomControl") {
LoginWithCustomControlURLView(
onNavigateHome = backTo("main"), backTo("userSwitcher"))
}
} }
// Show the intro screen one time // Show the intro screen one time
@ -212,12 +273,6 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
lifecycleScope.launch {
Notifier.readyToPrepareVPN.collect { isReady ->
if (isReady)
App.getApplication().prepareVPN(this@MainActivity, RequestCodes.requestPrepareVPN)
}
}
} }
init { init {
@ -238,23 +293,53 @@ class MainActivity : ComponentActivity() {
lifecycleScope.launch { Notifier.loginFinished.collect { _ -> loginQRCode.set(null) } } lifecycleScope.launch { Notifier.loginFinished.collect { _ -> loginQRCode.set(null) } }
} }
private fun showOtherVPNConflictDialog() {
AlertDialog.Builder(this)
.setTitle(R.string.vpn_permission_denied)
.setMessage(R.string.multiple_vpn_explainer)
.setPositiveButton(R.string.go_to_settings) { _, _ ->
// Intent to open the VPN settings
val intent = Intent(Settings.ACTION_VPN_SETTINGS)
startActivity(intent)
}
.setNegativeButton(R.string.cancel, null)
.show()
}
fun isAnotherVpnActive(context: Context): Boolean {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetwork = connectivityManager.activeNetwork
if (activeNetwork != null) {
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
if (networkCapabilities != null &&
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
return true
}
}
return false
}
// Returns true if we should render a QR code instead of launching a browser // Returns true if we should render a QR code instead of launching a browser
// for login requests // for login requests
private fun useQRCodeLogin(): Boolean { private fun useQRCodeLogin(): Boolean {
return AndroidTVUtil.isAndroidTV() return AndroidTVUtil.isAndroidTV()
} }
override fun onNewIntent(intent: Intent?) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
if (intent?.getBooleanExtra(START_AT_ROOT, false) == true) { if (intent.getBooleanExtra(START_AT_ROOT, false)) {
if (this::navController.isInitialized) {
navController.popBackStack(route = "main", inclusive = false) navController.popBackStack(route = "main", inclusive = false)
} }
} }
}
private fun login(urlString: String) { private fun login(urlString: String) {
// Launch coroutine to listen for state changes. When the user completes login, relaunch // Launch coroutine to listen for state changes. When the user completes login, relaunch
// MainActivity to bring the app back to focus. // MainActivity to bring the app back to focus.
App.getApplication().applicationScope.launch { App.get().applicationScope.launch {
try { try {
Notifier.state.collect { state -> Notifier.state.collect { state ->
if (state > Ipn.State.NeedsMachineAuth) { if (state > Ipn.State.NeedsMachineAuth) {
@ -272,7 +357,7 @@ class MainActivity : ComponentActivity() {
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Login: failed to start MainActivity: $e") TSLog.e(TAG, "Login: failed to start MainActivity: $e")
} }
} }
@ -286,7 +371,7 @@ class MainActivity : ComponentActivity() {
val fallbackIntent = Intent(Intent.ACTION_VIEW, url) val fallbackIntent = Intent(Intent.ACTION_VIEW, url)
startActivity(fallbackIntent) startActivity(fallbackIntent)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Login: failed to open browser: $e") TSLog.e(TAG, "Login: failed to open browser: $e")
} }
} }
} }
@ -295,45 +380,25 @@ class MainActivity : ComponentActivity() {
super.onResume() super.onResume()
val restrictionsManager = val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
MDMSettings.update(App.getApplication(), restrictionsManager)
}
} }
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
// (jonathan) TODO: Requesting VPN permissions onStart is a bit aggressive. This should
// be done when the user initiall starts the VPN
requestVpnPermission()
} }
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
val restrictionsManager = val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
MDMSettings.update(App.getApplication(), restrictionsManager)
}
}
private fun requestVpnPermission() {
val vpnIntent = VpnService.prepare(this)
if (vpnIntent != null) {
val contract = VpnPermissionContract()
requestVpnPermission =
registerForActivityResult(contract) { granted ->
Log.i("VPN", "VPN permission ${if (granted) "granted" else "denied"}")
}
requestVpnPermission.launch(Unit)
}
} }
private fun openApplicationSettings() { private fun openApplicationSettings() {
val intent = val intent =
Intent( Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
Settings.ACTION_APPLICATION_DETAILS_SETTINGS, putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
Uri.fromParts("package", packageName, null)) }
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent) startActivity(intent)
} }
@ -350,9 +415,9 @@ class MainActivity : ComponentActivity() {
} }
} }
class VpnPermissionContract : ActivityResultContract<Unit, Boolean>() { class VpnPermissionContract : ActivityResultContract<Intent, Boolean>() {
override fun createIntent(context: Context, input: Unit): Intent { override fun createIntent(context: Context, input: Intent): Intent {
return VpnService.prepare(context) ?: Intent() return input
} }
override fun parseResult(resultCode: Int, intent: Intent?): Boolean { override fun parseResult(resultCode: Int, intent: Intent?): Boolean {

@ -1,35 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.app.Activity;
import java.lang.reflect.Method;
public class MaybeGoogle {
static boolean isGoogle() {
return getGoogle() != null;
}
static String getIdTokenForActivity(Activity act) {
Class<?> google = getGoogle();
if (google == null) {
return "";
}
try {
Method method = google.getMethod("getIdTokenForActivity", Activity.class);
return (String) method.invoke(null, act);
} catch (Exception e) {
return "";
}
}
private static Class getGoogle() {
try {
return Class.forName("com.tailscale.ipn.Google");
} catch (ClassNotFoundException e) {
return null;
}
}
}

@ -0,0 +1,168 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.util.Log
import com.tailscale.ipn.util.TSLog
import libtailscale.Libtailscale
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
object NetworkChangeCallback {
private const val TAG = "NetworkChangeCallback"
private data class NetworkInfo(var caps: NetworkCapabilities, var linkProps: LinkProperties)
private val lock = ReentrantLock()
private val activeNetworks = mutableMapOf<Network, NetworkInfo>() // keyed by Network
// monitorDnsChanges sets up a network callback to monitor changes to the
// system's network state and update the DNS configuration when interfaces
// become available or properties of those interfaces change.
fun monitorDnsChanges(connectivityManager: ConnectivityManager, dns: DnsConfig) {
val networkConnectivityRequest =
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
.build()
// Use registerNetworkCallback to listen for updates from all networks, and
// then update DNS configs for the best network when LinkProperties are changed.
// Per
// https://developer.android.com/reference/android/net/ConnectivityManager.NetworkCallback#onAvailable(android.net.Network), this happens after all other updates.
//
// Note that we can't use registerDefaultNetworkCallback because the
// default network used by Tailscale will always show up with capability
// NOT_VPN=false, and we must filter out NOT_VPN networks to avoid routing
// loops.
connectivityManager.registerNetworkCallback(
networkConnectivityRequest,
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
super.onAvailable(network)
TSLog.d(TAG, "onAvailable: network ${network}")
lock.withLock {
activeNetworks[network] = NetworkInfo(NetworkCapabilities(), LinkProperties())
}
}
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
super.onCapabilitiesChanged(network, capabilities)
lock.withLock { activeNetworks[network]?.caps = capabilities }
}
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
super.onLinkPropertiesChanged(network, linkProperties)
lock.withLock {
activeNetworks[network]?.linkProps = linkProperties
maybeUpdateDNSConfig("onLinkPropertiesChanged", dns)
}
}
override fun onLost(network: Network) {
super.onLost(network)
TSLog.d(TAG, "onLost: network ${network}")
lock.withLock {
activeNetworks.remove(network)
maybeUpdateDNSConfig("onLost", dns)
}
}
})
}
// pickNonMetered returns the first non-metered network in the list of
// networks, or the first network if none are non-metered.
private fun pickNonMetered(networks: Map<Network, NetworkInfo>): Network? {
for ((network, info) in networks) {
if (info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)) {
return network
}
}
return networks.keys.firstOrNull()
}
// pickDefaultNetwork returns a non-VPN network to use as the 'default'
// network; one that is used as a gateway to the internet and from which we
// obtain our DNS servers.
private fun pickDefaultNetwork(): Network? {
// Filter the list of all networks to those that have the INTERNET
// capability, are not VPNs, and have a non-zero number of DNS servers
// available.
val networks =
activeNetworks.filter { (_, info) ->
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) &&
info.linkProps.dnsServers.isNotEmpty() == true
}
// If we have one; just return it; otherwise, prefer networks that are also
// not metered (i.e. cell modems).
val nonMeteredNetwork = pickNonMetered(networks)
if (nonMeteredNetwork != null) {
return nonMeteredNetwork
}
// Okay, less good; just return the first network that has the INTERNET and
// NOT_VPN capabilities; even though this interface doesn't have any DNS
// servers set, we'll use our DNS fallback servers to make queries. It's
// strictly better to return an interface + use the DNS fallback servers
// than to return nothing and not be able to route traffic.
for ((network, info) in activeNetworks) {
if (info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) {
Log.w(
TAG,
"no networks available that also have DNS servers set; falling back to first network ${network}")
return network
}
}
// Otherwise, return nothing; we don't want to return a VPN network since
// it could result in a routing loop, and a non-INTERNET network isn't
// helpful.
Log.w(TAG, "no networks available to pick a default network")
return null
}
// maybeUpdateDNSConfig will maybe update our DNS configuration based on the
// current set of active Networks.
private fun maybeUpdateDNSConfig(why: String, dns: DnsConfig) {
val defaultNetwork = pickDefaultNetwork()
if (defaultNetwork == null) {
TSLog.d(TAG, "${why}: no default network available; not updating DNS config")
return
}
val info = activeNetworks[defaultNetwork]
if (info == null) {
Log.w(
TAG,
"${why}: [unexpected] no info available for default network; not updating DNS config")
return
}
val sb = StringBuilder()
for (ip in info.linkProps.dnsServers) {
sb.append(ip.hostAddress).append(" ")
}
val searchDomains: String? = info.linkProps.domains
if (searchDomains != null) {
sb.append("\n")
sb.append(searchDomains)
}
if (dns.updateDNSFromNetwork(sb.toString())) {
TSLog.d(
TAG,
"${why}: updated DNS config for network ${defaultNetwork} (${info.linkProps.interfaceName})")
Libtailscale.onDNSConfigChanged(info.linkProps.interfaceName)
}
}
}

@ -1,28 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.app.Fragment;
import android.content.Intent;
public class Peer extends Fragment {
private static int resultOK = -1;
public class RequestCodes {
public static final int requestPrepareVPN = 1001;
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == RequestCodes.requestPrepareVPN) {
if (resultCode == resultOK) {
App.getApplication().startVPN();
} else {
App.getApplication().setWantRunning(false);
// notify VPN revoked
}
}
}
}

@ -3,46 +3,44 @@
package com.tailscale.ipn; package com.tailscale.ipn;
import android.content.Context; import android.app.PendingIntent;
import android.content.Intent; import android.content.Intent;
import android.os.Build;
import android.service.quicksettings.Tile; import android.service.quicksettings.Tile;
import android.service.quicksettings.TileService; import android.service.quicksettings.TileService;
public class QuickToggleService extends TileService { public class QuickToggleService extends TileService {
// lock protects the static fields below it. // lock protects the static fields below it.
private static final Object lock = new Object(); private static final Object lock = new Object();
// Active tracks whether the VPN is active.
private static boolean active; // isRunning tracks whether the VPN is running.
// Ready tracks whether the tailscale backend is private static boolean isRunning;
// ready to switch on/off.
private static boolean ready;
// currentTile tracks getQsTile while service is listening. // currentTile tracks getQsTile while service is listening.
private static Tile currentTile; private static Tile currentTile;
private static void updateTile() { public static void updateTile() {
var app = UninitializedApp.get();
Tile t; Tile t;
boolean act; boolean act;
synchronized (lock) { synchronized (lock) {
t = currentTile; t = currentTile;
act = active && ready; act = isRunning && app.isAbleToStartVPN();
} }
if (t == null) { if (t == null) {
return; return;
} }
t.setLabel("Tailscale");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
t.setSubtitle(act ? app.getString(R.string.connected) : app.getString(R.string.not_connected));
}
t.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE); t.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
t.updateTile(); t.updateTile();
} }
static void setReady(Context ctx, boolean rdy) { static void setVPNRunning(boolean running) {
synchronized (lock) { synchronized (lock) {
ready = rdy; isRunning = running;
}
updateTile();
}
static void setStatus(Context ctx, boolean act) {
synchronized (lock) {
active = act;
} }
updateTile(); updateTile();
} }
@ -62,29 +60,40 @@ public class QuickToggleService extends TileService {
} }
} }
@SuppressWarnings("deprecation")
@Override @Override
public void onClick() { public void onClick() {
boolean r; boolean r;
synchronized (lock) { synchronized (lock) {
r = ready; r = UninitializedApp.get().isAbleToStartVPN();
} }
if (r) { if (r) {
// Get the application to make sure it initializes
App.get();
onTileClick(); onTileClick();
} else { } else {
// Start main activity. // Start main activity.
Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName()); Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// Request code for opening activity.
startActivityAndCollapse(PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE));
} else {
// Deprecated, but still required for older versions.
startActivityAndCollapse(i); startActivityAndCollapse(i);
} }
} }
}
private void onTileClick() { private void onTileClick() {
boolean act; UninitializedApp app = UninitializedApp.get();
boolean needsToStop;
synchronized (lock) { synchronized (lock) {
act = active && ready; needsToStop = app.isAbleToStartVPN() && isRunning;
}
if (needsToStop) {
app.stopVPN();
} else {
app.startVPN();
} }
Intent i = new Intent(act ? IPNReceiver.INTENT_DISCONNECT_VPN : IPNReceiver.INTENT_CONNECT_VPN);
i.setPackage(getPackageName());
i.setClass(getApplicationContext(), com.tailscale.ipn.IPNReceiver.class);
sendBroadcast(i);
} }
} }

@ -8,21 +8,21 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.util.Log import android.webkit.MimeTypeMap
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.AppTheme import com.tailscale.ipn.ui.theme.AppTheme
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.util.universalFit import com.tailscale.ipn.ui.util.universalFit
import com.tailscale.ipn.ui.view.TaildropView import com.tailscale.ipn.ui.view.TaildropView
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlin.random.Random
// ShareActivity is the entry point for Taildrop share intents // ShareActivity is the entry point for Taildrop share intents
class ShareActivity : ComponentActivity() { class ShareActivity : ComponentActivity() {
@ -30,8 +30,8 @@ class ShareActivity : ComponentActivity() {
private val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>> = MutableStateFlow(emptyList()) private val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>> = MutableStateFlow(emptyList())
override fun onCreate(state: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(state) super.onCreate(savedInstanceState)
setContent { setContent {
AppTheme { AppTheme {
Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox
@ -45,16 +45,12 @@ class ShareActivity : ComponentActivity() {
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
Notifier.start(lifecycleScope) // Ensure our app instance is initialized
App.get()
loadFiles() loadFiles()
} }
override fun onStop() { override fun onNewIntent(intent: Intent) {
super.onStop()
Notifier.stop()
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
loadFiles() loadFiles()
@ -63,19 +59,19 @@ class ShareActivity : ComponentActivity() {
// Loads the files from the intent. // Loads the files from the intent.
fun loadFiles() { fun loadFiles() {
if (intent == null) { if (intent == null) {
Log.e(TAG, "Share failure - No intent found") TSLog.e(TAG, "Share failure - No intent found")
return return
} }
val act = intent.action val act = intent.action
val uris: List<Uri?>?
uris = val uris: List<Uri?>? =
when (act) { when (act) {
Intent.ACTION_SEND -> { Intent.ACTION_SEND -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)) listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java))
} else { } else {
@Suppress("DEPRECATION")
listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri) listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)
} }
} }
@ -83,11 +79,11 @@ class ShareActivity : ComponentActivity() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra<Uri?>(Intent.EXTRA_STREAM, Uri::class.java) intent.getParcelableArrayListExtra<Uri?>(Intent.EXTRA_STREAM, Uri::class.java)
} else { } else {
intent.getParcelableArrayListExtra<Uri?>(Intent.EXTRA_STREAM) @Suppress("DEPRECATION") intent.getParcelableArrayListExtra<Uri?>(Intent.EXTRA_STREAM)
} }
} }
else -> { else -> {
Log.e(TAG, "No extras found in intent - nothing to share") TSLog.e(TAG, "No extras found in intent - nothing to share")
null null
} }
} }
@ -98,7 +94,20 @@ class ShareActivity : ComponentActivity() {
val nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) val nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeCol = c.getColumnIndex(OpenableColumns.SIZE) val sizeCol = c.getColumnIndex(OpenableColumns.SIZE)
c.moveToFirst() c.moveToFirst()
val name = c.getString(nameCol) val name: String =
c.getString(nameCol)
?: run {
// For some reason, some content resolvers don't return a name.
// Try to build a name from a random integer plus file extension
// (if type can be determined), else just a random integer.
val rand = Random.nextLong()
contentResolver.getType(it)?.let { mimeType ->
MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let {
extension ->
"$rand.$extension"
} ?: "$rand"
} ?: "$rand"
}
val size = c.getLong(sizeCol) val size = c.getLong(sizeCol)
c.close() c.close()
val file = Ipn.OutgoingFile(Name = name, DeclaredSize = size) val file = Ipn.OutgoingFile(Name = name, DeclaredSize = size)
@ -108,7 +117,7 @@ class ShareActivity : ComponentActivity() {
} ?: emptyList() } ?: emptyList()
if (pendingFiles.isEmpty()) { if (pendingFiles.isEmpty()) {
Log.e(TAG, "Share failure - no files extracted from intent") TSLog.e(TAG, "Share failure - no files extracted from intent")
} }
requestedTransfers.set(pendingFiles) requestedTransfers.set(pendingFiles)

@ -11,56 +11,55 @@ import android.content.Intent;
import android.net.VpnService; import android.net.VpnService;
import android.os.Build; import android.os.Build;
import androidx.annotation.NonNull;
import androidx.work.Worker; import androidx.work.Worker;
import androidx.work.WorkerParameters; import androidx.work.WorkerParameters;
import com.tailscale.ipn.util.TSLog;
/**
* A worker that exists to support IPNReceiver.
*/
public final class StartVPNWorker extends Worker { public final class StartVPNWorker extends Worker {
public StartVPNWorker( public StartVPNWorker(Context appContext, WorkerParameters workerParams) {
Context appContext,
WorkerParameters workerParams) {
super(appContext, workerParams); super(appContext, workerParams);
} }
@NonNull
@Override @Override
public Result doWork() { public Result doWork() {
App app = ((App) getApplicationContext()); UninitializedApp app = UninitializedApp.get();
boolean ableToStartVPN = app.isAbleToStartVPN();
// We will start the VPN from the background if (ableToStartVPN) {
app.setAutoConnect(true); if (VpnService.prepare(app) == null) {
// We need to make sure we prepare the VPN Service, just in case it isn't prepared. // We're ready and have permissions, start the VPN
Intent intent = VpnService.prepare(app);
if (intent == null) {
// If null then the VPN is already prepared and/or it's just been prepared because we have permission
app.startVPN(); app.startVPN();
return Result.success(); return Result.success();
} else { }
// This VPN possibly doesn't have permission, we need to display a notification which when clicked launches the intent provided. }
android.util.Log.e("StartVPNWorker", "Tailscale doesn't have permission from the system to start VPN. Launching the intent provided.");
// We aren't ready to start the VPN or don't have permission, open the Tailscale app.
TSLog.e("StartVPNWorker", "Tailscale isn't ready to start the VPN, notify the user.");
// Send notification // Send notification
NotificationManager notificationManager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE); NotificationManager notificationManager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE);
String channelId = "start_vpn_channel"; String channelId = "start_vpn_channel";
// Use createNotificationChannel method from App.java // Use createNotificationChannel method from App.java
app.createNotificationChannel(channelId, "Start VPN Channel", NotificationManager.IMPORTANCE_DEFAULT); app.createNotificationChannel(channelId, getApplicationContext().getString(R.string.vpn_start), getApplicationContext().getString(R.string.notifications_delivered_when_user_interaction_is_required_to_establish_the_vpn_tunnel), NotificationManager.IMPORTANCE_HIGH);
// Use prepareIntent if available.
Intent intent = app.getPackageManager().getLaunchIntentForPackage(app.getPackageName());
assert intent != null;
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
int pendingIntentFlags = PendingIntent.FLAG_ONE_SHOT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_IMMUTABLE : 0); int pendingIntentFlags = PendingIntent.FLAG_ONE_SHOT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_IMMUTABLE : 0);
PendingIntent pendingIntent = PendingIntent.getActivity(app, 0, intent, pendingIntentFlags); PendingIntent pendingIntent = PendingIntent.getActivity(app, 0, intent, pendingIntentFlags);
Notification notification = new Notification.Builder(app, channelId) Notification notification = new Notification.Builder(app, channelId).setContentTitle(app.getString(R.string.title_connection_failed)).setContentText(app.getString(R.string.body_open_tailscale)).setSmallIcon(R.drawable.ic_notification).setContentIntent(pendingIntent).setAutoCancel(true).build();
.setContentTitle("Tailscale Connection Failed")
.setContentText("Tap here to renew permission.")
.setSmallIcon(R.drawable.ic_notification)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build();
notificationManager.notify(1, notification); notificationManager.notify(1, notification);
return Result.failure(); return Result.failure();
} }
} }
}

@ -5,9 +5,13 @@ package com.tailscale.ipn;
import android.content.Context; import android.content.Context;
import androidx.annotation.NonNull;
import androidx.work.Worker; import androidx.work.Worker;
import androidx.work.WorkerParameters; import androidx.work.WorkerParameters;
/**
* A worker that exists to support IPNReceiver.
*/
public final class StopVPNWorker extends Worker { public final class StopVPNWorker extends Worker {
public StopVPNWorker( public StopVPNWorker(
@ -16,9 +20,10 @@ public final class StopVPNWorker extends Worker {
super(appContext, workerParams); super(appContext, workerParams);
} }
@NonNull
@Override @Override
public Result doWork() { public Result doWork() {
App.getApplication().setWantRunning(false); UninitializedApp.get().stopVPN();
return Result.success(); return Result.success();
} }
} }

@ -0,0 +1,112 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.WorkerParameters
import com.tailscale.ipn.UninitializedApp.Companion.STATUS_CHANNEL_ID
import com.tailscale.ipn.UninitializedApp.Companion.STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
class UseExitNodeWorker(
appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
val app = UninitializedApp.get()
suspend fun runAndGetResult(): String? {
val exitNodeName = inputData.getString(EXIT_NODE_NAME)
val exitNodeId = if (exitNodeName.isNullOrEmpty()) {
null
} else {
if (!app.isAbleToStartVPN()) {
return app.getString(R.string.vpn_is_not_ready_to_start)
}
val peers =
(Notifier.netmap.value
?: run { return@runAndGetResult app.getString(R.string.tailscale_is_not_setup) })
.Peers ?: run { return@runAndGetResult app.getString(R.string.no_peers_found) }
val filteredPeers = peers.filter {
it.displayName == exitNodeName
}.toList()
if (filteredPeers.isEmpty()) {
return app.getString(R.string.no_peers_with_name_found, exitNodeName)
} else if (filteredPeers.size > 1) {
return app.getString(R.string.multiple_peers_with_name_found, exitNodeName)
} else if (!filteredPeers[0].isExitNode) {
return app.getString(
R.string.peer_with_name_is_not_an_exit_node,
exitNodeName
)
}
filteredPeers[0].StableID
}
val allowLanAccess = inputData.getBoolean(ALLOW_LAN_ACCESS, false)
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeID = exitNodeId
prefsOut.ExitNodeAllowLANAccess = allowLanAccess
val scope = CoroutineScope(Dispatchers.Default + Job())
var result: String? = null
Client(scope).editPrefs(prefsOut) {
result = if (it.isFailure) {
it.exceptionOrNull()?.message
} else {
null
}
}
scope.coroutineContext[Job]?.join()
return result
}
val result = runAndGetResult()
return if (result != null) {
val intent =
Intent(app, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent: PendingIntent =
PendingIntent.getActivity(
app, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val notification = NotificationCompat.Builder(app, STATUS_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(app.getString(R.string.use_exit_node_intent_failed))
.setContentText(result)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.build()
app.notifyStatus(notification)
Result.failure(Data.Builder().putString(ERROR_KEY, result).build())
} else {
Result.success()
}
}
companion object {
const val EXIT_NODE_NAME = "EXIT_NODE_NAME"
const val ALLOW_LAN_ACCESS = "ALLOW_LAN_ACCESS"
const val ERROR_KEY = "error"
}
}

@ -5,6 +5,8 @@ package com.tailscale.ipn
import android.net.VpnService import android.net.VpnService
import libtailscale.ParcelFileDescriptor import libtailscale.ParcelFileDescriptor
import java.net.InetAddress
import android.net.IpPrefix as AndroidIpPrefix
class VPNServiceBuilder(private val builder: VpnService.Builder) : libtailscale.VPNServiceBuilder { class VPNServiceBuilder(private val builder: VpnService.Builder) : libtailscale.VPNServiceBuilder {
override fun addAddress(p0: String, p1: Int) { override fun addAddress(p0: String, p1: Int) {
@ -19,6 +21,12 @@ class VPNServiceBuilder(private val builder: VpnService.Builder) : libtailscale.
builder.addRoute(p0, p1) builder.addRoute(p0, p1)
} }
override fun excludeRoute(p0: String, p1: Int) {
val inetAddress = InetAddress.getByName(p0)
val prefix = AndroidIpPrefix(inetAddress, p1)
builder.excludeRoute(prefix)
}
override fun addSearchDomain(p0: String) { override fun addSearchDomain(p0: String) {
builder.addSearchDomain(p0) builder.addSearchDomain(p0)
} }

@ -11,42 +11,89 @@ import kotlin.reflect.full.isSubclassOf
import kotlin.reflect.jvm.jvmErasure import kotlin.reflect.jvm.jvmErasure
object MDMSettings { object MDMSettings {
// The String message used in this NoSuchKeyException must match the value of
// syspolicy.ErrNoSuchKey defined in Go. We compare against its exact text
// to determine whether the requested policy setting is not configured and
// an actual syspolicy.ErrNoSuchKey should be returned from syspolicyHandler
// to the backend.
class NoSuchKeyException : Exception("no such key")
val forceEnabled = BooleanMDMSetting("ForceEnabled", "Force Enabled Connection Toggle") val forceEnabled = BooleanMDMSetting("ForceEnabled", "Force Enabled Connection Toggle")
// Handled on the backed
val exitNodeID = StringMDMSetting("ExitNodeID", "Forced Exit Node: Stable ID") val exitNodeID = StringMDMSetting("ExitNodeID", "Forced Exit Node: Stable ID")
// (jonathan) TODO: Unused but required. There is some funky go string duration parsing required
// here.
val keyExpirationNotice = StringMDMSetting("KeyExpirationNotice", "Key Expiration Notice Period") val keyExpirationNotice = StringMDMSetting("KeyExpirationNotice", "Key Expiration Notice Period")
val loginURL = StringMDMSetting("LoginURL", "Custom control server URL") val loginURL = StringMDMSetting("LoginURL", "Custom control server URL")
val managedByCaption = StringMDMSetting("ManagedByCaption", "Managed By - Caption") val managedByCaption = StringMDMSetting("ManagedByCaption", "Managed By - Caption")
val managedByOrganizationName = val managedByOrganizationName =
StringMDMSetting("ManagedByOrganizationName", "Managed By - Organization Name") StringMDMSetting("ManagedByOrganizationName", "Managed By - Organization Name")
val managedByURL = StringMDMSetting("ManagedByURL", "Managed By - Support URL") val managedByURL = StringMDMSetting("ManagedByURL", "Managed By - Support URL")
// Handled on the backend
val tailnet = StringMDMSetting("Tailnet", "Recommended/Required Tailnet Name") val tailnet = StringMDMSetting("Tailnet", "Recommended/Required Tailnet Name")
val hiddenNetworkDevices = val hiddenNetworkDevices =
StringArrayListMDMSetting("HiddenNetworkDevices", "Hidden Network Device Categories") StringArrayListMDMSetting("HiddenNetworkDevices", "Hidden Network Device Categories")
// Unused on Android
val allowIncomingConnections = val allowIncomingConnections =
AlwaysNeverUserDecidesMDMSetting("AllowIncomingConnections", "Allow Incoming Connections") AlwaysNeverUserDecidesMDMSetting("AllowIncomingConnections", "Allow Incoming Connections")
// Unused on Android
val detectThirdPartyAppConflicts = val detectThirdPartyAppConflicts =
AlwaysNeverUserDecidesMDMSetting( AlwaysNeverUserDecidesMDMSetting(
"DetectThirdPartyAppConflicts", "Detect potentially problematic third-party apps") "DetectThirdPartyAppConflicts", "Detect potentially problematic third-party apps")
val exitNodeAllowLANAccess = val exitNodeAllowLANAccess =
AlwaysNeverUserDecidesMDMSetting( AlwaysNeverUserDecidesMDMSetting(
"ExitNodeAllowLANAccess", "Allow LAN Access when using an exit node") "ExitNodeAllowLANAccess", "Allow LAN Access when using an exit node")
// Handled on the backend
val postureChecking = val postureChecking =
AlwaysNeverUserDecidesMDMSetting("PostureChecking", "Enable Posture Checking") AlwaysNeverUserDecidesMDMSetting("PostureChecking", "Enable Posture Checking")
val useTailscaleDNSSettings = val useTailscaleDNSSettings =
AlwaysNeverUserDecidesMDMSetting("UseTailscaleDNSSettings", "Use Tailscale DNS Settings") AlwaysNeverUserDecidesMDMSetting("UseTailscaleDNSSettings", "Use Tailscale DNS Settings")
// Unused on Android
val useTailscaleSubnets = val useTailscaleSubnets =
AlwaysNeverUserDecidesMDMSetting("UseTailscaleSubnets", "Use Tailscale Subnets") AlwaysNeverUserDecidesMDMSetting("UseTailscaleSubnets", "Use Tailscale Subnets")
val exitNodesPicker = ShowHideMDMSetting("ExitNodesPicker", "Exit Nodes Picker") val exitNodesPicker = ShowHideMDMSetting("ExitNodesPicker", "Exit Nodes Picker")
val manageTailnetLock = ShowHideMDMSetting("ManageTailnetLock", "“Manage Tailnet lock” menu item") val manageTailnetLock = ShowHideMDMSetting("ManageTailnetLock", "“Manage Tailnet lock” menu item")
// Unused on Android
val resetToDefaults = ShowHideMDMSetting("ResetToDefaults", "“Reset to Defaults” menu item") val resetToDefaults = ShowHideMDMSetting("ResetToDefaults", "“Reset to Defaults” menu item")
val runExitNode = ShowHideMDMSetting("RunExitNode", "Run as Exit Node") val runExitNode = ShowHideMDMSetting("RunExitNode", "Run as Exit Node")
// Unused on Android
val testMenu = ShowHideMDMSetting("TestMenu", "Show Debug Menu") val testMenu = ShowHideMDMSetting("TestMenu", "Show Debug Menu")
// Unused on Android
val updateMenu = ShowHideMDMSetting("UpdateMenu", "“Update Available” menu item") val updateMenu = ShowHideMDMSetting("UpdateMenu", "“Update Available” menu item")
// (jonathan) TODO: Use this when suggested exit nodes are implemented
val allowedSuggestedExitNodes =
StringArrayListMDMSetting("AllowedSuggestedExitNodes", "Allowed Suggested Exit Nodes")
// Allows admins to define a list of packages that won't be routed via Tailscale.
val excludedPackages = StringMDMSetting("ExcludedPackageNames", "Excluded Package Names")
// Allows admins to define a list of packages that will be routed via Tailscale, letting all other
// apps skip the VPN tunnel.
val includedPackages = StringMDMSetting("IncludedPackageNames", "Included Package Names")
// Handled on the backend
val authKey = StringMDMSetting("AuthKey", "Auth Key for login")
val allSettings by lazy { val allSettings by lazy {
MDMSettings::class MDMSettings::class
.declaredMemberProperties .declaredMemberProperties
@ -61,6 +108,8 @@ object MDMSettings {
fun update(app: App, restrictionsManager: RestrictionsManager?) { fun update(app: App, restrictionsManager: RestrictionsManager?) {
val bundle = restrictionsManager?.applicationRestrictions val bundle = restrictionsManager?.applicationRestrictions
allSettings.forEach { it.setFrom(bundle, app) } val preferences = lazy { app.getEncryptedPrefs() }
allSettings.forEach { it.setFrom(bundle, preferences) }
app.notifyPolicyChanged()
} }
} }

@ -3,88 +3,102 @@
package com.tailscale.ipn.mdm package com.tailscale.ipn.mdm
import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import com.tailscale.ipn.App import com.tailscale.ipn.App
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
data class SettingState<T>(val value: T, val isSet: Boolean)
abstract class MDMSetting<T>(defaultValue: T, val key: String, val localizedTitle: String) { abstract class MDMSetting<T>(defaultValue: T, val key: String, val localizedTitle: String) {
val flow: StateFlow<T> = MutableStateFlow<T>(defaultValue) val defaultValue = defaultValue
val flow = MutableStateFlow(SettingState(defaultValue, false))
fun setFrom(bundle: Bundle?, prefs: Lazy<SharedPreferences>) {
val v: T? = getFrom(bundle, prefs)
flow.set(SettingState(v ?: defaultValue, v != null))
}
fun setFrom(bundle: Bundle?, app: App) { fun getFrom(bundle: Bundle?, prefs: Lazy<SharedPreferences>): T? {
val v = getFrom(bundle, app) return when {
flow.set(v) bundle != null -> bundle.takeIf { it.containsKey(key) }?.let { getFromBundle(it) }
else -> prefs.value.takeIf { it.contains(key) }?.let { getFromPrefs(it) }
}
} }
abstract fun getFrom(bundle: Bundle?, app: App): T protected abstract fun getFromBundle(bundle: Bundle): T
protected abstract fun getFromPrefs(prefs: SharedPreferences): T
} }
class BooleanMDMSetting(key: String, localizedTitle: String) : class BooleanMDMSetting(key: String, localizedTitle: String) :
MDMSetting<Boolean>(false, key, localizedTitle) { MDMSetting<Boolean>(false, key, localizedTitle) {
override fun getFrom(bundle: Bundle?, app: App) = override fun getFromBundle(bundle: Bundle) = bundle.getBoolean(key)
bundle?.getBoolean(key) ?: app.getEncryptedPrefs().getBoolean(key, false) override fun getFromPrefs(prefs: SharedPreferences) = prefs.getBoolean(key, false)
} }
class StringMDMSetting(key: String, localizedTitle: String) : class StringMDMSetting(key: String, localizedTitle: String) :
MDMSetting<String?>(null, key, localizedTitle) { MDMSetting<String?>(null, key, localizedTitle) {
override fun getFrom(bundle: Bundle?, app: App) = override fun getFromBundle(bundle: Bundle) = bundle.getString(key)
bundle?.getString(key) ?: app.getEncryptedPrefs().getString(key, null) override fun getFromPrefs(prefs: SharedPreferences) = prefs.getString(key, null)
} }
class StringArrayListMDMSetting(key: String, localizedTitle: String) : class StringArrayListMDMSetting(key: String, localizedTitle: String) :
MDMSetting<List<String>?>(null, key, localizedTitle) { MDMSetting<List<String>?>(null, key, localizedTitle) {
override fun getFrom(bundle: Bundle?, app: App) = override fun getFromBundle(bundle: Bundle) = bundle.getStringArrayList(key)
bundle?.getStringArrayList(key) override fun getFromPrefs(prefs: SharedPreferences) =
?: app.getEncryptedPrefs().getStringSet(key, HashSet<String>())?.toList() prefs.getStringSet(key, HashSet<String>())?.toList()
} }
class AlwaysNeverUserDecidesMDMSetting(key: String, localizedTitle: String) : class AlwaysNeverUserDecidesMDMSetting(key: String, localizedTitle: String) :
MDMSetting<AlwaysNeverUserDecides>(AlwaysNeverUserDecides.UserDecides, key, localizedTitle) { MDMSetting<AlwaysNeverUserDecides>(AlwaysNeverUserDecides.UserDecides, key, localizedTitle) {
override fun getFrom(bundle: Bundle?, app: App): AlwaysNeverUserDecides { override fun getFromBundle(bundle: Bundle) =
val storedString = AlwaysNeverUserDecides.fromString(bundle.getString(key))
bundle?.getString(key) override fun getFromPrefs(prefs: SharedPreferences) =
?: App.getApplication().getEncryptedPrefs().getString(key, null) AlwaysNeverUserDecides.fromString(prefs.getString(key, null))
?: "user-decides"
return when (storedString) {
"always" -> {
AlwaysNeverUserDecides.Always
}
"never" -> {
AlwaysNeverUserDecides.Never
}
else -> {
AlwaysNeverUserDecides.UserDecides
}
}
}
} }
class ShowHideMDMSetting(key: String, localizedTitle: String) : class ShowHideMDMSetting(key: String, localizedTitle: String) :
MDMSetting<ShowHide>(ShowHide.Show, key, localizedTitle) { MDMSetting<ShowHide>(ShowHide.Show, key, localizedTitle) {
override fun getFrom(bundle: Bundle?, app: App): ShowHide { override fun getFromBundle(bundle: Bundle) =
val storedString = ShowHide.fromString(bundle.getString(key))
bundle?.getString(key) override fun getFromPrefs(prefs: SharedPreferences) =
?: App.getApplication().getEncryptedPrefs().getString(key, null) ShowHide.fromString(prefs.getString(key, null))
?: "show"
return when (storedString) {
"hide" -> {
ShowHide.Hide
}
else -> {
ShowHide.Show
}
}
}
} }
enum class AlwaysNeverUserDecides(val value: String) { enum class AlwaysNeverUserDecides(val value: String) {
Always("always"), Always("always"),
Never("never"), Never("never"),
UserDecides("user-decides") UserDecides("user-decides");
val hiddenFromUser: Boolean
get() {
return this != UserDecides
}
override fun toString(): String {
return value
}
companion object {
fun fromString(value: String?): AlwaysNeverUserDecides {
return values().find { it.value == value } ?: UserDecides
}
}
} }
enum class ShowHide(val value: String) { enum class ShowHide(val value: String) {
Show("show"), Show("show"),
Hide("hide") Hide("hide");
override fun toString(): String {
return value
}
companion object {
fun fromString(value: String?): ShowHide {
return ShowHide.values().find { it.value == value } ?: Show
}
}
} }

@ -4,14 +4,15 @@
package com.tailscale.ipn.ui.localapi package com.tailscale.ipn.ui.localapi
import android.content.Context import android.content.Context
import android.util.Log
import com.tailscale.ipn.ui.model.BugReportID import com.tailscale.ipn.ui.model.BugReportID
import com.tailscale.ipn.ui.model.Errors import com.tailscale.ipn.ui.model.Errors
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.IpnState import com.tailscale.ipn.ui.model.IpnState
import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.util.InputStreamAdapter import com.tailscale.ipn.ui.util.InputStreamAdapter
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -46,6 +47,7 @@ private object Endpoint {
const val FILES = "files" const val FILES = "files"
const val FILE_PUT = "file-put" const val FILE_PUT = "file-put"
const val TAILFS_SERVER_ADDRESS = "tailfs/fileserver-address" const val TAILFS_SERVER_ADDRESS = "tailfs/fileserver-address"
const val ENABLE_EXIT_NODE = "set-use-exit-node-enabled"
} }
typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit
@ -56,6 +58,8 @@ typealias BugReportIdHandler = (Result<BugReportID>) -> Unit
typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
typealias PingResultHandler = (Result<IpnState.PingResult>) -> Unit
/** /**
* Client provides a mechanism for calling Go's LocalAPIClient. Every LocalAPI endpoint has a * Client provides a mechanism for calling Go's LocalAPIClient. Every LocalAPI endpoint has a
* corresponding method on this Client. * corresponding method on this Client.
@ -72,6 +76,17 @@ class Client(private val scope: CoroutineScope) {
get(Endpoint.STATUS, responseHandler = responseHandler) get(Endpoint.STATUS, responseHandler = responseHandler)
} }
fun ping(peer: Tailcfg.Node, responseHandler: PingResultHandler) {
val ip = peer.primaryIPv4Address.orEmpty()
if (ip.isEmpty()) {
responseHandler(Result.failure(Exception("No IP address for peer $peer")))
return
}
val path = "${Endpoint.PING}?ip=${ip}&type=disco"
post(path, timeoutMillis = 2000L, responseHandler = responseHandler)
}
fun bugReportId(responseHandler: BugReportIdHandler) { fun bugReportId(responseHandler: BugReportIdHandler) {
post(Endpoint.BUG_REPORT, responseHandler = responseHandler) post(Endpoint.BUG_REPORT, responseHandler = responseHandler)
} }
@ -85,6 +100,11 @@ class Client(private val scope: CoroutineScope) {
return patch(Endpoint.PREFS, body, responseHandler = responseHandler) return patch(Endpoint.PREFS, body, responseHandler = responseHandler)
} }
fun setUseExitNode(use: Boolean, responseHandler: (Result<Ipn.Prefs>) -> Unit) {
val path = "${Endpoint.ENABLE_EXIT_NODE}?enabled=$use"
return post(path, responseHandler = responseHandler)
}
fun profiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit) { fun profiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit) {
get(Endpoint.PROFILES, responseHandler = responseHandler) get(Endpoint.PROFILES, responseHandler = responseHandler)
} }
@ -155,7 +175,7 @@ class Client(private val scope: CoroutineScope) {
}) })
} catch (e: Exception) { } catch (e: Exception) {
parts.forEach { it.body.close() } parts.forEach { it.body.close() }
Log.e(TAG, "Error creating file upload body: $e") TSLog.e(TAG, "Error creating file upload body: $e")
responseHandler(Result.failure(e)) responseHandler(Result.failure(e))
return return
} }
@ -200,6 +220,7 @@ class Client(private val scope: CoroutineScope) {
private inline fun <reified T> post( private inline fun <reified T> post(
path: String, path: String,
body: ByteArray? = null, body: ByteArray? = null,
timeoutMillis: Long = 30000,
noinline responseHandler: (Result<T>) -> Unit noinline responseHandler: (Result<T>) -> Unit
) { ) {
Request( Request(
@ -207,6 +228,7 @@ class Client(private val scope: CoroutineScope) {
method = "POST", method = "POST",
path = path, path = path,
body = body, body = body,
timeoutMillis = timeoutMillis,
responseType = typeOf<T>(), responseType = typeOf<T>(),
responseHandler = responseHandler) responseHandler = responseHandler)
.execute() .execute()
@ -285,7 +307,7 @@ class Request<T>(
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
fun execute() { fun execute() {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
Log.d(TAG, "Executing request:${method}:${fullPath} on app $app") TSLog.d(TAG, "Executing request:${method}:${fullPath} on app $app")
try { try {
val resp = val resp =
if (parts != null) app.callLocalAPIMultipart(timeoutMillis, method, fullPath, parts) if (parts != null) app.callLocalAPIMultipart(timeoutMillis, method, fullPath, parts)
@ -298,6 +320,8 @@ class Request<T>(
// TODO: use the streaming body for performance // TODO: use the streaming body for performance
// An empty body is a perfectly valid response and indicates success // An empty body is a perfectly valid response and indicates success
val respData = resp.bodyBytes() ?: ByteArray(0) val respData = resp.bodyBytes() ?: ByteArray(0)
@Suppress("UNCHECKED_CAST")
val response: Result<T> = val response: Result<T> =
when (responseType) { when (responseType) {
typeOf<String>() -> Result.success(respData.decodeToString() as T) typeOf<String>() -> Result.success(respData.decodeToString() as T)
@ -326,7 +350,7 @@ class Request<T>(
// The response handler will invoked internally by the request parser // The response handler will invoked internally by the request parser
scope.launch { responseHandler(response) } scope.launch { responseHandler(response) }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error executing request:${method}:${fullPath}: $e") TSLog.e(TAG, "Error executing request:${method}:${fullPath}: $e")
scope.launch { responseHandler(Result.failure(e)) } scope.launch { responseHandler(Result.failure(e)) }
} }
} }

@ -0,0 +1,85 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.model
import androidx.compose.material3.ListItemColors
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import com.tailscale.ipn.ui.theme.warning
import kotlinx.serialization.Serializable
class Health {
@Serializable
data class State(
// WarnableCode -> UnhealthyState or null
var Warnings: Map<String, UnhealthyState?>? = null,
)
@Serializable
data class UnhealthyState(
var WarnableCode: String,
var Severity: Severity,
var Title: String,
var Text: String,
var BrokenSince: String? = null,
var Args: Map<String, String>? = null,
var ImpactsConnectivity: Boolean? = false,
var DependsOn: List<String>? = null, // an array of WarnableCodes this depends on
) : Comparable<UnhealthyState> {
fun hiddenByDependencies(currentWarnableCodes: Set<String>): Boolean {
return this.DependsOn?.let {
it.any { depWarnableCode -> currentWarnableCodes.contains(depWarnableCode) }
} == true
}
override fun compareTo(other: UnhealthyState): Int {
// Compare by severity first
val severityComparison = Severity.compareTo(other.Severity)
if (severityComparison != 0) {
return severityComparison
}
// If severities are equal, compare by warnableCode
return WarnableCode.compareTo(other.WarnableCode)
}
}
@Serializable
enum class Severity : Comparable<Severity> {
low,
medium,
high;
@Composable
fun listItemColors(): ListItemColors {
val default = ListItemDefaults.colors()
return when (this) {
Severity.low ->
ListItemColors(
containerColor = MaterialTheme.colorScheme.surface,
headlineColor = MaterialTheme.colorScheme.secondary,
leadingIconColor = MaterialTheme.colorScheme.secondary,
overlineColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.8f),
supportingTextColor = MaterialTheme.colorScheme.secondary,
trailingIconColor = MaterialTheme.colorScheme.secondary,
disabledHeadlineColor = default.disabledHeadlineColor,
disabledLeadingIconColor = default.disabledLeadingIconColor,
disabledTrailingIconColor = default.disabledTrailingIconColor)
Severity.medium,
Severity.high ->
ListItemColors(
containerColor = MaterialTheme.colorScheme.warning,
headlineColor = MaterialTheme.colorScheme.onPrimary,
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f),
supportingTextColor = MaterialTheme.colorScheme.onPrimary,
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
disabledHeadlineColor = default.disabledHeadlineColor,
disabledLeadingIconColor = default.disabledLeadingIconColor,
disabledTrailingIconColor = default.disabledTrailingIconColor)
}
}
}
}

@ -18,7 +18,11 @@ class Ipn {
NeedsMachineAuth(3), NeedsMachineAuth(3),
Stopped(4), Stopped(4),
Starting(5), Starting(5),
Running(6); Running(6),
// Stopping represents a state where a request to stop Tailscale has been issue but has not
// completed. This state allows UI to optimistically reflect a stopped state, and to fallback if
// necessary.
Stopping(7);
companion object { companion object {
fun fromInt(value: Int): State { fun fromInt(value: Int): State {
@ -46,6 +50,7 @@ class Ipn {
var IncomingFiles: List<PartialFile>? = null, var IncomingFiles: List<PartialFile>? = null,
var ClientVersion: Tailcfg.ClientVersion? = null, var ClientVersion: Tailcfg.ClientVersion? = null,
var TailFSShares: List<String>? = null, var TailFSShares: List<String>? = null,
var Health: Health.State? = null,
) )
@Serializable @Serializable
@ -65,7 +70,22 @@ class Ipn {
var ForceDaemon: Boolean = false, var ForceDaemon: Boolean = false,
var HostName: String = "", var HostName: String = "",
var AutoUpdate: AutoUpdatePrefs? = AutoUpdatePrefs(true, true), var AutoUpdate: AutoUpdatePrefs? = AutoUpdatePrefs(true, true),
) var InternalExitNodePrior: String? = null,
) {
// For the InternalExitNodePrior and ExitNodeId, these will treats the empty string as null to
// simplify the downstream logic.
val selectedExitNodeID: String?
get() {
return if (InternalExitNodePrior.isNullOrEmpty()) null else InternalExitNodePrior
}
val activeExitNodeID: String?
get() {
return if (ExitNodeID.isNullOrEmpty()) null else ExitNodeID
}
}
@Serializable @Serializable
data class MaskedPrefs( data class MaskedPrefs(
@ -79,6 +99,7 @@ class Ipn {
var AdvertiseRoutesSet: Boolean? = null, var AdvertiseRoutesSet: Boolean? = null,
var ForceDaemonSet: Boolean? = null, var ForceDaemonSet: Boolean? = null,
var HostnameSet: Boolean? = null, var HostnameSet: Boolean? = null,
var InternalExitNodePriorSet: Boolean? = null,
) { ) {
var ControlURL: String? = null var ControlURL: String? = null
@ -105,6 +126,12 @@ class Ipn {
ExitNodeIDSet = true ExitNodeIDSet = true
} }
var InternalExitNodePrior: String? = null
set(value) {
field = value
InternalExitNodePriorSet = true
}
var ExitNodeAllowLANAccess: Boolean? = null var ExitNodeAllowLANAccess: Boolean? = null
set(value) { set(value) {
field = value field = value
@ -135,7 +162,7 @@ class Ipn {
ForceDaemonSet = true ForceDaemonSet = true
} }
var Hostname: Boolean? = null var Hostname: String? = null
set(value) { set(value) {
field = value field = value
HostnameSet = true HostnameSet = true
@ -194,7 +221,8 @@ class Ipn {
@Serializable @Serializable
data class Options( data class Options(
var FrontendLogID: String? = null, var FrontendLogID: String? = null,
var Prefs: Prefs? = null, var UpdatePrefs: Prefs? = null,
var AuthKey: String? = null,
) )
} }

@ -23,6 +23,8 @@ class IpnState {
val Tags: List<String>? = null, val Tags: List<String>? = null,
val PrimaryRoutes: List<String>? = null, val PrimaryRoutes: List<String>? = null,
val Addrs: List<String>? = null, val Addrs: List<String>? = null,
val CurAddr: String? = null,
val Relay: String? = null,
val Online: Boolean, val Online: Boolean,
val ExitNode: Boolean, val ExitNode: Boolean,
val ExitNodeOption: Boolean, val ExitNodeOption: Boolean,

@ -22,7 +22,7 @@ object Permissions {
@Composable @Composable
get() { get() {
val permissionStates = rememberMultiplePermissionsState(permissions = all.map { it.name }) val permissionStates = rememberMultiplePermissionsState(permissions = all.map { it.name })
return all.zip(permissionStates.permissions).filter { (permission, state) -> return all.zip(permissionStates.permissions).filter { (_, state) ->
!state.status.isGranted && !state.status.shouldShowRationale !state.status.isGranted && !state.status.shouldShowRationale
} }
} }

@ -7,11 +7,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.off import com.tailscale.ipn.ui.theme.off
import com.tailscale.ipn.ui.theme.on import com.tailscale.ipn.ui.theme.on
import com.tailscale.ipn.ui.util.ComposableStringFormatter import com.tailscale.ipn.ui.util.ComposableStringFormatter
import com.tailscale.ipn.ui.util.DisplayAddress import com.tailscale.ipn.ui.util.DisplayAddress
import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.viewModel.PeerSettingInfo import com.tailscale.ipn.ui.viewModel.PeerSettingInfo
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
@ -91,6 +93,16 @@ class Tailcfg {
Capabilities?.contains("https://tailscale.com/cap/is-admin") == true || Capabilities?.contains("https://tailscale.com/cap/is-admin") == true ||
CapMap?.contains("https://tailscale.com/cap/is-admin") == true CapMap?.contains("https://tailscale.com/cap/is-admin") == true
// Derives the url to directly administer a node
val nodeAdminUrl: String
get() = primaryIPv4Address?.let { "${Links.ADMIN_URL}/machines/${it}" } ?: Links.ADMIN_URL
val primaryIPv4Address: String?
get() = displayAddresses.firstOrNull { it.type == DisplayAddress.addrType.V4 }?.address
val primaryIPv6Address: String?
get() = displayAddresses.firstOrNull { it.type == DisplayAddress.addrType.V6 }?.address
// isExitNode reproduces the Go logic in local.go peerStatusFromNode // isExitNode reproduces the Go logic in local.go peerStatusFromNode
val isExitNode: Boolean = val isExitNode: Boolean =
AllowedIPs?.contains("0.0.0.0/0") ?: false && AllowedIPs?.contains("::/0") ?: false AllowedIPs?.contains("0.0.0.0/0") ?: false && AllowedIPs?.contains("::/0") ?: false
@ -101,6 +113,20 @@ class Tailcfg {
val displayName: String val displayName: String
get() = ComputedName ?: Name get() = ComputedName ?: Name
val exitNodeName: String
get() {
if (isMullvadNode &&
Hostinfo.Location?.Country != null &&
Hostinfo.Location?.City != null &&
Hostinfo.Location?.CountryCode != null) {
return "${Hostinfo.Location!!.CountryCode!!.flag()} ${Hostinfo.Location!!.Country!!}: ${Hostinfo.Location!!.City!!}"
}
return displayName
}
val keyDoesNotExpire: Boolean
get() = KeyExpiry == "0001-01-01T00:00:00Z"
fun isSelfNode(netmap: Netmap.NetworkMap): Boolean = StableID == netmap.SelfNode.StableID fun isSelfNode(netmap: Netmap.NetworkMap): Boolean = StableID == netmap.SelfNode.StableID
fun connectedOrSelfNode(nm: Netmap.NetworkMap?) = fun connectedOrSelfNode(nm: Netmap.NetworkMap?) =
@ -131,7 +157,13 @@ class Tailcfg {
PeerSettingInfo(R.string.os, ComposableStringFormatter(Hostinfo.OS!!)), PeerSettingInfo(R.string.os, ComposableStringFormatter(Hostinfo.OS!!)),
) )
} }
if (keyDoesNotExpire) {
result.add(
PeerSettingInfo(
R.string.key_expiry, ComposableStringFormatter(R.string.deviceKeyNeverExpires)))
} else {
result.add(PeerSettingInfo(R.string.key_expiry, TimeUtil.keyExpiryFromGoTime(KeyExpiry))) result.add(PeerSettingInfo(R.string.key_expiry, TimeUtil.keyExpiryFromGoTime(KeyExpiry)))
}
return result return result
} }

@ -0,0 +1,140 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.notifier
import android.Manifest
import android.content.pm.PackageManager
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import com.tailscale.ipn.App
import com.tailscale.ipn.R
import com.tailscale.ipn.UninitializedApp.Companion.notificationManager
import com.tailscale.ipn.ui.model.Health
import com.tailscale.ipn.ui.model.Health.UnhealthyState
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@OptIn(FlowPreview::class)
class HealthNotifier(
healthStateFlow: StateFlow<Health.State?>,
scope: CoroutineScope,
) {
companion object {
const val HEALTH_CHANNEL_ID = "tailscale-health"
}
private val TAG = "Health"
private val ignoredWarnableCodes: Set<String> =
setOf(
// Ignored on Android because installing unstable takes quite some effort
"is-using-unstable-version",
// Ignored on Android because we already have a dedicated connected/not connected
// notification
"wantrunning-false")
init {
scope.launch {
healthStateFlow
.distinctUntilChanged { old, new -> old?.Warnings?.count() == new?.Warnings?.count() }
.debounce(5000)
.collect { health ->
TSLog.d(TAG, "Health updated: ${health?.Warnings?.keys?.sorted()}")
health?.Warnings?.let {
notifyHealthUpdated(it.values.mapNotNull { it }.toTypedArray())
}
}
}
}
val currentWarnings: StateFlow<Set<UnhealthyState>> = MutableStateFlow(setOf())
val currentIcon: StateFlow<Int?> = MutableStateFlow(null)
private fun notifyHealthUpdated(warnings: Array<UnhealthyState>) {
val warningsBeforeAdd = currentWarnings.value
val currentWarnableCodes = warnings.map { it.WarnableCode }.toSet()
val addedWarnings: MutableSet<UnhealthyState> = mutableSetOf()
val isWarmingUp = warnings.any { it.WarnableCode == "warming-up" }
for (warning in warnings) {
if (ignoredWarnableCodes.contains(warning.WarnableCode)) {
continue
}
addedWarnings.add(warning)
if (this.currentWarnings.value.contains(warning)) {
// Already notified, skip
continue
} else if (warning.hiddenByDependencies(currentWarnableCodes)) {
// Ignore this warning because a dependency is also unhealthy
TSLog.d(TAG, "Ignoring ${warning.WarnableCode} because of dependency")
continue
} else if (!isWarmingUp) {
TSLog.d(TAG, "Adding health warning: ${warning.WarnableCode}")
this.currentWarnings.set(this.currentWarnings.value + warning)
if (warning.Severity == Health.Severity.high) {
this.sendNotification(warning.Title, warning.Text, warning.WarnableCode)
}
} else {
TSLog.d(TAG, "Ignoring ${warning.WarnableCode} because warming up")
}
}
val warningsToDrop = warningsBeforeAdd.minus(addedWarnings)
if (warningsToDrop.isNotEmpty()) {
TSLog.d(TAG, "Dropping health warnings with codes $warningsToDrop")
this.removeNotifications(warningsToDrop)
}
currentWarnings.set(this.currentWarnings.value.subtract(warningsToDrop))
this.updateIcon()
}
private fun updateIcon() {
if (currentWarnings.value.isEmpty()) {
this.currentIcon.set(null)
return
}
if (currentWarnings.value.any {
(it.Severity == Health.Severity.high || it.ImpactsConnectivity == true)
}) {
this.currentIcon.set(R.drawable.warning_rounded)
} else {
this.currentIcon.set(R.drawable.info)
}
}
private fun sendNotification(title: String, text: String, code: String) {
TSLog.d(TAG, "Sending notification for $code")
val notification =
NotificationCompat.Builder(App.get().applicationContext, HEALTH_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
if (ActivityCompat.checkSelfPermission(
App.get().applicationContext, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED) {
TSLog.d(TAG, "Notification permission not granted")
return
}
notificationManager.notify(code.hashCode(), notification)
}
private fun removeNotifications(warnings: Set<UnhealthyState>) {
TSLog.d(TAG, "Removing notifications for $warnings")
for (warning in warnings) {
notificationManager.cancel(warning.WarnableCode.hashCode())
}
}
}

@ -4,7 +4,9 @@
package com.tailscale.ipn.ui.notifier package com.tailscale.ipn.ui.notifier
import android.util.Log import android.util.Log
import com.tailscale.ipn.App
import com.tailscale.ipn.ui.model.Empty import com.tailscale.ipn.ui.model.Empty
import com.tailscale.ipn.ui.model.Health
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Ipn.Notify import com.tailscale.ipn.ui.model.Ipn.Notify
import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Netmap
@ -17,6 +19,7 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.decodeFromStream
import com.tailscale.ipn.util.TSLog
// Notifier is a wrapper around the IPN Bus notifier. It provides a way to watch // Notifier is a wrapper around the IPN Bus notifier. It provides a way to watch
// for changes in various parts of the Tailscale engine. You will typically only use // for changes in various parts of the Tailscale engine. You will typically only use
@ -30,12 +33,9 @@ object Notifier {
private val TAG = Notifier::class.simpleName private val TAG = Notifier::class.simpleName
private val decoder = Json { ignoreUnknownKeys = true } private val decoder = Json { ignoreUnknownKeys = true }
// Global App State
val tileReady: StateFlow<Boolean> = MutableStateFlow(false)
val readyToPrepareVPN: StateFlow<Boolean> = MutableStateFlow(false)
// General IPN Bus State // General IPN Bus State
val state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState) private val _state = MutableStateFlow(Ipn.State.NoState)
val state: StateFlow<Ipn.State> = _state
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null) val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
val prefs: StateFlow<Ipn.Prefs?> = MutableStateFlow(null) val prefs: StateFlow<Ipn.Prefs?> = MutableStateFlow(null)
val engineStatus: StateFlow<Ipn.EngineStatus?> = MutableStateFlow(null) val engineStatus: StateFlow<Ipn.EngineStatus?> = MutableStateFlow(null)
@ -43,6 +43,7 @@ object Notifier {
val browseToURL: StateFlow<String?> = MutableStateFlow(null) val browseToURL: StateFlow<String?> = MutableStateFlow(null)
val loginFinished: StateFlow<String?> = MutableStateFlow(null) val loginFinished: StateFlow<String?> = MutableStateFlow(null)
val version: StateFlow<String?> = MutableStateFlow(null) val version: StateFlow<String?> = MutableStateFlow(null)
val health: StateFlow<Health.State?> = MutableStateFlow(null)
// Taildrop-specific State // Taildrop-specific State
val outgoingFiles: StateFlow<List<Ipn.OutgoingFile>?> = MutableStateFlow(null) val outgoingFiles: StateFlow<List<Ipn.OutgoingFile>?> = MutableStateFlow(null)
@ -59,12 +60,16 @@ object Notifier {
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
fun start(scope: CoroutineScope) { fun start(scope: CoroutineScope) {
Log.d(TAG, "Starting") TSLog.d(TAG, "Starting Notifier")
if (!::app.isInitialized) {
App.get()
}
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
val mask = val mask =
NotifyWatchOpt.Netmap.value or NotifyWatchOpt.Netmap.value or
NotifyWatchOpt.Prefs.value or NotifyWatchOpt.Prefs.value or
NotifyWatchOpt.InitialState.value NotifyWatchOpt.InitialState.value or
NotifyWatchOpt.InitialHealthState.value
manager = manager =
app.watchNotifications(mask.toLong()) { notification -> app.watchNotifications(mask.toLong()) { notification ->
val notify = decoder.decodeFromStream<Notify>(notification.inputStream()) val notify = decoder.decodeFromStream<Notify>(notification.inputStream())
@ -79,16 +84,13 @@ object Notifier {
notify.OutgoingFiles?.let(outgoingFiles::set) notify.OutgoingFiles?.let(outgoingFiles::set)
notify.FilesWaiting?.let(filesWaiting::set) notify.FilesWaiting?.let(filesWaiting::set)
notify.IncomingFiles?.let(incomingFiles::set) notify.IncomingFiles?.let(incomingFiles::set)
} notify.Health?.let(health::set)
state.collect { currstate ->
readyToPrepareVPN.set(currstate > Ipn.State.Stopped)
tileReady.set(currstate >= Ipn.State.Stopped)
} }
} }
} }
fun stop() { fun stop() {
Log.d(TAG, "Stopping") TSLog.d(TAG, "Stopping Notifier")
manager?.let { manager?.let {
it.stop() it.stop()
manager = null manager = null
@ -103,6 +105,12 @@ object Notifier {
Prefs(4), Prefs(4),
Netmap(8), Netmap(8),
NoPrivateKey(16), NoPrivateKey(16),
InitialTailFSShares(32) InitialTailFSShares(32),
InitialOutgoingFiles(64),
InitialHealthState(128),
}
fun setState(newState: Ipn.State) {
_state.value = newState
} }
} }

@ -16,6 +16,7 @@ import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@ -27,7 +28,12 @@ import com.google.accompanist.systemuicontroller.rememberSystemUiController
@Composable @Composable
fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) { fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
val colors = LightColors val colors =
if (useDarkTheme) {
DarkColors
} else {
LightColors
}
val typography = val typography =
Typography( Typography(
@ -38,7 +44,8 @@ fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable
// margins in list items. // margins in list items.
bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp)) bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp))
val systemUiController = rememberSystemUiController() // TODO: Migrate to Activity.enableEdgeToEdge
@Suppress("deprecation") val systemUiController = rememberSystemUiController()
DisposableEffect(systemUiController, useDarkTheme) { DisposableEffect(systemUiController, useDarkTheme) {
systemUiController.setStatusBarColor(color = colors.surfaceContainer) systemUiController.setStatusBarColor(color = colors.surfaceContainer)
@ -78,8 +85,43 @@ private val LightColors =
scrim = Color(0xAA000000), // black scrim = Color(0xAA000000), // black
) )
private val DarkColors =
darkColorScheme(
primary = Color(0xFF3E5DB3), // blue-600
onPrimary = Color(0xFFFFFFFF), // white
primaryContainer = Color(0xFFf0f5ff), // blue-0
onPrimaryContainer = Color(0xFF5A82DC), // blue-400
error = Color(0xFFEF5350), // red-400
onError = Color(0xFFFFFFFF), // white
errorContainer = Color(0xFFfff6f4), // red-0
onErrorContainer = Color(0xFF940822), // red-600
surfaceDim = Color(0xFF1f1e1e), // gray-900
surface = Color(0xFF232222), // gray-800
background = Color(0xFF181717), // gray-1000
surfaceBright = Color(0xFF444342), // gray-600
surfaceContainerLowest = Color(0xFF1f1e1e), // gray-900
surfaceContainerLow = Color(0xFF232222), // gray-800
surfaceContainer = Color(0xFF181717), // gray-1000
surfaceContainerHigh = Color(0xFF232222), // gray-800
surfaceContainerHighest = Color(0xFF2e2d2d), // gray-700
surfaceVariant = Color(0xFF1f1e1e), // gray-900
onSurface = Color(0xFFfaf9f8), // gray-0
onSurfaceVariant = Color(0xFFafacab), // gray-400
outline = Color(0xFF706E6D), // gray-500
outlineVariant = Color(0xFF2E2D2D), // gray-700
inverseSurface = Color(0xFFEDEBEA), // gray-200
inverseOnSurface = Color(0xFF000000), // black
scrim = Color(0xAA000000), // black
)
val ColorScheme.warning: Color val ColorScheme.warning: Color
get() = Color(0xFFD97916) // yellow-300 @Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFFBB5504) // yellow-400
} else {
Color(0xFFD97917) // yellow-300
}
val ColorScheme.onWarning: Color val ColorScheme.onWarning: Color
get() = Color(0xFFFFFFFF) // white get() = Color(0xFFFFFFFF) // white
@ -106,11 +148,35 @@ val ColorScheme.on: Color
get() = Color(0xFF1CA672) // green-300 get() = Color(0xFF1CA672) // green-300
val ColorScheme.off: Color val ColorScheme.off: Color
get() = Color(0xFFD9D6D5) // gray-300 @Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFF444342) // gray-600
} else {
Color(0xFFD9D6D5) // gray-300
}
val ColorScheme.link: Color val ColorScheme.link: Color
get() = onPrimaryContainer get() = onPrimaryContainer
val ColorScheme.customError: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFF940821) // red-600
} else {
Color(0xFFB22D30) // red-500
}
val ColorScheme.customErrorContainer: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFF760012) // red-700
} else {
Color(0xFF940821) // red-600
}
/** /**
* Main color scheme for list items, uses onPrimaryContainer color for leading and trailing icons. * Main color scheme for list items, uses onPrimaryContainer color for leading and trailing icons.
*/ */
@ -207,7 +273,24 @@ val ColorScheme.warningListItem: ListItemColors
containerColor = MaterialTheme.colorScheme.warning, containerColor = MaterialTheme.colorScheme.warning,
headlineColor = MaterialTheme.colorScheme.onPrimary, headlineColor = MaterialTheme.colorScheme.onPrimary,
leadingIconColor = MaterialTheme.colorScheme.onPrimary, leadingIconColor = MaterialTheme.colorScheme.onPrimary,
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f),
supportingTextColor = MaterialTheme.colorScheme.onPrimary,
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
disabledHeadlineColor = default.disabledHeadlineColor,
disabledLeadingIconColor = default.disabledLeadingIconColor,
disabledTrailingIconColor = default.disabledTrailingIconColor)
}
/** Color scheme for list items that should be styled as an error item. */
val ColorScheme.errorListItem: ListItemColors
@Composable
get() {
val default = ListItemDefaults.colors()
return ListItemColors(
containerColor = MaterialTheme.colorScheme.customError,
headlineColor = MaterialTheme.colorScheme.onPrimary,
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f),
supportingTextColor = MaterialTheme.colorScheme.onPrimary, supportingTextColor = MaterialTheme.colorScheme.onPrimary,
trailingIconColor = MaterialTheme.colorScheme.onPrimary, trailingIconColor = MaterialTheme.colorScheme.onPrimary,
disabledHeadlineColor = default.disabledHeadlineColor, disabledHeadlineColor = default.disabledHeadlineColor,
@ -231,11 +314,130 @@ val ColorScheme.secondaryButton: ButtonColors
@Composable @Composable
get() { get() {
val defaults = ButtonDefaults.buttonColors() val defaults = ButtonDefaults.buttonColors()
if (isSystemInDarkTheme()) {
return ButtonColors(
containerColor = Color(0xFF4B70CC), // blue-500
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
} else {
return ButtonColors(
containerColor = Color(0xFF5A82DC), // blue-400
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
}
}
val ColorScheme.errorButton: ButtonColors
@Composable
get() {
val defaults = ButtonDefaults.buttonColors()
if (isSystemInDarkTheme()) {
return ButtonColors( return ButtonColors(
containerColor = Color(0xFF6D94EC), // blue-400 containerColor = Color(0xFFB22D30), // red-500
contentColor = Color(0xFFFFFFFF), // white contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor, disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor) disabledContentColor = defaults.disabledContentColor)
} else {
return ButtonColors(
containerColor = Color(0xFFD04841), // red-400
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
}
}
val ColorScheme.warningButton: ButtonColors
@Composable
get() {
val defaults = ButtonDefaults.buttonColors()
if (isSystemInDarkTheme()) {
return ButtonColors(
containerColor = Color(0xFFD97917), // yellow-300
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
} else {
return ButtonColors(
containerColor = Color(0xFFE5993E), // yellow-200
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
}
}
val ColorScheme.defaultTextColor: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color.White
} else {
Color.Black
}
val ColorScheme.logoBackground: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFFFFFFFF) // white
} else {
Color(0xFF1F1E1E)
}
val ColorScheme.standaloneLogoDotEnabled: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFFFFFFFF)
} else {
Color(0xFF000000)
}
val ColorScheme.standaloneLogoDotDisabled: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0x66FFFFFF)
} else {
Color(0x661F1E1E)
}
val ColorScheme.onBackgroundLogoDotEnabled: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFF141414)
} else {
Color(0xFFFFFFFF)
}
val ColorScheme.onBackgroundLogoDotDisabled: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0x66141414)
} else {
Color(0x66FFFFFF)
}
val ColorScheme.exitNodeToggleButton: ButtonColors
@Composable
get() {
val defaults = ButtonDefaults.buttonColors()
return if (isSystemInDarkTheme()) {
ButtonColors(
containerColor = Color(0xFF444342), // grey-600
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
} else {
ButtonColors(
containerColor = Color(0xFFEDEBEA), // grey-300
contentColor = Color(0xFF000000), // black
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
}
} }
val ColorScheme.disabled: Color val ColorScheme.disabled: Color
@ -245,16 +447,15 @@ val ColorScheme.disabled: Color
val ColorScheme.searchBarColors: TextFieldColors val ColorScheme.searchBarColors: TextFieldColors
@Composable @Composable
get() { get() {
val defaults = OutlinedTextFieldDefaults.colors()
return OutlinedTextFieldDefaults.colors( return OutlinedTextFieldDefaults.colors(
focusedLeadingIconColor = MaterialTheme.colorScheme.onSurface, focusedLeadingIconColor = MaterialTheme.colorScheme.onSurface,
unfocusedLeadingIconColor = MaterialTheme.colorScheme.onSurface, unfocusedLeadingIconColor = MaterialTheme.colorScheme.onSurface,
focusedTextColor = MaterialTheme.colorScheme.onSurface, focusedTextColor = MaterialTheme.colorScheme.onSurface,
unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledTextColor = MaterialTheme.colorScheme.onSurfaceVariant, disabledTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.background, focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
unfocusedContainerColor = MaterialTheme.colorScheme.background, unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
disabledContainerColor = MaterialTheme.colorScheme.background, disabledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
focusedBorderColor = Color.Transparent, focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent) unfocusedBorderColor = Color.Transparent)
} }

@ -0,0 +1,24 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import com.tailscale.ipn.ui.model.Ipn
class AdvertisedRoutesHelper {
companion object {
fun exitNodeOnFromPrefs(prefs: Ipn.Prefs): Boolean {
var v4 = false
var v6 = false
prefs.AdvertiseRoutes?.forEach {
if (it == "0.0.0.0/0") {
v4 = true
}
if (it == "::/0") {
v6 = true
}
}
return v4 && v6
}
}
}

@ -9,13 +9,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.App import com.tailscale.ipn.UninitializedApp
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
object AndroidTVUtil { object AndroidTVUtil {
fun isAndroidTV(): Boolean { fun isAndroidTV(): Boolean {
return (App.appInstance.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEVISION) || val pm = UninitializedApp.get().packageManager
App.appInstance.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)) return (pm.hasSystemFeature(@Suppress("deprecation") PackageManager.FEATURE_TELEVISION) ||
pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
} }
} }

@ -4,6 +4,7 @@
package com.tailscale.ipn.ui.util package com.tailscale.ipn.ui.util
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@ -24,9 +25,15 @@ import com.tailscale.ipn.ui.theme.titledListItem
@Composable @Composable
fun ClipboardValueView(value: String, title: String? = null, subtitle: String? = null) { fun ClipboardValueView(value: String, title: String? = null, subtitle: String? = null) {
val localClipboardManager = LocalClipboardManager.current val localClipboardManager = LocalClipboardManager.current
val modifier =
Modifier.focusable()
.clickable {
localClipboardManager.setText(AnnotatedString(value))
}
ListItem( ListItem(
colors = MaterialTheme.colorScheme.titledListItem, colors = MaterialTheme.colorScheme.titledListItem,
modifier = Modifier.clickable { localClipboardManager.setText(AnnotatedString(value)) }, modifier = modifier,
overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } }, overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } },
headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) }, headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) },
supportingContent = supportingContent =

@ -0,0 +1,53 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.on
sealed class ConnectionMode {
class NotConnected : ConnectionMode()
class Derp(val relayName: String) : ConnectionMode()
class Direct : ConnectionMode()
@Composable
fun titleString(): String {
return when (this) {
is NotConnected -> stringResource(id = R.string.not_connected)
is Derp -> stringResource(R.string.relayed_connection, relayName)
is Direct -> stringResource(R.string.direct_connection)
}
}
fun contentKey(): String {
return when (this) {
is NotConnected -> "NotConnected"
is Derp -> "Derp($relayName)"
is Direct -> "Direct"
}
}
fun iconDrawable(): Int {
return when (this) {
is NotConnected -> R.drawable.xmark_circle
is Derp -> R.drawable.link_off
is Direct -> R.drawable.link
}
}
@Composable
fun color(): Color {
return when (this) {
is NotConnected -> MaterialTheme.colorScheme.onPrimary
is Derp -> MaterialTheme.colorScheme.error
is Direct -> MaterialTheme.colorScheme.on
}
}
}

@ -3,7 +3,7 @@
package com.tailscale.ipn.ui.util package com.tailscale.ipn.ui.util
class DisplayAddress(val ip: String) { class DisplayAddress(ip: String) {
enum class addrType { enum class addrType {
V4, V4,
V6, V6,

@ -0,0 +1,34 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import android.Manifest
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
data class InstalledApp(val name: String, val packageName: String)
class InstalledAppsManager(
val packageManager: PackageManager,
) {
fun fetchInstalledApps(): List<InstalledApp> {
return packageManager
.getInstalledApplications(PackageManager.GET_META_DATA)
.filter(appIsIncluded)
.map {
InstalledApp(
name = it.loadLabel(packageManager).toString(),
packageName = it.packageName,
)
}
.sortedBy { it.name }
}
private val appIsIncluded: (ApplicationInfo) -> Boolean = { app ->
app.packageName != "com.tailscale.ipn" &&
// Only show apps that can access the Internet
packageManager.checkPermission(Manifest.permission.INTERNET, app.packageName) ==
PackageManager.PERMISSION_GRANTED
}
}

@ -4,6 +4,7 @@
package com.tailscale.ipn.ui.util package com.tailscale.ipn.ui.util
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -41,7 +42,8 @@ object Lists {
title: String, title: String,
bottomPadding: Dp = 0.dp, bottomPadding: Dp = 0.dp,
style: TextStyle = MaterialTheme.typography.titleMedium, style: TextStyle = MaterialTheme.typography.titleMedium,
fontWeight: FontWeight? = null fontWeight: FontWeight? = null,
focusable: Boolean = false
) { ) {
Box( Box(
modifier = modifier =
@ -50,7 +52,8 @@ object Lists {
Text( Text(
title, title,
modifier = modifier =
Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding), Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding)
.focusable(focusable),
style = style, style = style,
fontWeight = fontWeight) fontWeight = fontWeight)
} }

@ -12,9 +12,11 @@ import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.view.TailscaleLogoView import com.tailscale.ipn.ui.view.TailscaleLogoView
@ -39,7 +41,7 @@ object LoadingIndicator {
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
content() content()
val isLoading = loading.collectAsState().value val isLoading by loading.collectAsState()
if (isLoading) { if (isLoading) {
Box(Modifier.clickable {}.matchParentSize().background(Color.Gray.copy(alpha = 0.0f))) Box(Modifier.clickable {}.matchParentSize().background(Color.Gray.copy(alpha = 0.0f)))
@ -53,7 +55,8 @@ object LoadingIndicator {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally) { horizontalAlignment = Alignment.CenterHorizontally) {
TailscaleLogoView(true, Modifier.size(72.dp)) TailscaleLogoView(
true, usesOnBackgroundColors = false, Modifier.size(72.dp).alpha(0.4f))
} }
} }
} }

@ -3,6 +3,8 @@
package com.tailscale.ipn.ui.util package com.tailscale.ipn.ui.util
import androidx.compose.ui.util.fastAny
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.model.UserID import com.tailscale.ipn.ui.model.UserID
@ -19,21 +21,41 @@ class PeerCategorizer {
val selfNode = netmap.SelfNode val selfNode = netmap.SelfNode
var grouped = mutableMapOf<UserID, MutableList<Tailcfg.Node>>() var grouped = mutableMapOf<UserID, MutableList<Tailcfg.Node>>()
val mdm = MDMSettings.hiddenNetworkDevices.flow.value.value
val hideMyDevices = mdm?.contains("current-user") ?: false
val hideOtherDevices = mdm?.contains("other-users") ?: false
val hideTaggedDevices = mdm?.contains("tagged-devices") ?: false
val me = netmap.currentUserProfile()
for (peer in (peers + selfNode)) { for (peer in (peers + selfNode)) {
// (jonathan) TODO: MDM -> There are a number of MDM settings to hide devices from the user
// (jonathan) TODO: MDM -> currentUser, otherUsers, taggedDevices
val userId = peer.User val userId = peer.User
val profile = netmap.userProfile(userId)
// Mullvad nodes should not be shown in the peer list
if (peer.isMullvadNode) {
continue
}
// Hide devices based on MDM settings
if (hideMyDevices && userId == me?.ID) {
continue
}
if (hideOtherDevices && userId != me?.ID) {
continue
}
if (hideTaggedDevices && (profile?.isTaggedDevice() == true)) {
continue
}
// Mullvad based nodes should not be shown in the peer list
if (!peer.isMullvadNode) {
if (!grouped.containsKey(userId)) { if (!grouped.containsKey(userId)) {
grouped[userId] = mutableListOf() grouped[userId] = mutableListOf()
} }
grouped[userId]?.add(peer) grouped[userId]?.add(peer)
} }
}
val me = netmap.currentUserProfile()
peerSets = peerSets =
grouped grouped
@ -90,7 +112,10 @@ class PeerCategorizer {
} }
val matchingPeers = val matchingPeers =
peers.filter { it.displayName.contains(searchTerm, ignoreCase = true) } peers.filter {
it.displayName.contains(searchTerm, ignoreCase = true) ||
(it.Addresses ?: emptyList()).fastAny { addr -> addr.contains(searchTerm) }
}
if (matchingPeers.isNotEmpty()) { if (matchingPeers.isNotEmpty()) {
PeerSet(user, matchingPeers) PeerSet(user, matchingPeers)
} else { } else {

@ -4,11 +4,15 @@
package com.tailscale.ipn.ui.util package com.tailscale.ipn.ui.util
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.util.TSLog
import java.time.Duration
import java.time.Instant import java.time.Instant
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Date import java.util.Date
object TimeUtil { object TimeUtil {
val TAG = "TimeUtil"
fun keyExpiryFromGoTime(goTime: String?): ComposableStringFormatter { fun keyExpiryFromGoTime(goTime: String?): ComposableStringFormatter {
val time = goTime ?: return ComposableStringFormatter(R.string.empty) val time = goTime ?: return ComposableStringFormatter(R.string.empty)
@ -70,10 +74,51 @@ object TimeUtil {
return Date.from(i) return Date.from(i)
} }
// Returns true if the given time is within 24 hours from now or in the past. // Returns true if the given Go time string is in the past, or will occur within the given
fun isWithin24Hours(goTime: String): Boolean { // duration from now.
fun isWithinExpiryNotificationWindow(window: Duration, goTime: String): Boolean {
val expTime = epochMillisFromGoTime(goTime) val expTime = epochMillisFromGoTime(goTime)
val now = Instant.now().toEpochMilli() val now = Instant.now().toEpochMilli()
return (expTime - now) / 1000 < 86400 return (expTime - now) / 1000 < window.seconds
}
// Parses a Go duration string (e.g. "2h3.2m4s") and returns a Java Duration object.
// Returns null if the input string is not a valid Go duration or contains
// units other than y,w,d,h,m,s (ms and us are explicitly not supported).
fun duration(goDuration: String): Duration? {
if (goDuration.contains("ms") || goDuration.contains("us")) {
return null
}
var duration = 0.0
var valStr = ""
for (c in goDuration) {
// Scan digits and decimal points
if (c.isDigit() || c == '.') {
valStr += c
} else {
try {
val durationFragment = valStr.toDouble()
duration +=
when (c) {
'y' -> durationFragment * 31536000.0 // 365 days
'w' -> durationFragment * 604800.0
'd' -> durationFragment * 86400.0
'h' -> durationFragment * 3600.0
'm' -> durationFragment * 60.0
's' -> durationFragment
else -> {
TSLog.e(TAG, "Invalid duration string: $goDuration")
return null
}
}
} catch (e: NumberFormatException) {
TSLog.e(TAG, "Invalid duration string: $goDuration")
return null
}
valStr = ""
}
}
return Duration.ofSeconds(duration.toLong())
} }
} }

@ -3,8 +3,8 @@
package com.tailscale.ipn.ui.view package com.tailscale.ipn.ui.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
@ -22,33 +22,41 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.BuildConfig import com.tailscale.ipn.BuildConfig
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.logoBackground
@Composable @Composable
fun AboutView(backToSettings: BackNavigation) { fun AboutView(backToSettings: BackNavigation) {
Scaffold(topBar = { Header(R.string.about_view_title, onBack = backToSettings) }) { innerPadding val localClipboardManager = LocalClipboardManager.current
Scaffold(topBar = { Header(R.string.about_view_header, onBack = backToSettings) }) { innerPadding
-> ->
Column( Column(
verticalArrangement = verticalArrangement =
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically), Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(innerPadding).verticalScroll(rememberScrollState())) { modifier =
Image( Modifier.fillMaxWidth()
.fillMaxHeight()
.padding(innerPadding)
.verticalScroll(rememberScrollState())) {
TailscaleLogoView(
usesOnBackgroundColors = true,
modifier = modifier =
Modifier.width(100.dp) Modifier.width(100.dp)
.height(100.dp) .height(100.dp)
.clip(RoundedCornerShape(50)) .clip(RoundedCornerShape(50))
.background(MaterialTheme.colorScheme.onSurface) .background(MaterialTheme.colorScheme.logoBackground)
.padding(15.dp), .padding(25.dp))
painter = painterResource(id = R.drawable.androidicon),
contentDescription = stringResource(R.string.app_icon_content_description))
Column( Column(
verticalArrangement = verticalArrangement =
@ -59,6 +67,10 @@ fun AboutView(backToSettings: BackNavigation) {
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
fontSize = MaterialTheme.typography.titleLarge.fontSize) fontSize = MaterialTheme.typography.titleLarge.fontSize)
Text( Text(
modifier =
Modifier.clickable {
localClipboardManager.setText(AnnotatedString(BuildConfig.VERSION_NAME))
},
text = "${stringResource(R.string.version)} ${BuildConfig.VERSION_NAME}", text = "${stringResource(R.string.version)} ${BuildConfig.VERSION_NAME}",
fontWeight = MaterialTheme.typography.bodyMedium.fontWeight, fontWeight = MaterialTheme.typography.bodyMedium.fontWeight,
fontSize = MaterialTheme.typography.bodyMedium.fontSize) fontSize = MaterialTheme.typography.bodyMedium.fontSize)
@ -78,3 +90,9 @@ fun AboutView(backToSettings: BackNavigation) {
} }
} }
} }
@Preview
@Composable
fun AboutPreview() {
AboutView({})
}

@ -17,9 +17,11 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.annotation.ExperimentalCoilApi import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
@OptIn(ExperimentalCoilApi::class) @OptIn(ExperimentalCoilApi::class)
@ -34,7 +36,10 @@ fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50, action: (() -> Unit)
indication = rememberRipple(bounded = false), indication = rememberRipple(bounded = false),
onClick = action) onClick = action)
} }
Icon(imageVector = Icons.Default.Person, contentDescription = null, modifier = modifier) Icon(
imageVector = Icons.Default.Person,
contentDescription = stringResource(R.string.settings_title),
modifier = modifier)
profile?.UserProfile?.ProfilePicURL?.let { url -> profile?.UserProfile?.ProfilePicURL?.let { url ->
AsyncImage(model = url, modifier = Modifier.size((size * 1.2f).dp), contentDescription = null) AsyncImage(model = url, modifier = Modifier.size((size * 1.2f).dp), contentDescription = null)

@ -14,6 +14,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -22,18 +23,21 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.defaultTextColor
import com.tailscale.ipn.ui.theme.link import com.tailscale.ipn.ui.theme.link
import com.tailscale.ipn.ui.util.ClipboardValueView import com.tailscale.ipn.ui.util.ClipboardValueView
import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.BugReportViewModel import com.tailscale.ipn.ui.viewModel.BugReportViewModel
@Composable @Composable
fun BugReportView(backToSettings: BackNavigation, model: BugReportViewModel = viewModel()) { fun BugReportView(backToSettings: BackNavigation, model: BugReportViewModel = viewModel()) {
val handler = LocalUriHandler.current val handler = LocalUriHandler.current
val bugReportID = model.bugReportID.collectAsState().value val bugReportID by model.bugReportID.collectAsState()
Scaffold(topBar = { Header(R.string.bug_report_title, onBack = backToSettings) }) { innerPadding Scaffold(topBar = { Header(R.string.bug_report_title, onBack = backToSettings) }) { innerPadding
-> ->
@ -60,7 +64,9 @@ fun BugReportView(backToSettings: BackNavigation, model: BugReportViewModel = vi
@Composable @Composable
fun contactText(): AnnotatedString { fun contactText(): AnnotatedString {
val annotatedString = buildAnnotatedString { val annotatedString = buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
append(stringResource(id = R.string.bug_report_instructions_prefix)) append(stringResource(id = R.string.bug_report_instructions_prefix))
}
pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL) pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL)
withStyle( withStyle(
@ -72,7 +78,17 @@ fun contactText(): AnnotatedString {
} }
pop() pop()
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
append(stringResource(id = R.string.bug_report_instructions_suffix)) append(stringResource(id = R.string.bug_report_instructions_suffix))
} }
}
return annotatedString return annotatedString
} }
@Preview
@Composable
fun BugReportPreview() {
val vm = BugReportViewModel()
vm.bugReportID.set("12345678ABCDEF-12345678ABCDEF")
BugReportView({}, vm)
}

@ -6,19 +6,12 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -46,19 +39,3 @@ fun OpenURLButton(title: String, url: String) {
) )
} }
} }
@Composable
fun ClearButton(onClick: () -> Unit) {
IconButton(onClick = onClick, modifier = Modifier.size(24.dp)) {
Icon(Icons.Outlined.Clear, null)
}
}
@Composable
fun CloseButton() {
val focusManager = LocalFocusManager.current
IconButton(onClick = { focusManager.clearFocus() }, modifier = Modifier.size(24.dp)) {
Icon(Icons.Outlined.Close, null)
}
}

@ -0,0 +1,157 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.LoginWithAuthKeyViewModel
import com.tailscale.ipn.ui.viewModel.LoginWithCustomControlURLViewModel
data class LoginViewStrings(
var title: String,
var explanation: String,
var inputTitle: String,
var placeholder: String,
)
@Composable
fun LoginWithCustomControlURLView(
onNavigateHome: BackNavigation,
backToSettings: BackNavigation,
viewModel: LoginWithCustomControlURLViewModel = LoginWithCustomControlURLViewModel()
) {
Scaffold(
topBar = {
Header(
R.string.add_account,
onBack = backToSettings,
)
}) { innerPadding ->
val error by viewModel.errorDialog.collectAsState()
val strings =
LoginViewStrings(
title = stringResource(id = R.string.custom_control_menu),
explanation = stringResource(id = R.string.custom_control_menu_desc),
inputTitle = stringResource(id = R.string.custom_control_url_title),
placeholder = stringResource(id = R.string.custom_control_placeholder),
)
error?.let { ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) }) }
LoginView(
innerPadding = innerPadding,
strings = strings,
onSubmitAction = { viewModel.setControlURL(it, onNavigateHome) })
}
}
@Composable
fun LoginWithAuthKeyView(
onNavigateHome: BackNavigation,
backToSettings: BackNavigation,
viewModel: LoginWithAuthKeyViewModel = LoginWithAuthKeyViewModel()
) {
Scaffold(
topBar = {
Header(
R.string.add_account,
onBack = backToSettings,
)
}) { innerPadding ->
val error by viewModel.errorDialog.collectAsState()
val strings =
LoginViewStrings(
title = stringResource(id = R.string.auth_key_title),
explanation = stringResource(id = R.string.auth_key_explanation),
inputTitle = stringResource(id = R.string.auth_key_input_title),
placeholder = stringResource(id = R.string.auth_key_placeholder),
)
// Show the error overlay if need be
error?.let { ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) }) }
LoginView(
innerPadding = innerPadding,
strings = strings,
onSubmitAction = { viewModel.setAuthKey(it, onNavigateHome) })
}
}
@Composable
fun LoginView(
innerPadding: PaddingValues = PaddingValues(16.dp),
strings: LoginViewStrings,
onSubmitAction: (String) -> Unit,
) {
var textVal by remember { mutableStateOf("") }
Column(
modifier =
Modifier.padding(innerPadding)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)) {
ListItem(
colors = MaterialTheme.colorScheme.listItem,
headlineContent = { Text(text = strings.title) },
supportingContent = { Text(text = strings.explanation) })
ListItem(
colors = MaterialTheme.colorScheme.listItem,
headlineContent = { Text(text = strings.inputTitle) },
supportingContent = {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
colors =
TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent),
textStyle = MaterialTheme.typography.bodyMedium,
value = textVal,
onValueChange = { textVal = it },
placeholder = {
Text(strings.placeholder, style = MaterialTheme.typography.bodySmall)
},
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None)
)
})
ListItem(
colors = MaterialTheme.colorScheme.listItem,
headlineContent = {
Box(modifier = Modifier.fillMaxWidth()) {
Button(
onClick = { onSubmitAction(textVal) },
content = { Text(stringResource(id = R.string.add_account_short)) })
}
})
}
}

@ -14,18 +14,22 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.DnsType import com.tailscale.ipn.ui.model.DnsType
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.ClipboardValueView import com.tailscale.ipn.ui.util.ClipboardValueView
import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.DNSEnablementState import com.tailscale.ipn.ui.viewModel.DNSEnablementState
import com.tailscale.ipn.ui.viewModel.DNSSettingsViewModel import com.tailscale.ipn.ui.viewModel.DNSSettingsViewModel
import com.tailscale.ipn.ui.viewModel.DNSSettingsViewModelFactory import com.tailscale.ipn.ui.viewModel.DNSSettingsViewModelFactory
@ -38,7 +42,7 @@ fun DNSSettingsView(
backToSettings: BackNavigation, backToSettings: BackNavigation,
model: DNSSettingsViewModel = viewModel(factory = DNSSettingsViewModelFactory()) model: DNSSettingsViewModel = viewModel(factory = DNSSettingsViewModelFactory())
) { ) {
val state: DNSEnablementState = model.enablementState.collectAsState().value val state: DNSEnablementState by model.enablementState.collectAsState()
val resolvers = model.dnsConfig.collectAsState().value?.Resolvers ?: emptyList() val resolvers = model.dnsConfig.collectAsState().value?.Resolvers ?: emptyList()
val domains = model.dnsConfig.collectAsState().value?.Domains ?: emptyList() val domains = model.dnsConfig.collectAsState().value?.Domains ?: emptyList()
val routes: List<ViewableRoute> = val routes: List<ViewableRoute> =
@ -46,6 +50,7 @@ fun DNSSettingsView(
entry.value?.let { resolvers -> ViewableRoute(name = entry.key, resolvers) } ?: run { null } entry.value?.let { resolvers -> ViewableRoute(name = entry.key, resolvers) } ?: run { null }
} ?: emptyList() } ?: emptyList()
val useCorpDNS = Notifier.prefs.collectAsState().value?.CorpDNS == true val useCorpDNS = Notifier.prefs.collectAsState().value?.CorpDNS == true
val dnsSettingsMDMDisposition by MDMSettings.useTailscaleDNSSettings.flow.collectAsState()
Scaffold(topBar = { Header(R.string.dns_settings, onBack = backToSettings) }) { innerPadding -> Scaffold(topBar = { Header(R.string.dns_settings, onBack = backToSettings) }) { innerPadding ->
LoadingIndicator.Wrap { LoadingIndicator.Wrap {
@ -64,6 +69,7 @@ fun DNSSettingsView(
}, },
supportingContent = { Text(stringResource(state.caption)) }) supportingContent = { Text(stringResource(state.caption)) })
if (!dnsSettingsMDMDisposition.value.hiddenFromUser) {
Lists.ItemDivider() Lists.ItemDivider()
Setting.Switch( Setting.Switch(
R.string.use_ts_dns, R.string.use_ts_dns,
@ -73,6 +79,7 @@ fun DNSSettingsView(
model.toggleCorpDNS { LoadingIndicator.stop() } model.toggleCorpDNS { LoadingIndicator.stop() }
}) })
} }
}
if (resolvers.isNotEmpty()) { if (resolvers.isNotEmpty()) {
item("resolversHeader") { Lists.SectionDivider(stringResource(R.string.resolvers)) } item("resolversHeader") { Lists.SectionDivider(stringResource(R.string.resolvers)) }
@ -99,3 +106,11 @@ fun DNSSettingsView(
} }
} }
} }
@Preview
@Composable
fun DNSSettingsViewPreview() {
val vm = DNSSettingsViewModel()
vm.enablementState.set(DNSEnablementState.ENABLED)
DNSSettingsView(backToSettings = {}, vm)
}

@ -8,7 +8,9 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.AppTheme
enum class ErrorDialogType { enum class ErrorDialogType {
@ -17,7 +19,8 @@ enum class ErrorDialogType {
SWITCH_USER_FAILED, SWITCH_USER_FAILED,
ADD_PROFILE_FAILED, ADD_PROFILE_FAILED,
SHARE_DEVICE_NOT_CONNECTED, SHARE_DEVICE_NOT_CONNECTED,
SHARE_FAILED; SHARE_FAILED,
INVALID_AUTH_KEY;
val message: Int val message: Int
get() { get() {
@ -28,6 +31,7 @@ enum class ErrorDialogType {
ADD_PROFILE_FAILED -> R.string.add_profile_failed ADD_PROFILE_FAILED -> R.string.add_profile_failed
SHARE_DEVICE_NOT_CONNECTED -> R.string.share_device_not_connected SHARE_DEVICE_NOT_CONNECTED -> R.string.share_device_not_connected
SHARE_FAILED -> R.string.taildrop_share_failed SHARE_FAILED -> R.string.taildrop_share_failed
INVALID_AUTH_KEY -> R.string.invalidAuthKey
} }
} }
@ -40,6 +44,7 @@ enum class ErrorDialogType {
ADD_PROFILE_FAILED -> R.string.add_profile_failed_title ADD_PROFILE_FAILED -> R.string.add_profile_failed_title
SHARE_DEVICE_NOT_CONNECTED -> R.string.share_device_not_connected_title SHARE_DEVICE_NOT_CONNECTED -> R.string.share_device_not_connected_title
SHARE_FAILED -> R.string.taildrop_share_failed_title SHARE_FAILED -> R.string.taildrop_share_failed_title
INVALID_AUTH_KEY -> R.string.invalidAuthKeyTitle
} }
} }
@ -59,6 +64,7 @@ fun ErrorDialog(
@StringRes buttonText: Int = R.string.ok, @StringRes buttonText: Int = R.string.ok,
onDismiss: () -> Unit = {} onDismiss: () -> Unit = {}
) { ) {
AppTheme {
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(text = stringResource(id = title)) }, title = { Text(text = stringResource(id = title)) },
@ -67,3 +73,10 @@ fun ErrorDialog(
PrimaryActionButton(onClick = onDismiss) { Text(text = stringResource(id = buttonText)) } PrimaryActionButton(onClick = onDismiss) { Text(text = stringResource(id = buttonText)) }
}) })
} }
}
@Preview
@Composable
fun ErrorDialogPreview() {
ErrorDialog(ErrorDialogType.LOGOUT_FAILED)
}

@ -17,10 +17,14 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHide
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.disabledListItem import com.tailscale.ipn.ui.theme.disabledListItem
import com.tailscale.ipn.ui.theme.listItem import com.tailscale.ipn.ui.theme.listItem
@ -31,6 +35,7 @@ import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
import com.tailscale.ipn.ui.viewModel.selected import com.tailscale.ipn.ui.viewModel.selected
import kotlinx.coroutines.flow.MutableStateFlow
@Composable @Composable
fun ExitNodePicker( fun ExitNodePicker(
@ -40,23 +45,41 @@ fun ExitNodePicker(
LoadingIndicator.Wrap { LoadingIndicator.Wrap {
Scaffold(topBar = { Header(R.string.choose_exit_node, onBack = nav.onNavigateBackHome) }) { Scaffold(topBar = { Header(R.string.choose_exit_node, onBack = nav.onNavigateBackHome) }) {
innerPadding -> innerPadding ->
val tailnetExitNodes = model.tailnetExitNodes.collectAsState().value val tailnetExitNodes by model.tailnetExitNodes.collectAsState()
val mullvadExitNodesByCountryCode = model.mullvadExitNodesByCountryCode.collectAsState().value val mullvadExitNodesByCountryCode by model.mullvadExitNodesByCountryCode.collectAsState()
val mullvadExitNodeCount = model.mullvadExitNodeCount.collectAsState().value val mullvadExitNodeCount by model.mullvadExitNodeCount.collectAsState()
val anyActive = model.anyActive.collectAsState() val anyActive by model.anyActive.collectAsState()
val shouldShowMullvadInfo by model.shouldShowMullvadInfo.collectAsState()
val allowLANAccess = Notifier.prefs.collectAsState().value?.ExitNodeAllowLANAccess == true val allowLANAccess = Notifier.prefs.collectAsState().value?.ExitNodeAllowLANAccess == true
val showRunAsExitNode by MDMSettings.runExitNode.flow.collectAsState()
val allowLanAccessMDMDisposition by MDMSettings.exitNodeAllowLANAccess.flow.collectAsState()
val managedByOrganization by model.managedByOrganization.collectAsState()
val forcedExitNodeId = MDMSettings.exitNodeID.flow.collectAsState().value.value
LazyColumn(modifier = Modifier.padding(innerPadding)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "header") { item(key = "header") {
if (forcedExitNodeId != null) {
Text(
text =
managedByOrganization.value?.let {
stringResource(R.string.exit_node_mdm_orgname, it)
} ?: stringResource(R.string.exit_node_mdm),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp))
} else {
ExitNodeItem( ExitNodeItem(
model, model,
ExitNodePickerViewModel.ExitNode( ExitNodePickerViewModel.ExitNode(
label = stringResource(R.string.none), label = stringResource(R.string.none),
online = true, online = MutableStateFlow(true),
selected = !anyActive.value, selected = !anyActive,
)) ))
}
if (showRunAsExitNode.value == ShowHide.Show) {
Lists.ItemDivider() Lists.ItemDivider()
RunAsExitNodeItem(nav = nav, viewModel = model) RunAsExitNodeItem(nav = nav, viewModel = model, anyActive)
}
} }
item(key = "divider1") { Lists.SectionDivider() } item(key = "divider1") { Lists.SectionDivider() }
@ -69,9 +92,14 @@ fun ExitNodePicker(
MullvadItem( MullvadItem(
nav, mullvadExitNodesByCountryCode.size, mullvadExitNodesByCountryCode.selected) nav, mullvadExitNodesByCountryCode.size, mullvadExitNodesByCountryCode.selected)
} }
} else if (shouldShowMullvadInfo) {
item(key = "mullvad_info") {
Lists.SectionDivider()
MullvadInfoItem(nav)
}
} }
// TODO: make sure this actually works, and if not, leave it out for now if (!allowLanAccessMDMDisposition.value.hiddenFromUser) {
item(key = "allowLANAccess") { item(key = "allowLANAccess") {
Lists.SectionDivider() Lists.SectionDivider()
@ -84,33 +112,38 @@ fun ExitNodePicker(
} }
} }
} }
}
@Composable @Composable
fun ExitNodeItem( fun ExitNodeItem(
viewModel: ExitNodePickerViewModel, viewModel: ExitNodePickerViewModel,
node: ExitNodePickerViewModel.ExitNode, node: ExitNodePickerViewModel.ExitNode,
) { ) {
val online by node.online.collectAsState()
val isRunningExitNode = viewModel.isRunningExitNode.collectAsState().value
val forcedExitNodeId = MDMSettings.exitNodeID.flow.collectAsState().value.value
Box { Box {
var modifier: Modifier = Modifier var modifier: Modifier = Modifier
if (node.online) { if (online && !isRunningExitNode && forcedExitNodeId == null) {
modifier = modifier.clickable { viewModel.setExitNode(node) } modifier = modifier.clickable { viewModel.setExitNode(node) }
} }
ListItem( ListItem(
modifier = modifier, modifier = modifier,
colors = colors =
if (node.online) MaterialTheme.colorScheme.listItem if (online && !isRunningExitNode) MaterialTheme.colorScheme.listItem
else MaterialTheme.colorScheme.disabledListItem, else MaterialTheme.colorScheme.disabledListItem,
headlineContent = { headlineContent = {
Text(node.city.ifEmpty { node.label }, style = MaterialTheme.typography.bodyMedium) Text(node.city.ifEmpty { node.label }, style = MaterialTheme.typography.bodyMedium)
}, },
supportingContent = { supportingContent = {
if (!node.online) if (!online)
Text(stringResource(R.string.offline), style = MaterialTheme.typography.bodyMedium) Text(stringResource(R.string.offline), style = MaterialTheme.typography.bodyMedium)
}, },
trailingContent = { trailingContent = {
Row { Row {
if (node.selected) { if (node.selected) {
Icon(Icons.Outlined.Check, contentDescription = stringResource(R.string.selected)) Icon(Icons.Outlined.Check, null)
} }
} }
}) })
@ -134,19 +167,48 @@ fun MullvadItem(nav: ExitNodePickerNav, count: Int, selected: Boolean) {
}, },
trailingContent = { trailingContent = {
if (selected) { if (selected) {
Icon(Icons.Outlined.Check, contentDescription = stringResource(R.string.selected)) Icon(Icons.Outlined.Check, null)
}
})
}
} }
@Composable
fun MullvadInfoItem(nav: ExitNodePickerNav) {
Box {
ListItem(
modifier = Modifier.clickable { nav.onNavigateToMullvadInfo() },
headlineContent = {
Text(
stringResource(R.string.mullvad_exit_nodes),
style = MaterialTheme.typography.bodyMedium)
},
supportingContent = {
Text(
stringResource(R.string.enable_in_the_admin_console),
style = MaterialTheme.typography.bodyMedium)
}) })
} }
} }
@Composable @Composable
fun RunAsExitNodeItem(nav: ExitNodePickerNav, viewModel: ExitNodePickerViewModel) { fun RunAsExitNodeItem(
nav: ExitNodePickerNav,
viewModel: ExitNodePickerViewModel,
anyActive: Boolean
) {
val isRunningExitNode = viewModel.isRunningExitNode.collectAsState().value val isRunningExitNode = viewModel.isRunningExitNode.collectAsState().value
Box { Box {
var modifier: Modifier = Modifier
if (!anyActive) {
modifier = modifier.clickable { nav.onNavigateToRunAsExitNode() }
}
ListItem( ListItem(
modifier = Modifier.clickable { nav.onNavigateToRunAsExitNode() }, modifier = modifier,
colors =
if (!anyActive) MaterialTheme.colorScheme.listItem
else MaterialTheme.colorScheme.disabledListItem,
headlineContent = { headlineContent = {
Text( Text(
stringResource(id = R.string.run_as_exit_node), stringResource(id = R.string.run_as_exit_node),

@ -0,0 +1,99 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Health
import com.tailscale.ipn.ui.theme.success
import com.tailscale.ipn.ui.viewModel.HealthViewModel
@Composable
fun HealthView(backToSettings: BackNavigation, model: HealthViewModel = viewModel()) {
val warnings by model.warnings.collectAsState()
Scaffold(topBar = { Header(titleRes = R.string.health_warnings, onBack = backToSettings) }) {
innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
if (warnings.isEmpty()) {
item("allGood") {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.Top),
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)) {
Icon(
painter = painterResource(id = R.drawable.check_circle),
modifier = Modifier.size(48.dp),
contentDescription = "A green checkmark",
tint = MaterialTheme.colorScheme.success)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement =
Arrangement.spacedBy(2.dp, alignment = Alignment.CenterVertically),
modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.no_issues_found),
fontSize = MaterialTheme.typography.titleMedium.fontSize,
fontWeight = MaterialTheme.typography.titleMedium.fontWeight)
Text(
text = stringResource(R.string.tailscale_is_operating_normally),
color = MaterialTheme.colorScheme.secondary)
}
}
}
}
items(warnings) { HealthWarningView(it) }
}
}
}
@Composable
fun HealthWarningView(warning: Health.UnhealthyState) {
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainerLow)) {
Box(
modifier =
Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
.fillMaxWidth()) {
ListItem(
colors = warning.Severity.listItemColors(),
headlineContent = {
if (warning.Title.isNotEmpty()) {
Text(
warning.Title,
style = MaterialTheme.typography.titleMedium,
)
}
},
supportingContent = {
Text(warning.Text, style = MaterialTheme.typography.bodyMedium)
})
}
}
}

@ -3,7 +3,6 @@
package com.tailscale.ipn.ui.view package com.tailscale.ipn.ui.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -17,15 +16,17 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.AppTheme
@Composable @Composable
fun IntroView(onContinue: () -> Unit) { fun IntroView(onContinue: () -> Unit) {
@ -33,10 +34,7 @@ fun IntroView(onContinue: () -> Unit) {
modifier = Modifier.fillMaxHeight().fillMaxWidth().verticalScroll(rememberScrollState()), modifier = Modifier.fillMaxHeight().fillMaxWidth().verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center) { verticalArrangement = Arrangement.Center) {
Image( TailscaleLogoView(modifier = Modifier.width(60.dp).height(60.dp))
modifier = Modifier.width(80.dp).height(80.dp),
painter = painterResource(id = R.drawable.androidicon_light),
contentDescription = stringResource(R.string.app_icon_content_description))
Spacer(modifier = Modifier.height(40.dp)) Spacer(modifier = Modifier.height(40.dp))
Text( Text(
modifier = Modifier.padding(start = 40.dp, end = 40.dp, bottom = 40.dp), modifier = Modifier.padding(start = 40.dp, end = 40.dp, bottom = 40.dp),
@ -62,3 +60,9 @@ fun IntroView(onContinue: () -> Unit) {
} }
} }
} }
@Composable
@Preview
fun IntroViewPreview() {
AppTheme { Surface { IntroView({}) } }
}

@ -20,14 +20,18 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.AppTheme
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.LoginQRViewModel import com.tailscale.ipn.ui.viewModel.LoginQRViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -35,7 +39,8 @@ import com.tailscale.ipn.ui.viewModel.LoginQRViewModel
fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel()) { fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel()) {
Surface(color = MaterialTheme.colorScheme.scrim, modifier = Modifier.fillMaxSize()) { Surface(color = MaterialTheme.colorScheme.scrim, modifier = Modifier.fillMaxSize()) {
Dialog(onDismissRequest = onDismiss) { Dialog(onDismissRequest = onDismiss) {
val image = model.qrCode.collectAsState() val image by model.qrCode.collectAsState()
val numCode by model.numCode.collectAsState()
Column( Column(
modifier = modifier =
@ -54,15 +59,30 @@ fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel(
.background(MaterialTheme.colorScheme.onSurface) .background(MaterialTheme.colorScheme.onSurface)
.fillMaxWidth(), .fillMaxWidth(),
contentAlignment = Alignment.Center) { contentAlignment = Alignment.Center) {
image.value?.let { image?.let {
Image( Image(
bitmap = it, bitmap = it,
contentDescription = "Scan to login", contentDescription = "Scan to login",
modifier = Modifier.fillMaxSize()) modifier = Modifier.fillMaxSize())
} }
} }
numCode?.let { it ->
Text(
text = stringResource(R.string.enter_code_to_connect_to_tailnet, it),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface)
}
Button(onClick = onDismiss) { Text(text = stringResource(R.string.dismiss)) } Button(onClick = onDismiss) { Text(text = stringResource(R.string.dismiss)) }
} }
} }
} }
} }
@Composable
@Preview
fun LoginQRViewPreview() {
val vm = LoginQRViewModel()
vm.qrCode.set(vm.generateQRCode("https://tailscale.com", 200, 0))
vm.numCode.set("123456789")
AppTheme { LoginQRView({}, vm) }
}

@ -12,6 +12,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -24,7 +25,10 @@ import com.tailscale.ipn.ui.viewModel.IpnViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MDMSettingsDebugView(backToSettings: BackNavigation, model: IpnViewModel = viewModel()) { fun MDMSettingsDebugView(
backToSettings: BackNavigation,
@Suppress("UNUSED_PARAMETER") model: IpnViewModel = viewModel()
) {
Scaffold(topBar = { Header(R.string.current_mdm_settings, onBack = backToSettings) }) { Scaffold(topBar = { Header(R.string.current_mdm_settings, onBack = backToSettings) }) {
innerPadding -> innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
@ -38,7 +42,7 @@ fun MDMSettingsDebugView(backToSettings: BackNavigation, model: IpnViewModel = v
@Composable @Composable
fun MDMSettingView(setting: MDMSetting<*>) { fun MDMSettingView(setting: MDMSetting<*>) {
val value = setting.flow.collectAsState().value val value by setting.flow.collectAsState()
ListItem( ListItem(
headlineContent = { Text(setting.localizedTitle, maxLines = 3) }, headlineContent = { Text(setting.localizedTitle, maxLines = 3) },
supportingContent = { supportingContent = {
@ -49,7 +53,7 @@ fun MDMSettingView(setting: MDMSetting<*>) {
}, },
trailingContent = { trailingContent = {
Text( Text(
value.toString(), if (value.isSet) value.value.toString() else "[not set]",
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
maxLines = 1, maxLines = 1,
fontWeight = FontWeight.SemiBold) fontWeight = FontWeight.SemiBold)

@ -6,6 +6,8 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -26,19 +28,24 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowDropDown import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.Clear import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -47,8 +54,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -57,16 +67,23 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.tailscale.ipn.App
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHide
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.Permissions import com.tailscale.ipn.ui.model.Permissions
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.theme.customErrorContainer
import com.tailscale.ipn.ui.theme.disabled import com.tailscale.ipn.ui.theme.disabled
import com.tailscale.ipn.ui.theme.errorButton
import com.tailscale.ipn.ui.theme.errorListItem
import com.tailscale.ipn.ui.theme.exitNodeToggleButton
import com.tailscale.ipn.ui.theme.listItem import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.theme.minTextSize import com.tailscale.ipn.ui.theme.minTextSize
import com.tailscale.ipn.ui.theme.primaryListItem import com.tailscale.ipn.ui.theme.primaryListItem
@ -74,42 +91,72 @@ import com.tailscale.ipn.ui.theme.searchBarColors
import com.tailscale.ipn.ui.theme.secondaryButton import com.tailscale.ipn.ui.theme.secondaryButton
import com.tailscale.ipn.ui.theme.short import com.tailscale.ipn.ui.theme.short
import com.tailscale.ipn.ui.theme.surfaceContainerListItem import com.tailscale.ipn.ui.theme.surfaceContainerListItem
import com.tailscale.ipn.ui.theme.warningButton
import com.tailscale.ipn.ui.theme.warningListItem import com.tailscale.ipn.ui.theme.warningListItem
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.AutoResizingText import com.tailscale.ipn.ui.util.AutoResizingText
import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.IpnViewModel.NodeState
import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModel
// Navigation actions for the MainView // Navigation actions for the MainView
data class MainViewNavigation( data class MainViewNavigation(
val onNavigateToSettings: () -> Unit, val onNavigateToSettings: () -> Unit,
val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
val onNavigateToExitNodes: () -> Unit val onNavigateToExitNodes: () -> Unit,
val onNavigateToHealth: () -> Unit
) )
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewModel()) { fun MainView(
loginAtUrl: (String) -> Unit,
navigation: MainViewNavigation,
viewModel: MainViewModel
) {
val currentPingDevice by viewModel.pingViewModel.peer.collectAsState()
val healthIcon by viewModel.healthIcon.collectAsState()
LoadingIndicator.Wrap { LoadingIndicator.Wrap {
Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets -> Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets ->
Column( Column(
modifier = Modifier.fillMaxWidth().padding(paddingInsets), modifier = Modifier.fillMaxWidth().padding(paddingInsets),
verticalArrangement = Arrangement.Center) { verticalArrangement = Arrangement.Center) {
val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState).value // Assume VPN has been prepared for optimistic UI. Whether or not it has been prepared
val user = viewModel.loggedInUser.collectAsState(initial = null).value // cannot be known
val stateVal = viewModel.stateRes.collectAsState(initial = R.string.placeholder).value // until permission has been granted to prepare the VPN.
val isPrepared by viewModel.isVpnPrepared.collectAsState(initial = true)
val isOn by viewModel.vpnToggleState.collectAsState(initial = false)
val state by viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
val user by viewModel.loggedInUser.collectAsState(initial = null)
val stateVal by viewModel.stateRes.collectAsState(initial = R.string.placeholder)
val stateStr = stringResource(id = stateVal) val stateStr = stringResource(id = stateVal)
val netmap = viewModel.netmap.collectAsState(initial = null) val netmap by viewModel.netmap.collectAsState(initial = null)
val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState()
val disableToggle by MDMSettings.forceEnabled.flow.collectAsState()
val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false)
// Hide the header only on Android TV when the user needs to login
val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin)
ListItem( ListItem(
colors = MaterialTheme.colorScheme.surfaceContainerListItem, colors = MaterialTheme.colorScheme.surfaceContainerListItem,
leadingContent = { leadingContent = {
val isOn = viewModel.vpnToggleState.collectAsState(initial = false) if (!hideHeader) {
TintedSwitch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value) TintedSwitch(
onCheckedChange = {
if (!disableToggle.value) {
viewModel.toggleVpn()
}
},
enabled = !disableToggle.value,
checked = isOn)
}
}, },
headlineContent = { headlineContent = {
user?.NetworkProfile?.DomainName?.let { domain -> user?.NetworkProfile?.DomainName?.let { domain ->
@ -121,17 +168,39 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
} }
}, },
supportingContent = { supportingContent = {
if (!hideHeader) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = stateStr, style = MaterialTheme.typography.bodyMedium.short) Text(text = stateStr, style = MaterialTheme.typography.bodyMedium.short)
healthIcon?.let {
Spacer(modifier = Modifier.size(4.dp))
IconButton(
onClick = { navigation.onNavigateToHealth() },
modifier = Modifier.size(16.dp)) {
Icon(
painterResource(id = it),
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.error)
}
}
}
}
}, },
trailingContent = { trailingContent = {
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { Box(
modifier =
Modifier.weight(1f)
.focusable()
.clickable { navigation.onNavigateToSettings() }
.padding(8.dp),
contentAlignment = Alignment.CenterEnd) {
when (user) { when (user) {
null -> SettingsButton { navigation.onNavigateToSettings() } null -> SettingsButton { navigation.onNavigateToSettings() }
else -> else ->
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = modifier =
Modifier.size(42.dp).clip(CircleShape).clickable { Modifier.size(42.dp).clip(CircleShape).focusable().clickable {
navigation.onNavigateToSettings() navigation.onNavigateToSettings()
}) { }) {
Avatar(profile = user, size = 36) { Avatar(profile = user, size = 36) {
@ -147,10 +216,16 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
PromptPermissionsIfNecessary() PromptPermissionsIfNecessary()
ExpiryNotificationIfNecessary( viewModel.showVPNPermissionLauncherIfUnauthorized()
netmap = netmap.value, action = { viewModel.login {} })
ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) if (showKeyExpiry) {
ExpiryNotification(netmap = netmap, action = { viewModel.login() })
}
if (showExitNodePicker.value == ShowHide.Show) {
ExitNodeStatus(
navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
}
PeerList( PeerList(
viewModel = viewModel, viewModel = viewModel,
@ -159,7 +234,26 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
} }
Ipn.State.NoState, Ipn.State.NoState,
Ipn.State.Starting -> StartingView() Ipn.State.Starting -> StartingView()
else -> ConnectView(state, user, { viewModel.toggleVpn() }, { viewModel.login {} }) else -> {
ConnectView(
state,
isPrepared,
// If Tailscale is stopping, don't automatically restart; wait for user to take
// action (eg, if the user connected to another VPN).
state != Ipn.State.Stopping,
user,
{ viewModel.toggleVpn() },
{ viewModel.login() },
loginAtUrl,
netmap?.SelfNode,
{ viewModel.showVPNPermissionLauncherIfUnauthorized() })
}
}
}
currentPingDevice?.let { _ ->
ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) {
PingView(model = viewModel.pingViewModel)
} }
} }
} }
@ -168,15 +262,47 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
@Composable @Composable
fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
val prefs = viewModel.prefs.collectAsState() val nodeState by viewModel.nodeState.collectAsState()
val netmap = viewModel.netmap.collectAsState() val maybePrefs by viewModel.prefs.collectAsState()
val exitNodeId = prefs.value?.ExitNodeID val netmap by viewModel.netmap.collectAsState()
val peer = exitNodeId?.let { id -> netmap.value?.Peers?.find { it.StableID == id } }
val location = peer?.Hostinfo?.Location // There's nothing to render if we haven't loaded the prefs yet
val name = peer?.ComputedName val prefs = maybePrefs ?: return
val active = peer != null
// The activeExitNode is the source of truth. The selectedExitNode is only relevant if we
// don't have an active node.
val chosenExitNodeId = prefs.activeExitNodeID ?: prefs.selectedExitNodeID
val exitNodePeer = chosenExitNodeId?.let { id -> netmap?.Peers?.find { it.StableID == id } }
val name = exitNodePeer?.exitNodeName
val managedByOrganization by viewModel.managedByOrganization.collectAsState()
Box(
modifier =
Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surfaceContainer)) {
if (nodeState == NodeState.OFFLINE_MDM) {
Box(
modifier =
Modifier.padding(start = 16.dp, end = 16.dp, top = 56.dp, bottom = 16.dp)
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
.background(MaterialTheme.colorScheme.customErrorContainer)
.fillMaxWidth()
.align(Alignment.TopCenter)) {
Column(
modifier =
Modifier.padding(start = 16.dp, end = 16.dp, top = 36.dp, bottom = 16.dp)) {
Text(
text =
managedByOrganization.value?.let {
stringResource(R.string.exit_node_offline_mdm_orgname, it)
} ?: stringResource(R.string.exit_node_offline_mdm),
style = MaterialTheme.typography.bodyMedium,
color = Color.White)
}
}
}
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) {
Box( Box(
modifier = modifier =
Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp) Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp)
@ -185,11 +311,25 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
ListItem( ListItem(
modifier = Modifier.clickable { navAction() }, modifier = Modifier.clickable { navAction() },
colors = colors =
if (active) MaterialTheme.colorScheme.primaryListItem when (nodeState) {
else ListItemDefaults.colors(), NodeState.ACTIVE_AND_RUNNING -> MaterialTheme.colorScheme.primaryListItem
NodeState.ACTIVE_NOT_RUNNING -> MaterialTheme.colorScheme.listItem
NodeState.RUNNING_AS_EXIT_NODE -> MaterialTheme.colorScheme.warningListItem
NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorListItem
NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorListItem
NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorListItem
else ->
ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surface)
},
overlineContent = { overlineContent = {
Text( Text(
stringResource(R.string.exit_node), text =
if (nodeState == NodeState.OFFLINE_ENABLED ||
nodeState == NodeState.OFFLINE_DISABLED ||
nodeState == NodeState.OFFLINE_MDM)
stringResource(R.string.exit_node_offline)
else stringResource(R.string.exit_node),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
}, },
@ -197,9 +337,12 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = text =
location?.let { "${it.CountryCode?.flag()} ${it.Country}: ${it.City}" } when (nodeState) {
?: name NodeState.NONE -> stringResource(id = R.string.none)
?: stringResource(id = R.string.none), NodeState.RUNNING_AS_EXIT_NODE ->
stringResource(id = R.string.running_exit_node)
else -> name ?: ""
},
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis) overflow = TextOverflow.Ellipsis)
@ -207,17 +350,40 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
imageVector = Icons.Outlined.ArrowDropDown, imageVector = Icons.Outlined.ArrowDropDown,
contentDescription = null, contentDescription = null,
tint = tint =
if (active) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f) if (nodeState == NodeState.NONE)
else MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
) )
} }
}, },
trailingContent = { trailingContent = {
if (peer != null) { if (nodeState != NodeState.NONE) {
Button( Button(
colors = MaterialTheme.colorScheme.secondaryButton, colors =
onClick = { viewModel.disableExitNode() }) { when (nodeState) {
Text(stringResource(R.string.stop)) NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorButton
NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorButton
NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorButton
NodeState.RUNNING_AS_EXIT_NODE ->
MaterialTheme.colorScheme.warningButton
NodeState.ACTIVE_NOT_RUNNING ->
MaterialTheme.colorScheme.exitNodeToggleButton
else -> MaterialTheme.colorScheme.secondaryButton
},
onClick = {
if (nodeState == NodeState.RUNNING_AS_EXIT_NODE)
viewModel.setRunningExitNode(false)
else viewModel.toggleExitNode()
}) {
Text(
when (nodeState) {
NodeState.OFFLINE_DISABLED -> stringResource(id = R.string.enable)
NodeState.ACTIVE_NOT_RUNNING ->
stringResource(id = R.string.enable)
NodeState.RUNNING_AS_EXIT_NODE ->
stringResource(id = R.string.stop)
else -> stringResource(id = R.string.disable)
})
} }
} }
}) })
@ -230,8 +396,8 @@ fun SettingsButton(action: () -> Unit) {
IconButton(modifier = Modifier.size(24.dp), onClick = { action() }) { IconButton(modifier = Modifier.size(24.dp), onClick = { action() }) {
Icon( Icon(
Icons.Outlined.Settings, Icons.Outlined.Settings,
null, contentDescription = "Open settings",
) tint = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} }
@ -241,17 +407,28 @@ fun StartingView() {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) { horizontalAlignment = Alignment.CenterHorizontally) {
TailscaleLogoView(animated = true, Modifier.size(40.dp)) TailscaleLogoView(
animated = true, usesOnBackgroundColors = false, Modifier.size(40.dp).alpha(0.3f))
} }
} }
@Composable @Composable
fun ConnectView( fun ConnectView(
state: Ipn.State, state: Ipn.State,
isPrepared: Boolean,
shouldStartAutomatically: Boolean,
user: IpnLocal.LoginProfile?, user: IpnLocal.LoginProfile?,
connectAction: () -> Unit, connectAction: () -> Unit,
loginAction: () -> Unit loginAction: () -> Unit,
loginAtUrlAction: (String) -> Unit,
selfNode: Tailcfg.Node?,
showVPNPermissionLauncherIfUnauthorized: () -> Unit
) { ) {
LaunchedEffect(isPrepared) {
if (!isPrepared && shouldStartAutomatically) {
showVPNPermissionLauncherIfUnauthorized()
}
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) { Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
Column( Column(
@ -259,7 +436,45 @@ fun ConnectView(
verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically), verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
if (state != Ipn.State.NeedsLogin && user != null && !user.isEmpty()) { if (!isPrepared) {
TailscaleLogoView(modifier = Modifier.size(50.dp))
Spacer(modifier = Modifier.size(1.dp))
Text(
text = stringResource(id = R.string.welcome_to_tailscale),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center)
Text(
stringResource(R.string.give_permissions),
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Center)
Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = connectAction) {
Text(
text = stringResource(id = R.string.connect),
fontSize = MaterialTheme.typography.titleMedium.fontSize)
}
} else if (state == Ipn.State.NeedsMachineAuth) {
Icon(
modifier = Modifier.size(40.dp),
imageVector = Icons.Outlined.Lock,
contentDescription = "Device requires authentication")
Text(
text = stringResource(id = R.string.machine_auth_required),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center)
Text(
text = stringResource(id = R.string.machine_auth_explainer),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center)
Spacer(modifier = Modifier.size(1.dp))
selfNode?.let {
PrimaryActionButton(onClick = { loginAtUrlAction(it.nodeAdminUrl) }) {
Text(
text = stringResource(id = R.string.open_admin_console),
fontSize = MaterialTheme.typography.titleMedium.fontSize)
}
}
} else if (state != Ipn.State.NeedsLogin && user != null && !user.isEmpty()) {
Icon( Icon(
painter = painterResource(id = R.drawable.power), painter = painterResource(id = R.drawable.power),
contentDescription = null, contentDescription = null,
@ -320,15 +535,24 @@ fun PeerList(
onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
onSearch: (String) -> Unit onSearch: (String) -> Unit
) { ) {
val peerList = viewModel.peers.collectAsState(initial = emptyList<PeerSet>()) val peerList by viewModel.peers.collectAsState(initial = emptyList<PeerSet>())
val searchTermStr by viewModel.searchTerm.collectAsState(initial = "") val searchTermStr by viewModel.searchTerm.collectAsState(initial = "")
val showNoResults = val showNoResults =
derivedStateOf { searchTermStr.isNotEmpty() && peerList.value.isEmpty() }.value remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value
val netmap = viewModel.netmap.collectAsState() val netmap = viewModel.netmap.collectAsState()
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
var isFocussed by remember { mutableStateOf(false) } var isFocussed by remember { mutableStateOf(false) }
var isListFocussed by remember { mutableStateOf(false) }
val expandedPeer = viewModel.expandedMenuPeer.collectAsState()
val localClipboardManager = LocalClipboardManager.current
val enableSearch = !isAndroidTV()
if (enableSearch) {
Box(modifier = Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surface)) { Box(modifier = Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surface)) {
OutlinedTextField( OutlinedTextField(
modifier = modifier =
@ -338,7 +562,9 @@ fun PeerList(
singleLine = true, singleLine = true,
shape = MaterialTheme.shapes.extraLarge, shape = MaterialTheme.shapes.extraLarge,
colors = MaterialTheme.colorScheme.searchBarColors, colors = MaterialTheme.colorScheme.searchBarColors,
leadingIcon = { Icon(imageVector = Icons.Outlined.Search, contentDescription = "search") }, leadingIcon = {
Icon(imageVector = Icons.Outlined.Search, contentDescription = "search")
},
trailingIcon = { trailingIcon = {
if (isFocussed) { if (isFocussed) {
IconButton( IconButton(
@ -364,14 +590,19 @@ fun PeerList(
value = searchTermStr, value = searchTermStr,
onValueChange = { onSearch(it) }) onValueChange = { onSearch(it) })
} }
}
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().background(color = MaterialTheme.colorScheme.surface)) { modifier =
Modifier.fillMaxSize()
.onFocusChanged { isListFocussed = it.isFocused }
.background(color = MaterialTheme.colorScheme.surface)) {
if (showNoResults) { if (showNoResults) {
item { item {
Spacer( Spacer(
Modifier.height(16.dp) Modifier.height(16.dp)
.fillMaxSize() .fillMaxSize()
.focusable(false)
.background(color = MaterialTheme.colorScheme.surface)) .background(color = MaterialTheme.colorScheme.surface))
Lists.LargeTitle( Lists.LargeTitle(
@ -383,28 +614,25 @@ fun PeerList(
} }
var first = true var first = true
peerList.value.forEach { peerSet -> peerList.forEach { peerSet ->
if (!first) { if (!first) {
item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() } item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() }
} }
first = false first = false
stickyHeader { // Sticky headers are a bit broken on Android TV - they hide their content
Spacer( if (isAndroidTV()) {
Modifier.height(16.dp) item { NodesSectionHeader(peerSet = peerSet) }
.fillMaxSize() } else {
.background(color = MaterialTheme.colorScheme.surface)) stickyHeader { NodesSectionHeader(peerSet = peerSet) }
Lists.LargeTitle(
peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user),
bottomPadding = 8.dp,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold)
} }
itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer -> itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer ->
ListItem( ListItem(
modifier = Modifier.clickable { onNavigateToPeerDetails(peer) }, modifier =
Modifier.combinedClickable(
onClick = { onNavigateToPeerDetails(peer) },
onLongClick = { viewModel.expandedMenuPeer.set(peer) }),
colors = MaterialTheme.colorScheme.listItem, colors = MaterialTheme.colorScheme.listItem,
headlineContent = { headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@ -417,6 +645,38 @@ fun PeerList(
shape = RoundedCornerShape(percent = 50))) {} shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
Text(text = peer.displayName, style = MaterialTheme.typography.titleMedium) Text(text = peer.displayName, style = MaterialTheme.typography.titleMedium)
DropdownMenu(
expanded = expandedPeer.value?.StableID == peer.StableID,
onDismissRequest = { viewModel.hidePeerDropdownMenu() }) {
DropdownMenuItem(
leadingIcon = {
Icon(
painter = painterResource(R.drawable.clipboard),
contentDescription = null)
},
text = { Text(text = stringResource(R.string.copy_ip_address)) },
onClick = {
viewModel.copyIpAddress(peer, localClipboardManager)
viewModel.hidePeerDropdownMenu()
})
netmap.value?.let { netMap ->
if (!peer.isSelfNode(netMap)) {
// Don't show the ping item for the self-node
DropdownMenuItem(
leadingIcon = {
Icon(
painter = painterResource(R.drawable.timer),
contentDescription = null)
},
text = { Text(text = stringResource(R.string.ping)) },
onClick = {
viewModel.hidePeerDropdownMenu()
viewModel.startPing(peer)
})
}
}
}
} }
}, },
supportingContent = { supportingContent = {
@ -432,13 +692,21 @@ fun PeerList(
} }
@Composable @Composable
fun ExpiryNotificationIfNecessary(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) { fun NodesSectionHeader(peerSet: PeerSet) {
// Key expiry warning shown only if the key is expiring within 24 hours (or has already expired) Spacer(Modifier.height(16.dp).fillMaxSize().background(color = MaterialTheme.colorScheme.surface))
val networkMap = netmap ?: return
if (!TimeUtil.isWithin24Hours(networkMap.SelfNode.KeyExpiry)) { Lists.LargeTitle(
return peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user),
bottomPadding = 8.dp,
focusable = isAndroidTV(),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold)
} }
@Composable
fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) {
if (netmap == null) return
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) { Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) {
Box( Box(
modifier = modifier =
@ -450,7 +718,7 @@ fun ExpiryNotificationIfNecessary(netmap: Netmap.NetworkMap?, action: () -> Unit
colors = MaterialTheme.colorScheme.warningListItem, colors = MaterialTheme.colorScheme.warningListItem,
headlineContent = { headlineContent = {
Text( Text(
networkMap.SelfNode.expiryLabel(), netmap.SelfNode.expiryLabel(),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
}, },
@ -475,3 +743,19 @@ fun PromptPermissionsIfNecessary() {
} }
} }
} }
@Preview
@Composable
fun MainViewPreview() {
val vpnViewModel = VpnViewModel(App.get())
val vm = MainViewModel(vpnViewModel)
MainView(
{},
MainViewNavigation(
onNavigateToSettings = {},
onNavigateToPeerDetails = {},
onNavigateToExitNodes = {},
onNavigateToHealth = {}),
vm)
}

@ -16,24 +16,27 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.viewModel.IpnViewModel import com.tailscale.ipn.ui.viewModel.IpnViewModel
@Suppress("UNUSED_PARAMETER")
@Composable @Composable
fun ManagedByView(backToSettings: BackNavigation, model: IpnViewModel = viewModel()) { fun ManagedByView(backToSettings: BackNavigation, model: IpnViewModel = viewModel()) {
Scaffold(topBar = { Header(R.string.managed_by, onBack = backToSettings) }) { innerPadding -> Scaffold(topBar = { Header(R.string.managed_by, onBack = backToSettings) }) { _ ->
Column( Column(
verticalArrangement = verticalArrangement =
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically), Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
modifier = Modifier.fillMaxWidth().safeContentPadding().verticalScroll(rememberScrollState())) { modifier =
Modifier.fillMaxWidth().safeContentPadding().verticalScroll(rememberScrollState())) {
val managedByOrganization = val managedByOrganization =
MDMSettings.managedByOrganizationName.flow.collectAsState().value MDMSettings.managedByOrganizationName.flow.collectAsState().value.value
val managedByCaption = MDMSettings.managedByCaption.flow.collectAsState().value val managedByCaption = MDMSettings.managedByCaption.flow.collectAsState().value.value
val managedByURL = MDMSettings.managedByURL.flow.collectAsState().value val managedByURL = MDMSettings.managedByURL.flow.collectAsState().value.value
managedByOrganization?.let { managedByOrganization?.let {
Text(stringResource(R.string.managed_by_explainer_orgName, it)) Text(stringResource(R.string.managed_by_explainer_orgName, it))
} ?: run { Text(stringResource(R.string.managed_by_explainer)) } } ?: run { Text(stringResource(R.string.managed_by_explainer)) }
@ -46,3 +49,10 @@ fun ManagedByView(backToSettings: BackNavigation, model: IpnViewModel = viewMode
} }
} }
} }
@Preview
@Composable
fun ManagedByViewPreview() {
val vm = IpnViewModel()
ManagedByView(backToSettings = {}, vm)
}

@ -10,6 +10,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@ -29,10 +30,10 @@ fun MullvadExitNodePicker(
nav: ExitNodePickerNav, nav: ExitNodePickerNav,
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav)) model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) { ) {
val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState() val mullvadExitNodes by model.mullvadExitNodesByCountryCode.collectAsState()
val bestAvailableByCountry = model.mullvadBestAvailableByCountry.collectAsState() val bestAvailableByCountry by model.mullvadBestAvailableByCountry.collectAsState()
mullvadExitNodes.value[countryCode]?.toList()?.let { nodes -> mullvadExitNodes[countryCode]?.toList()?.let { nodes ->
val any = nodes.first() val any = nodes.first()
LoadingIndicator.Wrap { LoadingIndicator.Wrap {
@ -44,7 +45,7 @@ fun MullvadExitNodePicker(
}) { innerPadding -> }) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
if (nodes.size > 1) { if (nodes.size > 1) {
val bestAvailableNode = bestAvailableByCountry.value[countryCode]!! val bestAvailableNode = bestAvailableByCountry[countryCode]!!
item { item {
ExitNodeItem( ExitNodeItem(
model, model,

@ -17,6 +17,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@ -40,11 +41,11 @@ fun MullvadExitNodePickerList(
topBar = { topBar = {
Header(R.string.choose_mullvad_exit_node, onBack = nav.onNavigateBackToExitNodes) Header(R.string.choose_mullvad_exit_node, onBack = nav.onNavigateBackToExitNodes)
}) { innerPadding -> }) { innerPadding ->
val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState() val mullvadExitNodes by model.mullvadExitNodesByCountryCode.collectAsState()
LazyColumn(modifier = Modifier.padding(innerPadding)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
val sortedCountries = val sortedCountries =
mullvadExitNodes.value.entries.toList().sortedBy { mullvadExitNodes.entries.toList().sortedBy {
it.value.first().country.lowercase() it.value.first().country.lowercase()
} }
itemsWithDividers(sortedCountries) { (countryCode, nodes) -> itemsWithDividers(sortedCountries) { (countryCode, nodes) ->

@ -0,0 +1,56 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
@Composable
fun MullvadInfoView(nav: ExitNodePickerNav) {
Scaffold(
topBar = {
Header(R.string.choose_mullvad_exit_node, onBack = nav.onNavigateBackToExitNodes)
}) { innerPadding ->
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 48.dp),
modifier = Modifier.padding(innerPadding)) {
item {
Image(
painter = painterResource(id = R.drawable.mullvad_logo),
contentDescription = stringResource(R.string.the_mullvad_vpn_logo))
}
item {
Text(
stringResource(R.string.mullvad_info_title),
fontFamily = MaterialTheme.typography.titleLarge.fontFamily,
fontSize = MaterialTheme.typography.titleLarge.fontSize,
fontWeight = FontWeight.SemiBold)
}
item {
Text(
stringResource(R.string.mullvad_info_explainer),
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Center)
}
}
}
}

@ -5,6 +5,7 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -15,12 +16,15 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
@ -33,19 +37,26 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.listItem import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.theme.short import com.tailscale.ipn.ui.theme.short
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModelFactory import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModelFactory
import com.tailscale.ipn.ui.viewModel.PingViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PeerDetails( fun PeerDetails(
backToHome: BackNavigation, backToHome: BackNavigation,
nodeId: String, nodeId: String,
pingViewModel: PingViewModel,
model: PeerDetailsViewModel = model: PeerDetailsViewModel =
viewModel(factory = PeerDetailsViewModelFactory(nodeId, LocalContext.current.filesDir)) viewModel(
factory =
PeerDetailsViewModelFactory(nodeId, LocalContext.current.filesDir, pingViewModel))
) { ) {
val isPinging by model.isPinging.collectAsState()
model.netmap.collectAsState().value?.let { netmap -> model.netmap.collectAsState().value?.let { netmap ->
model.node.collectAsState().value?.let { node -> model.node.collectAsState().value?.let { node ->
Scaffold( Scaffold(
@ -72,6 +83,13 @@ fun PeerDetails(
} }
} }
}, },
actions = {
IconButton(onClick = { model.startPing() }) {
Icon(
painter = painterResource(R.drawable.timer),
contentDescription = "Ping device")
}
},
onBack = backToHome) onBack = backToHome)
}, },
) { innerPadding -> ) { innerPadding ->
@ -92,6 +110,11 @@ fun PeerDetails(
ValueRow(title = stringResource(id = it.titleRes), value = it.value.getString()) ValueRow(title = stringResource(id = it.titleRes), value = it.value.getString())
} }
} }
if (isPinging) {
ModalBottomSheet(onDismissRequest = { model.onPingDismissal() }) {
PingView(model = model.pingViewModel)
}
}
} }
} }
} }
@ -101,14 +124,24 @@ fun PeerDetails(
fun AddressRow(address: String, type: String) { fun AddressRow(address: String, type: String) {
val localClipboardManager = LocalClipboardManager.current val localClipboardManager = LocalClipboardManager.current
// Android TV doesn't have a clipboard, nor any way to use the values, so visible only.
val modifier =
if (isAndroidTV()) {
Modifier.focusable(false)
} else {
Modifier.clickable { localClipboardManager.setText(AnnotatedString(address)) }
}
ListItem( ListItem(
modifier = Modifier.clickable { localClipboardManager.setText(AnnotatedString(address)) }, modifier = modifier,
colors = MaterialTheme.colorScheme.listItem, colors = MaterialTheme.colorScheme.listItem,
headlineContent = { Text(text = address) }, headlineContent = { Text(text = address) },
supportingContent = { Text(text = type) }, supportingContent = { Text(text = type) },
trailingContent = { trailingContent = {
// TODO: there is some overlap with other uses of clipboard, DRY // TODO: there is some overlap with other uses of clipboard, DRY
if (!isAndroidTV()) {
Icon(painter = painterResource(id = R.drawable.clipboard), null) Icon(painter = painterResource(id = R.drawable.clipboard), null)
}
}) })
} }

@ -28,7 +28,7 @@ fun PeerView(
peer: Tailcfg.Node, peer: Tailcfg.Node,
selfPeer: String? = null, selfPeer: String? = null,
stateVal: Ipn.State? = null, stateVal: Ipn.State? = null,
subtitle: () -> String = { peer.Addresses?.first()?.split("/")?.first() ?: "" }, subtitle: () -> String = { peer.primaryIPv4Address ?: peer.primaryIPv6Address ?: "" },
onClick: (Tailcfg.Node) -> Unit = {}, onClick: (Tailcfg.Node) -> Unit = {},
trailingContent: @Composable () -> Unit = {} trailingContent: @Composable () -> Unit = {}
) { ) {

@ -0,0 +1,204 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
// TODO(angott): must mention usage of com.patrykandpatrick.vico library in LICENSES
import android.graphics.Typeface
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.patrykandpatrick.vico.compose.axis.axisGuidelineComponent
import com.patrykandpatrick.vico.compose.axis.horizontal.rememberBottomAxis
import com.patrykandpatrick.vico.compose.axis.vertical.rememberStartAxis
import com.patrykandpatrick.vico.compose.chart.Chart
import com.patrykandpatrick.vico.compose.chart.line.lineChart
import com.patrykandpatrick.vico.compose.component.shapeComponent
import com.patrykandpatrick.vico.compose.component.textComponent
import com.patrykandpatrick.vico.compose.dimensions.dimensionsOf
import com.patrykandpatrick.vico.compose.m3.style.m3ChartStyle
import com.patrykandpatrick.vico.compose.style.ProvideChartStyle
import com.patrykandpatrick.vico.compose.style.currentChartStyle
import com.patrykandpatrick.vico.core.axis.AxisItemPlacer
import com.patrykandpatrick.vico.core.chart.copy
import com.patrykandpatrick.vico.core.entry.FloatEntry
import com.patrykandpatrick.vico.core.entry.entryModelOf
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.util.ConnectionMode
import com.tailscale.ipn.ui.viewModel.PingViewModel
import java.text.DecimalFormat
@Composable
fun PingView(model: PingViewModel = viewModel()) {
val connectionMode: ConnectionMode by
model.connectionMode.collectAsState(initial = ConnectionMode.NotConnected())
val peer: Tailcfg.Node? by model.peer.collectAsState()
val lastLatencyValue: String by model.lastLatencyValue.collectAsState()
val pingValues: List<Double> by model.latencyValues.collectAsState()
val chartEntryModel =
entryModelOf(
pingValues.withIndex().map { FloatEntry((it.index + 1).toFloat(), it.value.toFloat()) })
val errorMessage: String? by model.errorMessage.collectAsState()
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(bottom = 36.dp)) {
Row {
Column {
Text(
stringResource(R.string.pinging_node_name, peer?.ComputedName ?: "???"),
fontStyle = MaterialTheme.typography.titleLarge.fontStyle,
fontWeight = FontWeight.Bold)
if (pingValues.isNotEmpty()) {
AnimatedContent(targetState = connectionMode, contentKey = { it.contentKey() }) {
targetConnectionMode ->
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Icon(
painter = painterResource(id = targetConnectionMode.iconDrawable()),
contentDescription = null,
tint = targetConnectionMode.color())
Text(
targetConnectionMode.titleString(),
fontSize = MaterialTheme.typography.bodySmall.fontSize,
color = targetConnectionMode.color())
}
}
}
}
AnimatedContent(
targetState = lastLatencyValue,
transitionSpec = {
// The new value slides down and fades in, while the previous value slides down
// and fades out.
(slideInVertically { height -> -height } + fadeIn())
.togetherWith(slideOutVertically { height -> height } + fadeOut())
.using(SizeTransform(clip = false))
}) { latency ->
Text(
latency,
fontFamily = FontFamily.Monospace,
fontSize = MaterialTheme.typography.titleLarge.fontSize,
textAlign = TextAlign.Right,
modifier = Modifier.fillMaxWidth())
}
}
if (pingValues.isNotEmpty()) {
ProvideChartStyle(chartStyle = m3ChartStyle()) {
val defaultLines = currentChartStyle.lineChart.lines
val circlePoint =
shapeComponent(
shape = CircleShape,
color = MaterialTheme.colorScheme.background,
strokeColor = MaterialTheme.colorScheme.surfaceTint,
strokeWidth = 2.dp)
Chart(
chart =
lineChart(
remember(defaultLines) {
defaultLines.map { defaultLine ->
defaultLine.copy(point = circlePoint, pointSizeDp = 10.0F)
}
},
spacing = 0.dp,
),
model = chartEntryModel,
startAxis =
rememberStartAxis(
valueFormatter = { value, _ ->
DecimalFormat("#;#").format(value) + " ms"
},
itemPlacer = remember { AxisItemPlacer.Vertical.default(maxItemCount = 5) },
label =
textComponent(
color = MaterialTheme.colorScheme.secondary,
typeface = Typeface.MONOSPACE,
padding = dimensionsOf(end = 8.dp)),
guideline =
axisGuidelineComponent(
color = MaterialTheme.colorScheme.secondaryContainer)),
bottomAxis =
rememberBottomAxis(
itemPlacer = remember { AxisItemPlacer.Horizontal.default(spacing = 1) },
label =
textComponent(
color = MaterialTheme.colorScheme.secondary,
typeface = Typeface.MONOSPACE,
),
guideline =
axisGuidelineComponent(
color = MaterialTheme.colorScheme.secondaryContainer)),
)
}
} else {
errorMessage?.also { error ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement =
Arrangement.spacedBy(6.dp, alignment = Alignment.CenterVertically),
modifier = Modifier.fillMaxWidth().height(200.dp)) {
Icon(
painter = painterResource(id = R.drawable.warning),
modifier = Modifier.size(48.dp),
contentDescription = null,
tint = Color.Red)
Text(
stringResource(id = R.string.pingFailed),
fontSize = MaterialTheme.typography.titleMedium.fontSize,
fontFamily = MaterialTheme.typography.titleMedium.fontFamily,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = Color.Red)
Text(
error,
textAlign = TextAlign.Center,
color = Color.Red,
)
}
}
?: run {
Column(
modifier = Modifier.fillMaxWidth().height(200.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
TailscaleLogoView(
true, usesOnBackgroundColors = false, Modifier.size(36.dp).alpha(0.4f))
}
}
}
}
}
fun Double.roundedString(decimals: Int): String = "%.${decimals}f".format(this)

@ -21,6 +21,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@ -31,15 +32,11 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.RunExitNodeViewModel import com.tailscale.ipn.ui.viewModel.IpnViewModel
import com.tailscale.ipn.ui.viewModel.RunExitNodeViewModelFactory
@Composable @Composable
fun RunExitNodeView( fun RunExitNodeView(nav: ExitNodePickerNav, model: IpnViewModel = viewModel()) {
nav: ExitNodePickerNav, val isRunningExitNode by model.isRunningExitNode.collectAsState()
model: RunExitNodeViewModel = viewModel(factory = RunExitNodeViewModelFactory())
) {
val isRunningExitNode = model.isRunningExitNode.collectAsState().value
Scaffold( Scaffold(
topBar = { Header(R.string.run_as_exit_node, onBack = nav.onNavigateBackToExitNodes) }) { topBar = { Header(R.string.run_as_exit_node, onBack = nav.onNavigateBackToExitNodes) }) {
@ -49,7 +46,11 @@ fun RunExitNodeView(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = verticalArrangement =
Arrangement.spacedBy(24.dp, alignment = Alignment.CenterVertically), Arrangement.spacedBy(24.dp, alignment = Alignment.CenterVertically),
modifier = Modifier.padding(innerPadding).padding(24.dp).fillMaxHeight().verticalScroll(rememberScrollState())) { modifier =
Modifier.padding(innerPadding)
.padding(24.dp)
.fillMaxHeight()
.verticalScroll(rememberScrollState())) {
RunExitNodeGraphic() RunExitNodeGraphic()
if (isRunningExitNode) { if (isRunningExitNode) {

@ -14,6 +14,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
@ -22,36 +23,48 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.BuildConfig import com.tailscale.ipn.BuildConfig
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHide
import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.link import com.tailscale.ipn.ui.theme.link
import com.tailscale.ipn.ui.theme.listItem import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.SettingsViewModel import com.tailscale.ipn.ui.viewModel.SettingsViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.ui.notifier.Notifier
@Composable @Composable
fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel()) { fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel(), vpnViewModel: VpnViewModel = viewModel()) {
val handler = LocalUriHandler.current val handler = LocalUriHandler.current
val user = viewModel.loggedInUser.collectAsState().value
val isAdmin = viewModel.isAdmin.collectAsState().value val user by viewModel.loggedInUser.collectAsState()
val managedByOrganization = viewModel.managedByOrganization.collectAsState().value val isAdmin by viewModel.isAdmin.collectAsState()
val tailnetLockEnabled = viewModel.tailNetLockEnabled.collectAsState().value val managedByOrganization by viewModel.managedByOrganization.collectAsState()
val corpDNSEnabled = viewModel.corpDNSEnabled.collectAsState().value val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState()
val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState()
val isVPNPrepared by vpnViewModel.vpnPrepared.collectAsState()
val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState()
Scaffold( Scaffold(
topBar = { topBar = {
Header(titleRes = R.string.settings_title, onBack = settingsNav.onNavigateBackHome) Header(titleRes = R.string.settings_title, onBack = settingsNav.onNavigateBackHome)
}) { innerPadding -> }) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) { Column(modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) {
if (isVPNPrepared) {
UserView( UserView(
profile = user, profile = user,
actionState = UserActionState.NAV, actionState = UserActionState.NAV,
onClick = settingsNav.onNavigateToUserSwitcher) onClick = settingsNav.onNavigateToUserSwitcher)
}
if (isAdmin) { if (isAdmin && !isAndroidTV()) {
Lists.ItemDivider() Lists.ItemDivider()
AdminTextView { handler.openUri(Links.ADMIN_URL) } AdminTextView { handler.openUri(Links.ADMIN_URL) }
} }
@ -66,6 +79,13 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
}, },
onClick = settingsNav.onNavigateToDNSSettings) onClick = settingsNav.onNavigateToDNSSettings)
Lists.ItemDivider()
Setting.Text(
R.string.split_tunneling,
subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale),
onClick = settingsNav.onNavigateToSplitTunneling)
if (showTailnetLock.value == ShowHide.Show) {
Lists.ItemDivider() Lists.ItemDivider()
Setting.Text( Setting.Text(
R.string.tailnet_lock, R.string.tailnet_lock,
@ -74,11 +94,12 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
stringResource(if (it) R.string.enabled else R.string.disabled) stringResource(if (it) R.string.enabled else R.string.disabled)
}, },
onClick = settingsNav.onNavigateToTailnetLock) onClick = settingsNav.onNavigateToTailnetLock)
}
Lists.ItemDivider() Lists.ItemDivider()
Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions) Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions)
managedByOrganization?.let { managedByOrganization.value?.let {
Lists.ItemDivider() Lists.ItemDivider()
Setting.Text( Setting.Text(
title = stringResource(R.string.managed_by_orgName, it), title = stringResource(R.string.managed_by_orgName, it),
@ -177,3 +198,14 @@ fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
Lists.InfoItem(adminStr, onClick = onNavigateToAdminConsole) Lists.InfoItem(adminStr, onClick = onNavigateToAdminConsole)
} }
@Preview
@Composable
fun SettingsPreview() {
val vm = SettingsViewModel()
vm.corpDNSEnabled.set(true)
vm.tailNetLockEnabled.set(true)
vm.isAdmin.set(true)
vm.managedByOrganization.set("Tails and Scales Inc.")
SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
}

@ -22,12 +22,16 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.theme.topAppBar import com.tailscale.ipn.ui.theme.topAppBar
import com.tailscale.ipn.ui.theme.ts_color_light_blue import com.tailscale.ipn.ui.theme.ts_color_light_blue
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
typealias BackNavigation = () -> Unit typealias BackNavigation = () -> Unit
@ -41,6 +45,12 @@ fun Header(
actions: @Composable RowScope.() -> Unit = {}, actions: @Composable RowScope.() -> Unit = {},
onBack: (() -> Unit)? = null onBack: (() -> Unit)? = null
) { ) {
val f = FocusRequester()
if (isAndroidTV()) {
LaunchedEffect(Unit) { f.requestFocus() }
}
TopAppBar( TopAppBar(
title = { title = {
title?.let { title() } title?.let { title() }
@ -51,18 +61,20 @@ fun Header(
}, },
colors = MaterialTheme.colorScheme.topAppBar, colors = MaterialTheme.colorScheme.topAppBar,
actions = actions, actions = actions,
navigationIcon = { onBack?.let { BackArrow(action = it) } }, navigationIcon = { onBack?.let { BackArrow(action = it, focusRequester = f) } },
) )
} }
@Composable @Composable
fun BackArrow(action: () -> Unit) { fun BackArrow(action: () -> Unit, focusRequester: FocusRequester) {
Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) { Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) {
Icon( Icon(
Icons.AutoMirrored.Filled.ArrowBack, Icons.AutoMirrored.Filled.ArrowBack,
null, contentDescription = "Go back to the previous screen",
modifier = modifier =
Modifier.clickable( Modifier.focusRequester(focusRequester)
.clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false), indication = rememberRipple(bounded = false),
onClick = { action() })) onClick = { action() }))
@ -71,7 +83,7 @@ fun BackArrow(action: () -> Unit) {
@Composable @Composable
fun CheckedIndicator() { fun CheckedIndicator() {
Icon(Icons.Default.CheckCircle, "selected", tint = ts_color_light_blue) Icon(Icons.Default.CheckCircle, null, tint = ts_color_light_blue)
} }
@Composable @Composable

@ -0,0 +1,111 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.App
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.viewModel.SplitTunnelAppPickerViewModel
@Composable
fun SplitTunnelAppPickerView(
backToSettings: BackNavigation,
model: SplitTunnelAppPickerViewModel = viewModel()
) {
val installedApps by model.installedApps.collectAsState()
val excludedPackageNames by model.excludedPackageNames.collectAsState()
val builtInDisallowedPackageNames: List<String> = App.get().builtInDisallowedPackageNames
val mdmIncludedPackages by model.mdmIncludedPackages.collectAsState()
val mdmExcludedPackages by model.mdmExcludedPackages.collectAsState()
Scaffold(topBar = { Header(titleRes = R.string.split_tunneling, onBack = backToSettings) }) {
innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "header") {
ListItem(
headlineContent = {
Text(
stringResource(
R.string
.selected_apps_will_access_the_internet_directly_without_using_tailscale))
})
}
if (mdmExcludedPackages.value?.isNotEmpty() == true) {
item("mdmExcludedNotice") {
ListItem(
headlineContent = {
Text(stringResource(R.string.certain_apps_are_not_routed_via_tailscale))
})
}
} else if (mdmIncludedPackages.value?.isNotEmpty() == true) {
item("mdmIncludedNotice") {
ListItem(
headlineContent = {
Text(stringResource(R.string.only_specific_apps_are_routed_via_tailscale))
})
}
} else {
item("resolversHeader") {
Lists.SectionDivider(
stringResource(R.string.count_excluded_apps, excludedPackageNames.count()))
}
items(installedApps) { app ->
ListItem(
headlineContent = { Text(app.name, fontWeight = FontWeight.SemiBold) },
leadingContent = {
Image(
bitmap =
model.installedAppsManager.packageManager
.getApplicationIcon(app.packageName)
.toBitmap()
.asImageBitmap(),
contentDescription = null,
modifier = Modifier.width(40.dp).height(40.dp))
},
supportingContent = {
Text(
app.packageName,
color = MaterialTheme.colorScheme.secondary,
fontSize = MaterialTheme.typography.bodySmall.fontSize,
letterSpacing = MaterialTheme.typography.bodySmall.letterSpacing)
},
trailingContent = {
Checkbox(
checked = excludedPackageNames.contains(app.packageName),
enabled = !builtInDisallowedPackageNames.contains(app.packageName),
onCheckedChange = { checked ->
if (checked) {
model.exclude(packageName = app.packageName)
} else {
model.unexclude(packageName = app.packageName)
}
})
})
Lists.ItemDivider()
}
}
}
}
}

@ -8,17 +8,19 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -30,6 +32,7 @@ import coil.compose.AsyncImage
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.util.Lists.SectionDivider
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.TaildropViewModel import com.tailscale.ipn.ui.viewModel.TaildropViewModel
import com.tailscale.ipn.ui.viewModel.TaildropViewModelFactory import com.tailscale.ipn.ui.viewModel.TaildropViewModelFactory
@ -43,7 +46,9 @@ fun TaildropView(
viewModel: TaildropViewModel = viewModel: TaildropViewModel =
viewModel(factory = TaildropViewModelFactory(requestedTransfers, applicationScope)) viewModel(factory = TaildropViewModelFactory(requestedTransfers, applicationScope))
) { ) {
Scaffold(topBar = { Header(R.string.share) }) { paddingInsets -> Scaffold(
contentWindowInsets = WindowInsets.Companion.statusBars,
topBar = { Header(R.string.share) }) { paddingInsets ->
val showDialog = viewModel.showDialog.collectAsState().value val showDialog = viewModel.showDialog.collectAsState().value
// Show the error overlay // Show the error overlay
@ -53,15 +58,16 @@ fun TaildropView(
FileShareHeader( FileShareHeader(
fileTransfers = requestedTransfers.collectAsState().value, fileTransfers = requestedTransfers.collectAsState().value,
totalSize = viewModel.totalSize) totalSize = viewModel.totalSize)
Spacer(modifier = Modifier.size(8.dp))
when (viewModel.state.collectAsState().value) { when (viewModel.state.collectAsState().value) {
Ipn.State.Running -> { Ipn.State.Running -> {
val peers = viewModel.myPeers.collectAsState().value val peers by viewModel.myPeers.collectAsState()
val context = LocalContext.current val context = LocalContext.current
FileSharePeerList( FileSharePeerList(
peers = peers, peers = peers,
stateViewGenerator = { peerId -> viewModel.TrailingContentForPeer(peerId = peerId) }, stateViewGenerator = { peerId ->
viewModel.TrailingContentForPeer(peerId = peerId)
},
onShare = { viewModel.share(context, it) }) onShare = { viewModel.share(context, it) })
} }
else -> { else -> {
@ -78,9 +84,7 @@ fun FileSharePeerList(
stateViewGenerator: @Composable (String) -> Unit, stateViewGenerator: @Composable (String) -> Unit,
onShare: (Tailcfg.Node) -> Unit onShare: (Tailcfg.Node) -> Unit
) { ) {
Column(modifier = Modifier.padding(horizontal = 8.dp)) { SectionDivider(stringResource(R.string.my_devices))
Text(stringResource(R.string.my_devices), style = MaterialTheme.typography.titleMedium)
}
when (peers.isEmpty()) { when (peers.isEmpty()) {
true -> { true -> {
@ -112,8 +116,8 @@ fun FileSharePeerList(
@Composable @Composable
fun FileShareConnectView(onToggle: () -> Unit) { fun FileShareConnectView(onToggle: () -> Unit) {
Column( Column(
modifier = Modifier.padding(horizontal = 8.dp).fillMaxHeight(), modifier = Modifier.padding(horizontal = 16.dp).fillMaxHeight(),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.spacedBy(6.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally) { horizontalAlignment = Alignment.CenterHorizontally) {
Text( Text(
stringResource(R.string.connect_to_your_tailnet_to_share_files), stringResource(R.string.connect_to_your_tailnet_to_share_files),
@ -129,7 +133,7 @@ fun FileShareConnectView(onToggle: () -> Unit) {
@Composable @Composable
fun FileShareHeader(fileTransfers: List<Ipn.OutgoingFile>, totalSize: Long) { fun FileShareHeader(fileTransfers: List<Ipn.OutgoingFile>, totalSize: Long) {
Column(modifier = Modifier.padding(horizontal = 8.dp)) { Column(modifier = Modifier.padding(horizontal = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
IconForTransfer(fileTransfers) IconForTransfer(fileTransfers)
Column(modifier = Modifier.padding(horizontal = 8.dp)) { Column(modifier = Modifier.padding(horizontal = 8.dp)) {
@ -150,10 +154,12 @@ fun FileShareHeader(fileTransfers: List<Ipn.OutgoingFile>, totalSize: Long) {
} }
} }
val size = Formatter.formatFileSize(LocalContext.current, totalSize.toLong()) val size = Formatter.formatFileSize(LocalContext.current, totalSize.toLong())
Text(size, style = MaterialTheme.typography.titleMedium) Text(
size,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.secondary)
} }
} }
HorizontalDivider()
} }
} }

@ -3,10 +3,13 @@
package com.tailscale.ipn.ui.view package com.tailscale.ipn.ui.view
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -14,6 +17,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@ -23,13 +27,16 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.defaultTextColor
import com.tailscale.ipn.ui.theme.link import com.tailscale.ipn.ui.theme.link
import com.tailscale.ipn.ui.util.ClipboardValueView import com.tailscale.ipn.ui.util.ClipboardValueView
import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.TailnetLockSetupViewModel import com.tailscale.ipn.ui.viewModel.TailnetLockSetupViewModel
import com.tailscale.ipn.ui.viewModel.TailnetLockSetupViewModelFactory import com.tailscale.ipn.ui.viewModel.TailnetLockSetupViewModelFactory
@ -38,16 +45,22 @@ fun TailnetLockSetupView(
backToSettings: BackNavigation, backToSettings: BackNavigation,
model: TailnetLockSetupViewModel = viewModel(factory = TailnetLockSetupViewModelFactory()) model: TailnetLockSetupViewModel = viewModel(factory = TailnetLockSetupViewModelFactory())
) { ) {
val statusItems = model.statusItems.collectAsState().value val statusItems by model.statusItems.collectAsState()
val nodeKey = model.nodeKey.collectAsState().value val nodeKey by model.nodeKey.collectAsState()
val tailnetLockKey = model.tailnetLockKey.collectAsState().value val tailnetLockKey by model.tailnetLockKey.collectAsState()
val tailnetLockTlPubKey = tailnetLockKey.replace("nlpub", "tlpub")
Scaffold(topBar = { Header(R.string.tailnet_lock, onBack = backToSettings) }) { innerPadding -> Scaffold(topBar = { Header(R.string.tailnet_lock, onBack = backToSettings) }) { innerPadding ->
LoadingIndicator.Wrap { LoadingIndicator.Wrap {
LazyColumn(modifier = Modifier.padding(innerPadding)) { Column(
item(key = "header") { ExplainerView() } modifier =
Modifier.padding(innerPadding)
.focusable()
.verticalScroll(rememberScrollState())
.fillMaxSize()) {
ExplainerView()
items(items = statusItems, key = { "status_${it.title}" }) { statusItem -> statusItems.forEach { statusItem ->
Lists.ItemDivider() Lists.ItemDivider()
ListItem( ListItem(
@ -59,28 +72,23 @@ fun TailnetLockSetupView(
}, },
headlineContent = { Text(stringResource(statusItem.title)) }) headlineContent = { Text(stringResource(statusItem.title)) })
} }
// Node key
item(key = "nodeKey") {
Lists.SectionDivider() Lists.SectionDivider()
ClipboardValueView( ClipboardValueView(
value = nodeKey, value = nodeKey,
title = stringResource(R.string.node_key), title = stringResource(R.string.node_key),
subtitle = stringResource(R.string.node_key_explainer)) subtitle = stringResource(R.string.node_key_explainer))
}
item(key = "tailnetLockKey") { // Tailnet lock key
Lists.SectionDivider() Lists.SectionDivider()
ClipboardValueView( ClipboardValueView(
value = tailnetLockKey, value = tailnetLockTlPubKey,
title = stringResource(R.string.tailnet_lock_key), title = stringResource(R.string.tailnet_lock_key),
subtitle = stringResource(R.string.tailnet_lock_key_explainer)) subtitle = stringResource(R.string.tailnet_lock_key_explainer))
} }
} }
} }
} }
}
@Composable @Composable
private fun ExplainerView() { private fun ExplainerView() {
@ -96,8 +104,10 @@ private fun ExplainerView() {
@Composable @Composable
fun explainerText(): AnnotatedString { fun explainerText(): AnnotatedString {
val annotatedString = buildAnnotatedString { return buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
append(stringResource(id = R.string.tailnet_lock_explainer)) append(stringResource(id = R.string.tailnet_lock_explainer))
}
pushStringAnnotation(tag = "tailnetLockSupportURL", annotation = Links.TAILNET_LOCK_KB_URL) pushStringAnnotation(tag = "tailnetLockSupportURL", annotation = Links.TAILNET_LOCK_KB_URL)
@ -110,5 +120,13 @@ fun explainerText(): AnnotatedString {
} }
pop() pop()
} }
return annotatedString }
@Composable
@Preview
fun TailnetLockSetupViewPreview() {
val vm = TailnetLockSetupViewModel()
vm.nodeKey.set("8BADF00D-EA7-1337-DEAD-BEEF")
vm.tailnetLockKey.set("C0FFEE-CAFE-50DA")
TailnetLockSetupView(backToSettings = {}, vm)
} }

@ -14,6 +14,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import com.tailscale.ipn.ui.theme.onBackgroundLogoDotDisabled
import com.tailscale.ipn.ui.theme.onBackgroundLogoDotEnabled
import com.tailscale.ipn.ui.theme.standaloneLogoDotDisabled
import com.tailscale.ipn.ui.theme.standaloneLogoDotEnabled
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -31,10 +35,24 @@ val logoDotsMatrix: DotsMatrix =
) )
@Composable @Composable
fun TailscaleLogoView(animated: Boolean = false, modifier: Modifier) { fun TailscaleLogoView(
animated: Boolean = false,
usesOnBackgroundColors: Boolean = false,
modifier: Modifier
) {
val primaryColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) val primaryColor: Color =
val secondaryColor: Color = primaryColor.copy(alpha = 0.1f) if (usesOnBackgroundColors) {
MaterialTheme.colorScheme.onBackgroundLogoDotEnabled
} else {
MaterialTheme.colorScheme.standaloneLogoDotEnabled
}
val secondaryColor: Color =
if (usesOnBackgroundColors) {
MaterialTheme.colorScheme.onBackgroundLogoDotDisabled
} else {
MaterialTheme.colorScheme.standaloneLogoDotDisabled
}
val currentDotsMatrix: StateFlow<DotsMatrix> = MutableStateFlow(logoDotsMatrix) val currentDotsMatrix: StateFlow<DotsMatrix> = MutableStateFlow(logoDotsMatrix)
var currentDotsMatrixIndex = 0 var currentDotsMatrixIndex = 0

@ -7,7 +7,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@ -19,19 +18,16 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
@ -40,26 +36,32 @@ import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.UserSwitcherViewModel import com.tailscale.ipn.ui.viewModel.UserSwitcherViewModel
data class UserSwitcherNav(
val backToSettings: BackNavigation,
val onNavigateHome: () -> Unit,
val onNavigateCustomControl: () -> Unit,
val onNavigateToAuthKey: () -> Unit
)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun UserSwitcherView( fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = viewModel()) {
backToSettings: BackNavigation,
onNavigateHome: () -> Unit,
viewModel: UserSwitcherViewModel = viewModel()
) {
val users = viewModel.loginProfiles.collectAsState().value val users by viewModel.loginProfiles.collectAsState()
val currentUser = viewModel.loggedInUser.collectAsState().value val currentUser by viewModel.loggedInUser.collectAsState()
val showHeaderMenu = viewModel.showHeaderMenu.collectAsState().value val showHeaderMenu by viewModel.showHeaderMenu.collectAsState()
Scaffold( Scaffold(
topBar = { topBar = {
Header( Header(
R.string.accounts, R.string.accounts,
onBack = backToSettings, onBack = nav.backToSettings,
actions = { actions = {
Row { Row {
FusMenu(viewModel = viewModel) FusMenu(
viewModel = viewModel,
onAuthKeyClick = nav.onNavigateToAuthKey,
onCustomClick = nav.onNavigateCustomControl)
IconButton(onClick = { viewModel.showHeaderMenu.set(!showHeaderMenu) }) { IconButton(onClick = { viewModel.showHeaderMenu.set(!showHeaderMenu) }) {
Icon(Icons.Default.MoreVert, "menu") Icon(Icons.Default.MoreVert, "menu")
} }
@ -69,7 +71,7 @@ fun UserSwitcherView(
Column( Column(
modifier = Modifier.padding(innerPadding).fillMaxWidth(), modifier = Modifier.padding(innerPadding).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)) { verticalArrangement = Arrangement.spacedBy(8.dp)) {
val showErrorDialog = viewModel.errorDialog.collectAsState().value val showErrorDialog by viewModel.errorDialog.collectAsState()
// Show the error overlay if need be // Show the error overlay if need be
showErrorDialog?.let { showErrorDialog?.let {
@ -102,7 +104,7 @@ fun UserSwitcherView(
viewModel.errorDialog.set(ErrorDialogType.LOGOUT_FAILED) viewModel.errorDialog.set(ErrorDialogType.LOGOUT_FAILED)
nextUserId.value = null nextUserId.value = null
} else { } else {
onNavigateHome() nav.onNavigateHome()
} }
} }
}) })
@ -120,7 +122,7 @@ fun UserSwitcherView(
} }
Lists.ItemDivider() Lists.ItemDivider()
Setting.Text(R.string.reauthenticate) { viewModel.login {} } Setting.Text(R.string.reauthenticate) { viewModel.login() }
if (currentUser != null) { if (currentUser != null) {
Lists.ItemDivider() Lists.ItemDivider()
@ -129,7 +131,8 @@ fun UserSwitcherView(
destructive = true, destructive = true,
onClick = { onClick = {
viewModel.logout { viewModel.logout {
if (it.isFailure) { it.onSuccess { nav.onNavigateHome() }
.onFailure {
viewModel.errorDialog.set(ErrorDialogType.LOGOUT_FAILED) viewModel.errorDialog.set(ErrorDialogType.LOGOUT_FAILED)
} }
} }
@ -142,50 +145,49 @@ fun UserSwitcherView(
} }
@Composable @Composable
fun FusMenu(viewModel: UserSwitcherViewModel) { fun FusMenu(
var url by remember { mutableStateOf("") } onCustomClick: () -> Unit,
val expanded = viewModel.showHeaderMenu.collectAsState().value onAuthKeyClick: () -> Unit,
viewModel: UserSwitcherViewModel
) {
val expanded by viewModel.showHeaderMenu.collectAsState()
DropdownMenu( DropdownMenu(
expanded = expanded, expanded = expanded,
onDismissRequest = { onDismissRequest = { viewModel.showHeaderMenu.set(false) },
url = "" modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)) {
MenuItem(
onClick = {
onCustomClick()
viewModel.showHeaderMenu.set(false) viewModel.showHeaderMenu.set(false)
}, },
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)) { text = stringResource(id = R.string.custom_control_menu))
DropdownMenuItem( MenuItem(
onClick = {}, onClick = {
text = { onAuthKeyClick()
Column { viewModel.showHeaderMenu.set(false)
Text( },
stringResource(id = R.string.custom_control_menu), text = stringResource(id = R.string.auth_key_menu))
style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.padding(2.dp))
Text(
stringResource(id = R.string.custom_control_menu_desc),
style = MaterialTheme.typography.bodySmall)
Spacer(modifier = Modifier.padding(8.dp))
OutlinedTextField(
colors =
TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent),
textStyle = MaterialTheme.typography.bodyMedium,
value = url,
onValueChange = { url = it },
placeholder = {
Text(
stringResource(id = R.string.custom_control_placeholder),
style = MaterialTheme.typography.bodySmall)
})
Spacer(modifier = Modifier.padding(8.dp))
PrimaryActionButton(onClick = { viewModel.setControlURL(url) }) {
Text(stringResource(id = R.string.add_account_short))
} }
} }
})
@Composable
fun MenuItem(text: String, onClick: () -> Unit) {
DropdownMenuItem(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 0.dp),
onClick = onClick,
text = { Text(text = text) })
} }
@Composable
@Preview
fun UserSwitcherViewPreview() {
val vm = UserSwitcherViewModel()
val nav =
UserSwitcherNav(
backToSettings = {},
onNavigateHome = {},
onNavigateCustomControl = {},
onNavigateToAuthKey = {})
UserSwitcherView(nav, vm)
} }

@ -0,0 +1,54 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.view.ErrorDialogType
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
const val AUTH_KEY_LENGTH = 16
open class CustomLoginViewModel : IpnViewModel() {
val errorDialog: StateFlow<ErrorDialogType?> = MutableStateFlow(null)
}
class LoginWithAuthKeyViewModel : CustomLoginViewModel() {
// Sets the auth key and invokes the login flow
fun setAuthKey(authKey: String, onSuccess: () -> Unit) {
// The most basic of checks for auth key syntax
if (authKey.isEmpty()) {
errorDialog.set(ErrorDialogType.INVALID_AUTH_KEY)
return
}
loginWithAuthKey(authKey) {
it.onFailure { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) }
it.onSuccess { onSuccess() }
}
}
}
class LoginWithCustomControlURLViewModel : CustomLoginViewModel() {
// Sets the custom control URL and invokes the login flow
fun setControlURL(urlStr: String, onSuccess: () -> Unit) {
// Some basic checks that the entered URL is "reasonable". The underlying
// localAPIClient will use the default server if we give it a broken URL,
// but we can make sure we can construct a URL from the input string and
// ensure it has an http/https scheme
when (urlStr.startsWith("http", ignoreCase = true) &&
urlStr.contains("://") &&
urlStr.length > 7) {
false -> {
errorDialog.set(ErrorDialogType.INVALID_CUSTOM_URL)
return
}
true -> {
loginWithCustomControlURL(urlStr) {
it.onFailure { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) }
it.onSuccess { onSuccess() }
}
}
}
}
}

@ -3,7 +3,6 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import android.util.Log
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -24,14 +23,16 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.tailscale.ipn.util.TSLog
class DNSSettingsViewModelFactory() : ViewModelProvider.Factory { class DNSSettingsViewModelFactory : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return DNSSettingsViewModel() as T return DNSSettingsViewModel() as T
} }
} }
class DNSSettingsViewModel() : IpnViewModel() { class DNSSettingsViewModel : IpnViewModel() {
val enablementState: StateFlow<DNSEnablementState> = val enablementState: StateFlow<DNSEnablementState> =
MutableStateFlow(DNSEnablementState.NOT_RUNNING) MutableStateFlow(DNSEnablementState.NOT_RUNNING)
val dnsConfig: StateFlow<Tailcfg.DNSConfig?> = MutableStateFlow(null) val dnsConfig: StateFlow<Tailcfg.DNSConfig?> = MutableStateFlow(null)
@ -42,7 +43,7 @@ class DNSSettingsViewModel() : IpnViewModel() {
.combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) } .combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) }
.stateIn(viewModelScope) .stateIn(viewModelScope)
.collect { (netmap, prefs) -> .collect { (netmap, prefs) ->
Log.d("DNSSettingsViewModel", "prefs: CorpDNS=" + prefs?.CorpDNS.toString()) TSLog.d("DNSSettingsViewModel", "prefs: CorpDNS=" + prefs?.CorpDNS.toString())
prefs?.let { prefs?.let {
if (it.CorpDNS) { if (it.CorpDNS) {
enablementState.set(DNSEnablementState.ENABLED) enablementState.set(DNSEnablementState.ENABLED)

@ -23,6 +23,7 @@ data class ExitNodePickerNav(
val onNavigateBackHome: () -> Unit, val onNavigateBackHome: () -> Unit,
val onNavigateBackToExitNodes: () -> Unit, val onNavigateBackToExitNodes: () -> Unit,
val onNavigateToMullvad: () -> Unit, val onNavigateToMullvad: () -> Unit,
val onNavigateToMullvadInfo: () -> Unit,
val onNavigateBackToMullvad: () -> Unit, val onNavigateBackToMullvad: () -> Unit,
val onNavigateToMullvadCountry: (String) -> Unit, val onNavigateToMullvadCountry: (String) -> Unit,
val onNavigateToRunAsExitNode: () -> Unit, val onNavigateToRunAsExitNode: () -> Unit,
@ -30,6 +31,7 @@ data class ExitNodePickerNav(
class ExitNodePickerViewModelFactory(private val nav: ExitNodePickerNav) : class ExitNodePickerViewModelFactory(private val nav: ExitNodePickerNav) :
ViewModelProvider.Factory { ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ExitNodePickerViewModel(nav) as T return ExitNodePickerViewModel(nav) as T
} }
@ -39,7 +41,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
data class ExitNode( data class ExitNode(
val id: StableNodeID? = null, val id: StableNodeID? = null,
val label: String, val label: String,
val online: Boolean, val online: StateFlow<Boolean>,
val selected: Boolean, val selected: Boolean,
val mullvad: Boolean = false, val mullvad: Boolean = false,
val priority: Int = 0, val priority: Int = 0,
@ -54,7 +56,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
val mullvadBestAvailableByCountry: StateFlow<Map<String, ExitNode>> = MutableStateFlow(TreeMap()) val mullvadBestAvailableByCountry: StateFlow<Map<String, ExitNode>> = MutableStateFlow(TreeMap())
val mullvadExitNodeCount: StateFlow<Int> = MutableStateFlow(0) val mullvadExitNodeCount: StateFlow<Int> = MutableStateFlow(0)
val anyActive: StateFlow<Boolean> = MutableStateFlow(false) val anyActive: StateFlow<Boolean> = MutableStateFlow(false)
val isRunningExitNode: StateFlow<Boolean> = MutableStateFlow(false) val shouldShowMullvadInfo: StateFlow<Boolean> = MutableStateFlow(false)
init { init {
viewModelScope.launch { viewModelScope.launch {
@ -62,8 +64,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
.combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) } .combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) }
.stateIn(viewModelScope) .stateIn(viewModelScope)
.collect { (netmap, prefs) -> .collect { (netmap, prefs) ->
isRunningExitNode.set(prefs?.let { AdvertisedRoutesHelper.exitNodeOnFromPrefs(it) }) val exitNodeId = prefs?.activeExitNodeID ?: prefs?.selectedExitNodeID
val exitNodeId = prefs?.ExitNodeID
netmap?.Peers?.let { peers -> netmap?.Peers?.let { peers ->
val allNodes = val allNodes =
peers peers
@ -72,7 +73,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
ExitNode( ExitNode(
id = it.StableID, id = it.StableID,
label = it.displayName, label = it.displayName,
online = it.Online ?: false, online = MutableStateFlow(it.Online ?: false),
selected = it.StableID == exitNodeId, selected = it.StableID == exitNodeId,
mullvad = it.Name.endsWith(".mullvad.ts.net."), mullvad = it.Name.endsWith(".mullvad.ts.net."),
priority = it.Hostinfo.Location?.Priority ?: 0, priority = it.Hostinfo.Location?.Priority ?: 0,
@ -86,9 +87,10 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
tailnetExitNodes.set(tailnetNodes.sortedWith { a, b -> a.label.compareTo(b.label) }) tailnetExitNodes.set(tailnetNodes.sortedWith { a, b -> a.label.compareTo(b.label) })
val allMullvadExitNodes = val allMullvadExitNodes =
allNodes.filter { allNodes.filter { node ->
// Pick all mullvad nodes that are online or the currently selected // Pick all mullvad nodes that are online or the currently selected
it.mullvad && (it.selected || it.online) val online = node.online.value
node.mullvad && (node.selected || online)
} }
val mullvadExitNodes = val mullvadExitNodes =
allMullvadExitNodes allMullvadExitNodes
@ -128,6 +130,13 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
mullvadBestAvailableByCountry.set(bestAvailableByCountry) mullvadBestAvailableByCountry.set(bestAvailableByCountry)
anyActive.set(allNodes.any { it.selected }) anyActive.set(allNodes.any { it.selected })
prefs?.let { prefs ->
// Only show the Mullvad info view if the user is an admin and is using a Tailscale
// control server, as it wouldn't be actionable information otherwise.
shouldShowMullvadInfo.set(
netmap.SelfNode.isAdmin && prefs.ControlURL.endsWith(".tailscale.com"))
}
} }
} }
} }
@ -137,6 +146,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
LoadingIndicator.start() LoadingIndicator.start()
val prefsOut = Ipn.MaskedPrefs() val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeID = node.id prefsOut.ExitNodeID = node.id
Client(viewModelScope).editPrefs(prefsOut) { Client(viewModelScope).editPrefs(prefsOut) {
nav.onNavigateBackHome() nav.onNavigateBackHome()
LoadingIndicator.stop() LoadingIndicator.stop()

@ -0,0 +1,23 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.ui.model.Health
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class HealthViewModel : ViewModel() {
val warnings: StateFlow<List<Health.UnhealthyState>> = MutableStateFlow(listOf())
init {
viewModelScope.launch {
App.get().healthNotifier?.currentWarnings?.collect { set -> warnings.set(set.sorted()) }
}
}
}

@ -3,23 +3,22 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App import com.tailscale.ipn.UninitializedApp
import com.tailscale.ipn.IPNReceiver import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.UserID import com.tailscale.ipn.ui.model.UserID
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.AdvertisedRoutesHelper
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@ -32,9 +31,36 @@ open class IpnViewModel : ViewModel() {
val loggedInUser: StateFlow<IpnLocal.LoginProfile?> = MutableStateFlow(null) val loggedInUser: StateFlow<IpnLocal.LoginProfile?> = MutableStateFlow(null)
val loginProfiles: StateFlow<List<IpnLocal.LoginProfile>?> = MutableStateFlow(null) val loginProfiles: StateFlow<List<IpnLocal.LoginProfile>?> = MutableStateFlow(null)
private val _vpnPrepared = MutableStateFlow(false)
val vpnPrepared: StateFlow<Boolean> = _vpnPrepared
// The userId associated with the current node. ie: The logged in user. // The userId associated with the current node. ie: The logged in user.
private var selfNodeUserId: UserID? = null private var selfNodeUserId: UserID? = null
val isRunningExitNode: StateFlow<Boolean> = MutableStateFlow(false)
private var lastPrefs: Ipn.Prefs? = null
val prefs = Notifier.prefs
val netmap = Notifier.netmap
private val _nodeState = MutableStateFlow(NodeState.NONE)
val nodeState: StateFlow<NodeState> = _nodeState
val managedByOrganization = MDMSettings.managedByOrganizationName.flow
enum class NodeState {
NONE,
ACTIVE_AND_RUNNING,
// Last selected exit node is active but is not being used.
ACTIVE_NOT_RUNNING,
// Last selected exit node is currently offline.
OFFLINE_ENABLED,
// Last selected exit node has been de-selected and is currently offline.
OFFLINE_DISABLED,
// Exit node selection is managed by an administrator, and last selected exit node is currently
// offline
OFFLINE_MDM,
RUNNING_AS_EXIT_NODE
}
init { init {
viewModelScope.launch { viewModelScope.launch {
Notifier.state.collect { Notifier.state.collect {
@ -56,81 +82,174 @@ open class IpnViewModel : ViewModel() {
} }
} }
viewModelScope.launch { loadUserProfiles() } viewModelScope.launch {
Log.d(TAG, "Created") Notifier.prefs.collect {
it?.let {
lastPrefs = it
isRunningExitNode.set(AdvertisedRoutesHelper.exitNodeOnFromPrefs(it))
}
} }
protected fun Context.findActivity(): Activity? =
when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
} }
private fun loadUserProfiles() { viewModelScope.launch { loadUserProfiles() }
Client(viewModelScope).profiles { result ->
result.onSuccess(loginProfiles::set).onFailure { viewModelScope.launch {
Log.e(TAG, "Error loading profiles: ${it.message}") combine(prefs, netmap, isRunningExitNode) { prefs, netmap, isRunningExitNode ->
// Handle nullability for prefs and netmap
val validPrefs = prefs ?: return@combine NodeState.NONE
val validNetmap = netmap ?: return@combine NodeState.NONE
val chosenExitNodeId = validPrefs.activeExitNodeID ?: validPrefs.selectedExitNodeID
val exitNodePeer =
chosenExitNodeId?.let { id -> validNetmap.Peers?.find { it.StableID == id } }
when {
exitNodePeer?.Online == false -> {
if (MDMSettings.exitNodeID.flow.value.value != null) {
NodeState.OFFLINE_MDM
} else if (validPrefs.activeExitNodeID != null) {
NodeState.OFFLINE_ENABLED
} else {
NodeState.OFFLINE_DISABLED
} }
} }
exitNodePeer != null -> {
Client(viewModelScope).currentProfile { result -> if (!validPrefs.activeExitNodeID.isNullOrEmpty()) {
result NodeState.ACTIVE_AND_RUNNING
.onSuccess { loggedInUser.set(if (it.isEmpty()) null else it) } } else {
.onFailure { Log.e(TAG, "Error loading current profile: ${it.message}") } NodeState.ACTIVE_NOT_RUNNING
} }
} }
isRunningExitNode == true -> {
fun toggleVpn() { NodeState.RUNNING_AS_EXIT_NODE
when (Notifier.state.value) { }
Ipn.State.Running -> stopVPN() else -> {
else -> startVPN() NodeState.NONE
}
} }
} }
.collect { nodeState -> _nodeState.value = nodeState }
}
TSLog.d(TAG, "Created")
}
// VPN Control
fun startVPN() { fun startVPN() {
val context = App.getApplication().applicationContext UninitializedApp.get().startVPN()
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_CONNECT_VPN
context.sendBroadcast(intent)
} }
fun stopVPN() { fun stopVPN() {
val context = App.getApplication().applicationContext UninitializedApp.get().stopVPN()
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_DISCONNECT_VPN
context.sendBroadcast(intent)
} }
fun login(completionHandler: (Result<Unit>) -> Unit = {}) { // Login/Logout
fun login(
maskedPrefs: Ipn.MaskedPrefs? = null,
authKey: String? = null,
completionHandler: (Result<Unit>) -> Unit = {}
) {
val loginAction = {
Client(viewModelScope).startLoginInteractive { result -> Client(viewModelScope).startLoginInteractive { result ->
result result
.onSuccess { Log.d(TAG, "Login started: $it") } .onSuccess { TSLog.d(TAG, "Login started: $it") }
.onFailure { Log.e(TAG, "Error starting login: ${it.message}") } .onFailure { TSLog.e(TAG, "Error starting login: ${it.message}") }
completionHandler(result) completionHandler(result)
} }
} }
// Need to stop running before logging in to clear routes:
// https://linear.app/tailscale/issue/ENG-3441/routesdns-is-not-cleared-when-switching-profiles-or-reauthenticating
val stopThenLogin = {
Client(viewModelScope).editPrefs(Ipn.MaskedPrefs().apply { WantRunning = false }) { result ->
result
.onSuccess { loginAction() }
.onFailure { TSLog.e(TAG, "Error setting wantRunning to false: ${it.message}") }
}
}
val startAction = {
Client(viewModelScope).start(Ipn.Options(AuthKey = authKey)) { start ->
start.onFailure { completionHandler(Result.failure(it)) }.onSuccess { stopThenLogin() }
}
}
// If an MDM control URL is set, we will always use that in lieu of anything the user sets.
var prefs = maskedPrefs
val mdmControlURL = MDMSettings.loginURL.flow.value.value
if (mdmControlURL != null) {
prefs = prefs ?: Ipn.MaskedPrefs()
prefs.ControlURL = mdmControlURL
TSLog.d(TAG, "Overriding control URL with MDM value: $mdmControlURL")
}
prefs?.let {
Client(viewModelScope).editPrefs(it) { result ->
result.onFailure { completionHandler(Result.failure(it)) }.onSuccess { startAction() }
}
} ?: run { startAction() }
}
fun loginWithAuthKey(authKey: String, completionHandler: (Result<Unit>) -> Unit = {}) {
val prefs = Ipn.MaskedPrefs()
prefs.WantRunning = true
login(prefs, authKey = authKey, completionHandler)
}
fun loginWithCustomControlURL(
controlURL: String,
completionHandler: (Result<Unit>) -> Unit = {}
) {
val prefs = Ipn.MaskedPrefs()
prefs.ControlURL = controlURL
login(prefs, completionHandler = completionHandler)
}
fun logout(completionHandler: (Result<String>) -> Unit = {}) { fun logout(completionHandler: (Result<String>) -> Unit = {}) {
Client(viewModelScope).logout { result -> Client(viewModelScope).logout { result ->
result result
.onSuccess { Log.d(TAG, "Logout started: $it") } .onSuccess { TSLog.d(TAG, "Logout started: $it") }
.onFailure { Log.e(TAG, "Error starting logout: ${it.message}") } .onFailure { TSLog.e(TAG, "Error starting logout: ${it.message}") }
completionHandler(result) completionHandler(result)
} }
} }
// User Profiles
private fun loadUserProfiles() {
Client(viewModelScope).profiles { result ->
result.onSuccess(loginProfiles::set).onFailure {
TSLog.e(TAG, "Error loading profiles: ${it.message}")
}
}
Client(viewModelScope).currentProfile { result ->
result
.onSuccess { loggedInUser.set(if (it.isEmpty()) null else it) }
.onFailure { TSLog.e(TAG, "Error loading current profile: ${it.message}") }
}
}
fun switchProfile(profile: IpnLocal.LoginProfile, completionHandler: (Result<String>) -> Unit) { fun switchProfile(profile: IpnLocal.LoginProfile, completionHandler: (Result<String>) -> Unit) {
val switchProfile = {
Client(viewModelScope).switchProfile(profile) { Client(viewModelScope).switchProfile(profile) {
startVPN() startVPN()
completionHandler(it) completionHandler(it)
} }
} }
Client(viewModelScope).editPrefs(Ipn.MaskedPrefs().apply { WantRunning = false }) { result ->
result
.onSuccess { switchProfile() }
.onFailure { TSLog.e(TAG, "Error setting wantRunning to false: ${it.message}") }
}
}
fun addProfile(completionHandler: (Result<String>) -> Unit) { fun addProfile(completionHandler: (Result<String>) -> Unit) {
Client(viewModelScope).addProfile { Client(viewModelScope).addProfile {
if (it.isSuccess) { if (it.isSuccess) {
login {} login()
} }
startVPN() startVPN()
completionHandler(it) completionHandler(it)
@ -144,51 +263,58 @@ open class IpnViewModel : ViewModel() {
} }
} }
// The below handle all types of preference modifications typically invoked by the UI. // Exit Node Manipulation
// Callers generally shouldn't care about the returned prefs value - the source of
// truth is the IPNModel, who's prefs flow will change in value to reflect the true fun toggleExitNode() {
// value of the pref setting in the back end (and will match the value returned here). val prefs = prefs.value ?: return
// Generally, you will want to inspect the returned value in the callback for errors
// to indicate why a particular setting did not change in the interface. LoadingIndicator.start()
// if (prefs.activeExitNodeID != null) {
// Usage: // We have an active exit node so we should keep it, but disable it
// - User/Interface changed to new value. Render the new value. Client(viewModelScope).setUseExitNode(false) { LoadingIndicator.stop() }
// - Submit the new value to the PrefsEditor } else if (prefs.selectedExitNodeID != null) {
// - Observe the prefs on the IpnModel and update the UI when/if the value changes. // We have a prior exit node to enable
// For a typical flow, the changed value should reflect the value already shown. Client(viewModelScope).setUseExitNode(true) { LoadingIndicator.stop() }
// - Inform the user of any error which may have occurred } else {
// // This should not be possible. In this state the button is hidden
// The "toggle' functions here will attempt to set the pref value to the inverse of TSLog.e(TAG, "No exit node to disable and no prior exit node to enable")
// what is currently known in the IpnModel.prefs. If IpnModel.prefs is not available, }
// the callback will be called with a NO_PREFS error }
fun setWantRunning(wantRunning: Boolean, callback: (Result<Ipn.Prefs>) -> Unit) {
Ipn.MaskedPrefs().WantRunning = wantRunning fun setRunningExitNode(isOn: Boolean) {
Client(viewModelScope).editPrefs(Ipn.MaskedPrefs(), callback) LoadingIndicator.start()
} lastPrefs?.let { currentPrefs ->
val newPrefs: Ipn.MaskedPrefs
fun toggleShieldsUp(callback: (Result<Ipn.Prefs>) -> Unit) { if (isOn) {
val prefs = newPrefs = setZeroRoutes(currentPrefs)
Notifier.prefs.value } else {
?: run { newPrefs = removeAllZeroRoutes(currentPrefs)
callback(Result.failure(Exception("no prefs"))) }
return@toggleShieldsUp Client(viewModelScope).editPrefs(newPrefs) { result ->
} LoadingIndicator.stop()
TSLog.d("RunExitNodeViewModel", "Edited prefs: $result")
val prefsOut = Ipn.MaskedPrefs() }
prefsOut.ShieldsUp = !prefs.ShieldsUp }
Client(viewModelScope).editPrefs(prefsOut, callback) }
}
private fun setZeroRoutes(prefs: Ipn.Prefs): Ipn.MaskedPrefs {
fun toggleRouteAll(callback: (Result<Ipn.Prefs>) -> Unit) { val newRoutes = (removeAllZeroRoutes(prefs).AdvertiseRoutes ?: emptyList()).toMutableList()
val prefs = newRoutes.add("0.0.0.0/0")
Notifier.prefs.value newRoutes.add("::/0")
?: run { val newPrefs = Ipn.MaskedPrefs()
callback(Result.failure(Exception("no prefs"))) newPrefs.AdvertiseRoutes = newRoutes
return@toggleRouteAll return newPrefs
} }
val prefsOut = Ipn.MaskedPrefs() private fun removeAllZeroRoutes(prefs: Ipn.Prefs): Ipn.MaskedPrefs {
prefsOut.RouteAll = !prefs.RouteAll val newRoutes = emptyList<String>().toMutableList()
Client(viewModelScope).editPrefs(prefsOut, callback) (prefs.AdvertiseRoutes ?: emptyList()).forEach {
if (it != "0.0.0.0/0" && it != "::/0") {
newRoutes.add(it)
}
}
val newPrefs = Ipn.MaskedPrefs()
newPrefs.AdvertiseRoutes = newRoutes
return newPrefs
} }
} }

@ -20,12 +20,28 @@ import kotlinx.coroutines.launch
class LoginQRViewModel : IpnViewModel() { class LoginQRViewModel : IpnViewModel() {
val numCode: StateFlow<String?> = MutableStateFlow(null)
val qrCode: StateFlow<ImageBitmap?> = MutableStateFlow(null) val qrCode: StateFlow<ImageBitmap?> = MutableStateFlow(null)
init { init {
viewModelScope.launch { viewModelScope.launch {
Notifier.browseToURL.collect { url -> Notifier.browseToURL.collect { url ->
url?.let { qrCode.set(generateQRCode(url, 200, 0)) } ?: run { qrCode.set(null) } url?.let {
qrCode.set(generateQRCode(url, 200, 0))
// Extract the string after "https://login.tailscale.com/a/"
val prefix = "https://login.tailscale.com/a/"
val code =
if (it.startsWith(prefix)) {
it.removePrefix(prefix)
} else {
null
}
numCode.set(code)
}
?: run {
qrCode.set(null)
numCode.set(null)
}
} }
} }
} }

@ -3,27 +3,56 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import android.content.Intent
import android.net.VpnService
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Ipn.State import com.tailscale.ipn.ui.model.Ipn.State
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.PeerCategorizer import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Duration
class MainViewModel : IpnViewModel() { class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(MainViewModel::class.java)) {
return MainViewModel(vpnViewModel) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
@OptIn(FlowPreview::class)
class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
// The user readable state of the system // The user readable state of the system
val stateRes: StateFlow<Int> = MutableStateFlow(State.NoState.userStringRes()) val stateRes: StateFlow<Int> = MutableStateFlow(userStringRes(State.NoState, State.NoState, true))
// The expected state of the VPN toggle // The expected state of the VPN toggle
val vpnToggleState: StateFlow<Boolean> = MutableStateFlow(false) private val _vpnToggleState = MutableStateFlow(false)
val vpnToggleState: StateFlow<Boolean> = _vpnToggleState
// Permission to prepare VPN
private var vpnPermissionLauncher: ActivityResultLauncher<Intent>? = null
// The list of peers // The list of peers
val peers: StateFlow<List<PeerSet>> = MutableStateFlow(emptyList<PeerSet>()) val peers: StateFlow<List<PeerSet>> = MutableStateFlow(emptyList<PeerSet>())
@ -31,20 +60,71 @@ class MainViewModel : IpnViewModel() {
// The current state of the IPN for determining view visibility // The current state of the IPN for determining view visibility
val ipnState = Notifier.state val ipnState = Notifier.state
val prefs = Notifier.prefs
val netmap = Notifier.netmap
// The active search term for filtering peers // The active search term for filtering peers
val searchTerm: StateFlow<String> = MutableStateFlow("") val searchTerm: StateFlow<String> = MutableStateFlow("")
// True if we should render the key expiry bannder
val showExpiry: StateFlow<Boolean> = MutableStateFlow(false)
// The peer for which the dropdown menu is currently expanded. Null if no menu is expanded
var expandedMenuPeer: StateFlow<Tailcfg.Node?> = MutableStateFlow(null)
var pingViewModel: PingViewModel = PingViewModel()
val isVpnPrepared: StateFlow<Boolean> = vpnViewModel.vpnPrepared
val isVpnActive: StateFlow<Boolean> = vpnViewModel.vpnActive
// Icon displayed in the button to present the health view
val healthIcon: StateFlow<Int?> = MutableStateFlow(null)
fun hidePeerDropdownMenu() {
expandedMenuPeer.set(null)
}
fun copyIpAddress(peer: Tailcfg.Node, clipboardManager: ClipboardManager) {
clipboardManager.setText(AnnotatedString(peer.primaryIPv4Address ?: ""))
}
fun startPing(peer: Tailcfg.Node) {
this.pingViewModel.startPing(peer)
}
fun onPingDismissal() {
this.pingViewModel.handleDismissal()
}
private val peerCategorizer = PeerCategorizer() private val peerCategorizer = PeerCategorizer()
init { init {
viewModelScope.launch { viewModelScope.launch {
Notifier.state.collect { state -> var previousState: State? = null
stateRes.set(state.userStringRes())
vpnToggleState.set( combine(Notifier.state, isVpnActive) { state, active -> state to active }
(state == State.Running || state == State.Starting || state == State.NoState)) .collect { (currentState, active) ->
// Determine the correct state resource string
stateRes.set(userStringRes(currentState, previousState, active))
// Determine if the VPN toggle should be on
val isOn =
when {
active && (currentState == State.Running || currentState == State.Starting) ->
true
previousState == State.NoState && currentState == State.Starting -> true
else -> false
}
// Update the VPN toggle state
_vpnToggleState.value = isOn
// Update the previous state
previousState = currentState
}
}
viewModelScope.launch {
searchTerm.debounce(250L).collect { term ->
peers.set(peerCategorizer.groupedAndFilteredPeers(term))
} }
} }
@ -53,12 +133,46 @@ class MainViewModel : IpnViewModel() {
it?.let { netmap -> it?.let { netmap ->
peerCategorizer.regenerateGroupedPeers(netmap) peerCategorizer.regenerateGroupedPeers(netmap)
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value)) peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value))
if (netmap.SelfNode.keyDoesNotExpire) {
showExpiry.set(false)
return@let
} else {
val expiryNotificationWindowMDM = MDMSettings.keyExpirationNotice.flow.value.value
val window =
expiryNotificationWindowMDM?.let { TimeUtil.duration(it) } ?: Duration.ofHours(24)
val expiresSoon =
TimeUtil.isWithinExpiryNotificationWindow(window, it.SelfNode.KeyExpiry)
showExpiry.set(expiresSoon)
}
} }
} }
} }
viewModelScope.launch { viewModelScope.launch {
searchTerm.collect { term -> peers.set(peerCategorizer.groupedAndFilteredPeers(term)) } App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) }
}
}
fun showVPNPermissionLauncherIfUnauthorized() {
val vpnIntent = VpnService.prepare(App.get())
if (vpnIntent != null) {
vpnPermissionLauncher?.launch(vpnIntent)
} else {
vpnViewModel.setVpnPrepared(true)
startVPN()
}
}
fun toggleVpn() {
val state = Notifier.state.value
val isPrepared = vpnViewModel.vpnPrepared.value
when {
!isPrepared -> showVPNPermissionLauncherIfUnauthorized()
state == Ipn.State.Running -> stopVPN()
state == Ipn.State.NeedsLogin && isAndroidTV() -> login()
else -> startVPN()
} }
} }
@ -66,23 +180,23 @@ class MainViewModel : IpnViewModel() {
this.searchTerm.set(searchTerm) this.searchTerm.set(searchTerm)
} }
fun disableExitNode() { fun setVpnPermissionLauncher(launcher: ActivityResultLauncher<Intent>) {
LoadingIndicator.start() // No intent means we're already authorized
val prefsOut = Ipn.MaskedPrefs() vpnPermissionLauncher = launcher
prefsOut.ExitNodeID = null
Client(viewModelScope).editPrefs(prefsOut) { LoadingIndicator.stop() }
} }
} }
private fun State?.userStringRes(): Int { private fun userStringRes(currentState: State?, previousState: State?, vpnActive: Boolean): Int {
return when (this) { return when {
State.NoState -> R.string.waiting previousState == State.NoState && currentState == State.Starting -> R.string.starting
State.InUseOtherUser -> R.string.placeholder currentState == State.NoState -> R.string.placeholder
State.NeedsLogin -> R.string.please_login currentState == State.InUseOtherUser -> R.string.placeholder
State.NeedsMachineAuth -> R.string.placeholder currentState == State.NeedsLogin ->
State.Stopped -> R.string.stopped if (vpnActive) R.string.please_login else R.string.connect_to_vpn
State.Starting -> R.string.starting currentState == State.NeedsMachineAuth -> R.string.needs_machine_auth
State.Running -> R.string.connected currentState == State.Stopped -> R.string.stopped
currentState == State.Starting -> R.string.starting
currentState == State.Running -> if (vpnActive) R.string.connected else R.string.placeholder
else -> R.string.placeholder else -> R.string.placeholder
} }
} }

@ -6,7 +6,6 @@ package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
@ -19,16 +18,24 @@ import java.io.File
data class PeerSettingInfo(val titleRes: Int, val value: ComposableStringFormatter) data class PeerSettingInfo(val titleRes: Int, val value: ComposableStringFormatter)
class PeerDetailsViewModelFactory(private val nodeId: StableNodeID, private val filesDir: File) : class PeerDetailsViewModelFactory(
ViewModelProvider.Factory { private val nodeId: StableNodeID,
private val filesDir: File,
private val pingViewModel: PingViewModel
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PeerDetailsViewModel(nodeId, filesDir) as T return PeerDetailsViewModel(nodeId, filesDir, pingViewModel) as T
} }
} }
class PeerDetailsViewModel(val nodeId: StableNodeID, val filesDir: File) : IpnViewModel() { class PeerDetailsViewModel(
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null) val nodeId: StableNodeID,
val filesDir: File,
val pingViewModel: PingViewModel
) : IpnViewModel() {
val node: StateFlow<Tailcfg.Node?> = MutableStateFlow(null) val node: StateFlow<Tailcfg.Node?> = MutableStateFlow(null)
val isPinging: StateFlow<Boolean> = MutableStateFlow(false)
init { init {
viewModelScope.launch { viewModelScope.launch {
@ -38,4 +45,14 @@ class PeerDetailsViewModel(val nodeId: StableNodeID, val filesDir: File) : IpnVi
} }
} }
} }
fun startPing() {
isPinging.set(true)
node.value?.let { this.pingViewModel.startPing(it) }
}
fun onPingDismissal() {
isPinging.set(false)
this.pingViewModel.handleDismissal()
}
} }

@ -0,0 +1,131 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import android.content.Context
import android.os.CountDownTimer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.util.ConnectionMode
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.view.roundedString
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class PingViewModelFactory(private val peer: Tailcfg.Node) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PingViewModel() as T
}
}
class PingViewModel : ViewModel() {
private val TAG = PingViewModel::class.simpleName
// The timer ticks every second, for a maximum of 10 seconds, hence triggering 10 ping
// requests.
private val timer =
object : CountDownTimer(1000 * 10, 1000) {
override fun onTick(millisUntilFinished: Long) {
sendPing()
fetchStatusAndUpdateConnectionMode()
}
override fun onFinish() {
TSLog.d(TAG, "Ping timer terminated")
}
}
// The peer to ping.
var peer: StateFlow<Tailcfg.Node?> = MutableStateFlow(null)
// Whether we are using a relayed or direct connection. Will be NotConnected until the first
// PeerStatus value has been fetched. NotConnected is not surfaced to the user.
val connectionMode: StateFlow<ConnectionMode> = MutableStateFlow(ConnectionMode.NotConnected())
// An error message to display if any request fails. Non-null if an error message must be surfaced
// to the user. If a subsequent request succeeds, this property should be set to null again.
val errorMessage: StateFlow<String?> = MutableStateFlow(null)
// The last latency value in a human-readable format (e.g. "14.5 ms").
val lastLatencyValue: StateFlow<String> = MutableStateFlow("")
// A list of latency values over time in milliseconds. These are used to plot the latency
// values in the chart.
var latencyValues: StateFlow<List<Double>> = MutableStateFlow(emptyList())
fun startPing(peer: Tailcfg.Node) {
this.peer.set(peer)
timer.start()
}
fun handleDismissal() {
timer.cancel()
this.peer.set(null)
this.connectionMode.set(ConnectionMode.NotConnected())
this.lastLatencyValue.set("")
this.latencyValues.set(emptyList())
this.errorMessage.set(null)
}
// sendPing asks the backend to send one ping to the peer and handles the response.
// It checks for any errors in the response Err field. If an error is present, it sets the
// errorMessage property to a non-null value and returns. If there is no error, it updates the
// lastLatencyValue property with the formatted latency, and adds the latency value to the
// latencyValues list.
private fun sendPing() {
peer.value?.let { peer ->
Client(viewModelScope).ping(peer) { response ->
response.onSuccess { pingResult ->
val error = pingResult.Err
if (error.isNotEmpty()) {
this.errorMessage.set(error.replaceFirstChar { it.uppercase() })
return@onSuccess
} else {
this.errorMessage.set(null)
val latency: Double = pingResult.LatencySeconds * 1000
this.lastLatencyValue.set("${latency.roundedString(1)} ms")
this.latencyValues.set(this.latencyValues.value + latency)
}
}
response.onFailure { error ->
val context: Context = App.get().applicationContext
val stringError = error.toString()
TSLog.d(TAG, "Ping request failed: $stringError")
if (stringError.contains("timeout")) {
this.errorMessage.set(
context.getString(
R.string.request_timed_out_make_sure_that_is_online, peer.ComputedName))
} else {
this.errorMessage.set(
context.getString(R.string.an_unknown_error_occurred_please_try_again))
}
}
}
}
}
// fetchStatusAndUpdateConnectionMode fetches the PeerStatus for the peer and updates the
// connectionMode property as soon as a direct connection is finally established.
private fun fetchStatusAndUpdateConnectionMode() {
Client(viewModelScope).status { statusResult ->
statusResult.onSuccess { result ->
result.Peer?.let { map ->
map[peer.value?.Key]?.let { peerStatus ->
val curAddr = peerStatus.CurAddr.orEmpty()
val relay = peerStatus.Relay.orEmpty()
if (curAddr.isNotEmpty()) {
this.connectionMode.set(ConnectionMode.Direct())
} else if (relay.isNotEmpty()) {
this.connectionMode.set(ConnectionMode.Derp(relayName = relay.uppercase()))
}
}
}
}
statusResult.onFailure { TSLog.d(TAG, "Failed to fetch status: $it") }
}
}
}

@ -1,97 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class RunExitNodeViewModelFactory() : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return RunExitNodeViewModel() as T
}
}
class AdvertisedRoutesHelper() {
companion object {
fun exitNodeOnFromPrefs(prefs: Ipn.Prefs): Boolean {
var v4 = false
var v6 = false
prefs.AdvertiseRoutes?.forEach {
if (it == "0.0.0.0/0") {
v4 = true
}
if (it == "::/0") {
v6 = true
}
}
return v4 && v6
}
}
}
class RunExitNodeViewModel() : IpnViewModel() {
val isRunningExitNode: StateFlow<Boolean> = MutableStateFlow(false)
var lastPrefs: Ipn.Prefs? = null
init {
viewModelScope.launch {
Notifier.prefs.stateIn(viewModelScope).collect { prefs ->
Log.d("RunExitNode", "prefs: AdvertiseRoutes=" + prefs?.AdvertiseRoutes.toString())
prefs?.let {
lastPrefs = it
isRunningExitNode.set(AdvertisedRoutesHelper.exitNodeOnFromPrefs(it))
} ?: run { isRunningExitNode.set(false) }
}
}
}
fun setRunningExitNode(isOn: Boolean) {
LoadingIndicator.start()
lastPrefs?.let { currentPrefs ->
val newPrefs: Ipn.MaskedPrefs
if (isOn) {
newPrefs = setZeroRoutes(currentPrefs)
} else {
newPrefs = removeAllZeroRoutes(currentPrefs)
}
Client(viewModelScope).editPrefs(newPrefs) { result ->
LoadingIndicator.stop()
Log.d("RunExitNodeViewModel", "Edited prefs: $result")
}
}
}
private fun setZeroRoutes(prefs: Ipn.Prefs): Ipn.MaskedPrefs {
val newRoutes = (removeAllZeroRoutes(prefs).AdvertiseRoutes ?: emptyList()).toMutableList()
newRoutes.add("0.0.0.0/0")
newRoutes.add("::/0")
val newPrefs = Ipn.MaskedPrefs()
newPrefs.AdvertiseRoutes = newRoutes
return newPrefs
}
private fun removeAllZeroRoutes(prefs: Ipn.Prefs): Ipn.MaskedPrefs {
val newRoutes = emptyList<String>().toMutableList()
(prefs.AdvertiseRoutes ?: emptyList()).forEach {
if (it != "0.0.0.0/0" && it != "::/0") {
newRoutes.add(it)
}
}
val newPrefs = Ipn.MaskedPrefs()
newPrefs.AdvertiseRoutes = newRoutes
return newPrefs
}
}

@ -4,7 +4,6 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.LoadingIndicator
@ -17,6 +16,7 @@ data class SettingsNav(
val onNavigateToBugReport: () -> Unit, val onNavigateToBugReport: () -> Unit,
val onNavigateToAbout: () -> Unit, val onNavigateToAbout: () -> Unit,
val onNavigateToDNSSettings: () -> Unit, val onNavigateToDNSSettings: () -> Unit,
val onNavigateToSplitTunneling: () -> Unit,
val onNavigateToTailnetLock: () -> Unit, val onNavigateToTailnetLock: () -> Unit,
val onNavigateToMDMSettings: () -> Unit, val onNavigateToMDMSettings: () -> Unit,
val onNavigateToManagedBy: () -> Unit, val onNavigateToManagedBy: () -> Unit,
@ -29,7 +29,6 @@ data class SettingsNav(
class SettingsViewModel : IpnViewModel() { class SettingsViewModel : IpnViewModel() {
// Display name for the logged in user // Display name for the logged in user
val isAdmin: StateFlow<Boolean> = MutableStateFlow(false) val isAdmin: StateFlow<Boolean> = MutableStateFlow(false)
val managedByOrganization = MDMSettings.managedByOrganizationName.flow
// True if tailnet lock is enabled. nil if not yet known. // True if tailnet lock is enabled. nil if not yet known.
val tailNetLockEnabled: StateFlow<Boolean?> = MutableStateFlow(null) val tailNetLockEnabled: StateFlow<Boolean?> = MutableStateFlow(null)
// True if tailscaleDNS is enabled. nil if not yet known. // True if tailscaleDNS is enabled. nil if not yet known.
@ -48,11 +47,7 @@ class SettingsViewModel : IpnViewModel() {
viewModelScope.launch { viewModelScope.launch {
Notifier.prefs.collect { Notifier.prefs.collect {
it?.let { it?.let { corpDNSEnabled.set(it.CorpDNS) } ?: run { corpDNSEnabled.set(null) }
corpDNSEnabled.set(it.CorpDNS)
} ?: run {
corpDNSEnabled.set(null)
}
} }
} }
} }

@ -0,0 +1,44 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.ViewModel
import com.tailscale.ipn.App
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.SettingState
import com.tailscale.ipn.ui.util.InstalledApp
import com.tailscale.ipn.ui.util.InstalledAppsManager
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class SplitTunnelAppPickerViewModel : ViewModel() {
val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager)
val excludedPackageNames: StateFlow<List<String>> = MutableStateFlow(listOf())
val installedApps: StateFlow<List<InstalledApp>> = MutableStateFlow(listOf())
val mdmExcludedPackages: StateFlow<SettingState<String?>> = MDMSettings.excludedPackages.flow
val mdmIncludedPackages: StateFlow<SettingState<String?>> = MDMSettings.includedPackages.flow
init {
installedApps.set(installedAppsManager.fetchInstalledApps())
excludedPackageNames.set(
App.get()
.disallowedPackageNames()
.intersect(installedApps.value.map { it.packageName }.toSet())
.toList())
}
fun exclude(packageName: String) {
if (excludedPackageNames.value.contains(packageName)) {
return
}
excludedPackageNames.set(excludedPackageNames.value + packageName)
App.get().addUserDisallowedPackageName(packageName)
}
fun unexclude(packageName: String) {
excludedPackageNames.set(excludedPackageNames.value - packageName)
App.get().removeUserDisallowedPackageName(packageName)
}
}

@ -4,7 +4,6 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import android.content.Context import android.content.Context
import android.util.Log
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -26,6 +25,7 @@ import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.view.ActivityIndicator import com.tailscale.ipn.ui.view.ActivityIndicator
import com.tailscale.ipn.ui.view.CheckedIndicator import com.tailscale.ipn.ui.view.CheckedIndicator
import com.tailscale.ipn.ui.view.ErrorDialogType import com.tailscale.ipn.ui.view.ErrorDialogType
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -36,13 +36,14 @@ class TaildropViewModelFactory(
private val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>>, private val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>>,
private val applicationScope: CoroutineScope private val applicationScope: CoroutineScope
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return TaildropViewModel(requestedTransfers, applicationScope) as T return TaildropViewModel(requestedTransfers, applicationScope) as T
} }
} }
class TaildropViewModel( class TaildropViewModel(
val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>>, private val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>>,
private val applicationScope: CoroutineScope private val applicationScope: CoroutineScope
) : IpnViewModel() { ) : IpnViewModel() {
@ -143,7 +144,7 @@ class TaildropViewModel(
allSharablePeers.filter { !(it.Online ?: false) }.sortedBy { it.Name } allSharablePeers.filter { !(it.Online ?: false) }.sortedBy { it.Name }
myPeers.set(onlinePeers + offlinePeers) myPeers.set(onlinePeers + offlinePeers)
} }
.onFailure { Log.e(TAG, "Error loading targets: ${it.message}") } .onFailure { TSLog.e(TAG, "Error loading targets: ${it.message}") }
} }
} }
@ -153,7 +154,7 @@ class TaildropViewModel(
fun TrailingContentForPeer(peerId: String) { fun TrailingContentForPeer(peerId: String) {
// Check our outgoing files for the peer and determine the state of the transfer. // Check our outgoing files for the peer and determine the state of the transfer.
val transfers = this.transfers.collectAsState().value.filter { it.PeerID == peerId } val transfers = this.transfers.collectAsState().value.filter { it.PeerID == peerId }
var status: TransferState = transferState(transfers) ?: return val status: TransferState = transferState(transfers) ?: return
// Still no status? Nothing to render for this peer // Still no status? Nothing to render for this peer

@ -16,7 +16,8 @@ import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
class TailnetLockSetupViewModelFactory() : ViewModelProvider.Factory { class TailnetLockSetupViewModelFactory : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return TailnetLockSetupViewModel() as T return TailnetLockSetupViewModel() as T
} }
@ -24,7 +25,7 @@ class TailnetLockSetupViewModelFactory() : ViewModelProvider.Factory {
data class StatusItem(@StringRes val title: Int, @DrawableRes val icon: Int) data class StatusItem(@StringRes val title: Int, @DrawableRes val icon: Int)
class TailnetLockSetupViewModel() : IpnViewModel() { class TailnetLockSetupViewModel : IpnViewModel() {
val statusItems: StateFlow<List<StatusItem>> = MutableStateFlow(emptyList()) val statusItems: StateFlow<List<StatusItem>> = MutableStateFlow(emptyList())
val nodeKey: StateFlow<String> = MutableStateFlow("unknown") val nodeKey: StateFlow<String> = MutableStateFlow("unknown")

@ -3,11 +3,6 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.view.ErrorDialogType import com.tailscale.ipn.ui.view.ErrorDialogType
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -19,49 +14,4 @@ class UserSwitcherViewModel : IpnViewModel() {
// True if we should render the kebab menu // True if we should render the kebab menu
val showHeaderMenu: StateFlow<Boolean> = MutableStateFlow(false) val showHeaderMenu: StateFlow<Boolean> = MutableStateFlow(false)
// Sets the custom control URL and immediatly invokes the login flow
fun setControlURL(urlStr: String) {
// Some basic checks that the entered URL is "reasonable". The underlying
// localAPIClient will use the default server if we give it a broken URL,
// but we can make sure we can construct a URL from the input string and
// ensure it has an http/https scheme
when (urlStr.startsWith("http") && urlStr.contains("://") && urlStr.length > 7) {
false -> errorDialog.set(ErrorDialogType.INVALID_CUSTOM_URL)
true -> {
showHeaderMenu.set(false)
// We need to have the current prefs to set them back with the new control URL
val prefs = Notifier.prefs.value
if (prefs == null) {
errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED)
return
}
// The basic flow for logging in with a custom control URL is to add a profile,
// call start with prefs that include the control URL pref, then
// start an interactive login.
val fail: (Throwable) -> Unit = { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) }
val login = {
Client(viewModelScope).startLoginInteractive { startLogin -> startLogin.onFailure(fail) }
}
val start = {
prefs.ControlURL = urlStr
val options = Ipn.Options(Prefs = prefs)
Client(viewModelScope).start(options) { start ->
start.onFailure(fail).onSuccess { login() }
}
}
Client(viewModelScope).addProfile { addProfile ->
addProfile.onFailure(fail).onSuccess { start() }
}
}
}
}
} }

@ -0,0 +1,65 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import android.app.Application
import android.net.VpnService
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class VpnViewModelFactory(private val application: Application) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(VpnViewModel::class.java)) {
return VpnViewModel(application) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
// Application context aware view model that tracks whether the VPN has been prepared. This must be
// application scoped because Tailscale might be toggled on and off outside of the activity
// lifecycle.
class VpnViewModel(application: Application) : AndroidViewModel(application) {
// Whether the VPN is prepared. This is set to true if the VPN application is already prepared, or
// if the user has previously consented to the VPN application. This is used to determine whether
// a VPN permission launcher needs to be shown.
val _vpnPrepared = MutableStateFlow(false)
val vpnPrepared: StateFlow<Boolean> = _vpnPrepared
// Whether a VPN interface has been established. This is set by net.updateTUN upon
// VpnServiceBuilder.establish, and consumed by UI to reflect VPN state.
val _vpnActive = MutableStateFlow(false)
val vpnActive: StateFlow<Boolean> = _vpnActive
val TAG = "VpnViewModel"
init {
prepareVpn()
}
private fun prepareVpn() {
// Check if the user has granted permission yet.
if (!vpnPrepared.value) {
val vpnIntent = VpnService.prepare(getApplication())
if (vpnIntent != null) {
setVpnPrepared(false)
Log.d(TAG, "VpnService.prepare returned non-null intent")
} else {
setVpnPrepared(true)
Log.d(TAG, "VpnService.prepare returned null intent, VPN is already prepared")
}
}
}
fun setVpnActive(isActive: Boolean) {
_vpnActive.value = isActive
}
fun setVpnPrepared(isPrepared: Boolean) {
_vpnPrepared.value = isPrepared
}
}

@ -0,0 +1,44 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.util
import android.util.Log
import libtailscale.Libtailscale
object TSLog {
var libtailscaleWrapper = LibtailscaleWrapper()
fun d(tag: String?, message: String) {
Log.d(tag, message)
libtailscaleWrapper.sendLog(tag, message)
}
fun w(tag: String, message: String) {
Log.w(tag, message)
libtailscaleWrapper.sendLog(tag, message)
}
// Overloaded function without Throwable because Java does not support default parameters
@JvmStatic
fun e(tag: String?, message: String) {
Log.e(tag, message)
libtailscaleWrapper.sendLog(tag, message)
}
fun e(tag: String?, message: String, throwable: Throwable? = null) {
if (throwable == null) {
Log.e(tag, message)
libtailscaleWrapper.sendLog(tag, message)
} else {
Log.e(tag, message, throwable)
libtailscaleWrapper.sendLog(tag, "$message ${throwable?.localizedMessage}")
}
}
class LibtailscaleWrapper {
public fun sendLog(tag: String?, message: String) {
val logTag = tag ?: ""
Libtailscale.sendLog((logTag + ": " + message).toByteArray(Charsets.UTF_8))
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 641 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 879 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

@ -1,41 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="200dp"
android:viewportWidth="200"
android:viewportHeight="200">
<path
android:pathData="M0,0h200v200h-200z"
android:fillColor="#1F1E1E"/>
<path
android:pathData="M50,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#ffffff"
android:fillAlpha="0.4"/>
<path
android:pathData="M87.5,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#ffffff"
android:fillAlpha="0.4"/>
<path
android:pathData="M125,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#ffffff"
android:fillAlpha="0.4"/>
<path
android:pathData="M50,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#ffffff"/>
<path
android:pathData="M87.5,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#ffffff"/>
<path
android:pathData="M125,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#ffffff"/>
<path
android:pathData="M50,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#ffffff"
android:fillAlpha="0.4"/>
<path
android:pathData="M87.5,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#ffffff"/>
<path
android:pathData="M125,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#ffffff"
android:fillAlpha="0.4"/>
</vector>

@ -1,34 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="200dp"
android:viewportWidth="200"
android:viewportHeight="200">
<path
android:pathData="M50,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#cccccc"/>
<path
android:pathData="M87.5,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#cccccc"/>
<path
android:pathData="M125,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#cccccc"/>
<path
android:pathData="M50,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#222222"/>
<path
android:pathData="M87.5,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#222222"/>
<path
android:pathData="M125,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#222222"/>
<path
android:pathData="M50,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#cccccc"/>
<path
android:pathData="M87.5,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#222222"/>
<path
android:pathData="M125,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#cccccc"/>
</vector>

@ -0,0 +1,38 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="100dp"
android:height="100dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M0,12.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#000000"
android:fillAlpha="0.4"/>
<path
android:pathData="M37.5,12.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#000000"
android:fillAlpha="0.4"/>
<path
android:pathData="M75,12.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#000000"
android:fillAlpha="0.4"/>
<path
android:pathData="M-0,50a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#000000"/>
<path
android:pathData="M37.5,50a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#000000"/>
<path
android:pathData="M75,50a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#000000"/>
<path
android:pathData="M-0,87.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#000000"
android:fillAlpha="0.4"/>
<path
android:pathData="M37.5,87.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#000000"/>
<path
android:pathData="M75,87.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
android:fillColor="#000000"
android:fillAlpha="0.4"/>
</vector>

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

Loading…
Cancel
Save