Compare commits

...

204 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>
2 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>
3 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>
7 days 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>
7 days ago
kari-ts 9654bb5d9d
android: include hex in LoginQRView (#502)
Updates tailscale/tailscale#13277

Signed-off-by: kari-ts <kari@tailscale.com>
7 days 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>
1 week 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>
1 week 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>
3 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>
4 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>
5 months ago
Andrea Gottardo 56da7b66d0
android: bump OSS and bump version code to 206 (#329)
Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
5 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>
5 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>
5 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
Percy Wegmann a77edc6724 android/ui: add support for themeing launcher icon
Updates tailscale/corp#19045

Signed-off-by: Percy Wegmann <percy@tailscale.com>
6 months ago
Percy Wegmann d396fdab27 android/ui: more UI tweaks
1. Add title to internal debug options

Updates tailscale/corp#19045

Signed-off-by: Percy Wegmann <percy@tailscale.com>
6 months ago
Percy Wegmann 0ae9da385e android/ui: move bug report helper text to its own list item
Updates tailscale/corp#19045

Signed-off-by: Percy Wegmann <percy@tailscale.com>
6 months ago
Percy Wegmann 9054264363 android/ui: sort self peer first in list of peers
Closes tailscale/corp#19111

Signed-off-by: Percy Wegmann <percy@tailscale.com>
6 months ago
Jonathan Nobels 11f52ad96b
android/ui: add state subheadings to settings rows (#311)
Fixes tailscale/corp#19044

Add version, dns state, and tailnet lock status as settings option subtitles.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
6 months ago
Percy Wegmann 482b350ce0 android: add smoke test
The test verifies that one can log in via the UI and hit hello.ts.net.

Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
6 months ago
kari-ts c8d1b30918
ui: show 'No results' when search returns empty (#309)
Updates tailscale/corp#18202

Signed-off-by: kari-ts <kari@tailscale.com>
6 months ago
kari-ts 6a00880f61
ui: port syspolicy handler code to new app (#304)
* ui: port syspolicy handler code to new app

port over https://github.com/tailscale/tailscale-android/pull/199 from cmd/tailscale and legacy_android to libtailscale and android/

Updates tailscale/corp#18202

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

* android: PR suggestions for syspolicyHandler (#308)

Updates tailscale/corp#18202

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

---------

Signed-off-by: kari-ts <kari@tailscale.com>
Signed-off-by: Percy Wegmann <percy@tailscale.com>
Co-authored-by: Percy Wegmann <percy@tailscale.com>
6 months ago
Jonathan Nobels a3638f9fc7
android/ui: fix accessibility font size issues (#307)
updates tailscale/corp#19057

Adds scrollability to some of the previously fixed views so they render properly with larger font sizes or smaller screens.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
6 months ago
Percy Wegmann c59c8537cf android/ui: show count of Mullvad countries, also fix navigation-related crash when adding new account
Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
6 months ago
Jonathan Nobels cc244812a6
android/ui: prevent navigation before we've added content (#306)
Fixes tailscale/corp#19070

If the activity hadn't yet been created, we can still get an onIntent which
was assuming the navController had been instantiated.  Switched that to
and optional so that we can null check it.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
6 months ago
kari-ts a325a90558
Revert "ui: port syspolicy handler code to new app (#302)" (#303)
This reverts commit f14836a750.
6 months ago
kari-ts f14836a750
ui: port syspolicy handler code to new app (#302)
port over https://github.com/tailscale/tailscale-android/pull/199 from cmd/tailscale and legacy_android to libtailscale and android/

Updates tailscale/corp#18202

Signed-off-by: kari-ts <kari@tailscale.com>
6 months ago
kari-ts 38f57b4737
build.gradle, Makefile: remove custom fdroid build (#297)
We're no longer using GoogleSignIn, so there's no need for separate product flavors
Clean up unused dependencies
Updates tailscale/corp#18202

Signed-off-by: kari-ts <kari@tailscale.com>
6 months ago
Percy Wegmann d676dca4f4 android/ui: navigation improvements
1. More careful back navigation to avoid navigating to blank screen
2. After adding an account, navigate back to main view

Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
6 months ago
Jonathan Nobels 32e407d06b
android/tv: reduce layout width and fix navigation (#295)
fixes tailscale/corp#18956
fixes tailscale/corp#18964

Adds a letterboxing effect as a temporary measure to make the UI a bit more usable on AndroidTV.
Fixes a few navigation peculiarities specific to TV (notably, there some padding on the user avatar so you can see when it's highlighted)
Pops a QR code on AndroidTV where we have no browser to complete the flow.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
6 months ago
Percy Wegmann 9bfa839380 android/ui: make it more obvious that account settings row is clickable
Updates tailscale/corp#18968

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

@ -18,4 +18,3 @@ Client][]. See also the dependencies in the [Tailscale CLI][].
- [{{.Name}}](https://pkg.go.dev/{{.Name}}) ([{{.LicenseName}}]({{.LicenseURL}}))
{{- end }}
- [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'
- name: Build APKs
run: make tailscale-new-debug.apk tailscale-new-fdroid.apk
run: make tailscale-debug.apk
- name: Run tests
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)

8
.gitignore vendored

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

@ -2,24 +2,33 @@
# Use of this source code is governed by a BSD-style
# 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
RELEASE_AAB=tailscale-release.aab
APPID=com.tailscale.ipn
AAR=android_legacy/libs/ipn.aar
KEYSTORE=tailscale.jks
KEYSTORE_ALIAS=tailscale
RELEASE_TV_AAB=tailscale-tv-release.aab
LIBTAILSCALE=android/libs/libtailscale.aar
TAILSCALE_VERSION=$(shell ./version/tailscale-version.sh 200)
OUR_VERSION=$(shell git describe --dirty --exclude "*" --always --abbrev=200)
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)
# 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.
VERSIONNAME_SHORT=$(shell echo $(VERSIONNAME) | cut -d - -f 1)
TAILSCALE_COMMIT=$(shell echo $(TAILSCALE_VERSION) | cut -d - -f 2 | cut -d t -f 2)
# 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)
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
@ -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.
ifeq ($(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
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 ?=
@ -68,11 +96,81 @@ export TOOLCHAINDIR
GOBIN ?= $(PWD)/android/build/go/bin
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
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:
@echo PATH=$(PATH)
@echo ANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT)
@ -80,17 +178,53 @@ env:
@echo ANDROID_STUDIO_ROOT=$(ANDROID_STUDIO_ROOT)
@echo JAVA_HOME=$(JAVA_HOME)
@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}"
tag_release: ## Tag a release
sed -i'.bak' 's/versionCode $(VERSIONCODE)/versionCode $(VERSIONCODE_PLUSONE)/' android_legacy/build.gradle && rm android_legacy/build.gradle.bak
sed -i'.bak' 's/versionName .*/versionName "$(VERSION_LONG)"/' android_legacy/build.gradle && rm android_legacy/build.gradle.bak
git commit -sm "android: bump version code" android_legacy/build.gradle
git tag -a "$(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}"
bumposs: ## Update the tailscale.com go module
GOPROXY=direct go get tailscale.com@main
go run tailscale.com/cmd/printdep --go > go.toolchain.rev
go mod tidy -compat=1.22
.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
.PHONY: update-version
update-version: ## Update the version in build.gradle
sed -i'.bak' 's/versionName .*/versionName "$(VERSION_LONG)"/' android/build.gradle && rm android/build.gradle.bak
.PHONY: update-oss
update-oss: ## Update the tailscale.com go module and update the version in build.gradle
GOPROXY=direct ./tool/go get tailscale.com@main
./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.
$(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
rm -rf $(ANDROID_HOME)/tmp
.PHONY: androidsdk
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
$(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
# 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.
.PHONY: checkandroidsdk
checkandroidsdk: ## Check that Android SDK is installed
@$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --list_installed | grep -q 'ndk' || (\
echo -e "\n\tERROR: Android SDK not installed.\n\
@ -118,90 +254,74 @@ checkandroidsdk: ## Check that Android SDK is installed
\tANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT)\n\n\
See README.md for instructions on how to install the prerequisites.\n"; exit 1)
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'
$(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)
tailscale-new-fdroid.apk: $(LIBTAILSCALE)
(cd android && ./gradlew test assembleFdroidDebug)
mv android/build/outputs/apk/fdroid/debug/android-fdroid-debug.apk $@
tailscale-new-debug.apk: $(LIBTAILSCALE)
(cd android && ./gradlew test assemblePlayDebug)
mv android/build/outputs/apk/play/debug/android-play-debug.apk $@
tailscale-new-debug: tailscale-new-debug.apk ## Build the new debug APK
test: $(LIBTAILSCALE) ## Run the Android tests
.PHONY: test
test: gradle-dependencies ## Run the Android tests
(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 $<
.PHONY: run
run: install ## Run the debug APK on a connected device
adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.IPNActivity
dockershell: ## Run a shell in the Docker build container
docker build -t tailscale-android .
docker run -v $(CURDIR):/build/tailscale-android -it --rm tailscale-android
clean: ## Remove build artifacts
-rm -rf android/build android_legacy/build $(DEBUG_APK) $(RELEASE_AAB) $(AAR) $(LIBTAILSCALE) android/libs tailscale-fdroid.apk *.apk
adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.MainActivity
.PHONY: docker-build-image
docker-build-image: ## Builds the docker image for the android build environment if it does not exist
@echo "Checking if docker image $(DOCKER_IMAGE) already exists..."
@if ! docker images $(DOCKER_IMAGE) -q | grep -q . ; then \
echo "Image does not exist. Building..."; \
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
.PHONY: help
help: ## Show this help
@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}'
@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

@ -10,13 +10,19 @@ This repository contains the open source Tailscale Android client.
## 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"
alt="Get it on Google Play"
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
@ -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:
```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
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
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
On the Fire Stick:
@ -129,7 +109,7 @@ Then some useful commands:
```
adb connect 10.2.200.213:5555
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
```
@ -151,8 +131,8 @@ Origin](https://en.wikipedia.org/wiki/Developer_Certificate_of_Origin)
## About Us
We are apenwarr, bradfitz, crawshaw, danderson, dfcarney,
from Tailscale Inc.
You can learn more about us from [our website](https://tailscale.com).
We are [Tailscale](https://tailscale.com). See
https://tailscale.com/company for more about us and what we're
building.
WireGuard is a registered trademark of Jason A. Donenfeld.

@ -1,6 +1,6 @@
buildscript {
ext.kotlin_version = "1.9.22"
ext.kotlin_compose_version = "1.5.10"
ext.compose_version = "1.5.10"
ext.accompanist_version = "0.34.0"
repositories {
@ -11,7 +11,7 @@ buildscript {
}
}
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-serialization:$kotlin_version"
classpath("com.ncorti.ktfmt.gradle:plugin:0.17.0")
@ -37,81 +37,147 @@ android {
defaultConfig {
minSdkVersion 26
targetSdkVersion 34
versionCode 198
versionName "1.59.53-t0f042b981-g1017015de26"
versionCode 241
versionName "1.75.11-t8b962f23d-gf07d419a125"
// 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 {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
lintOptions {
warningsAsErrors true
}
kotlinOptions {
jvmTarget = "17"
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
buildConfig true
compose true
}
composeOptions {
kotlinCompilerExtensionVersion = "$kotlin_compose_version"
kotlinCompilerExtensionVersion = "$compose_version"
}
flavorDimensions "version"
productFlavors {
fdroid {
// The fdroid flavor contains only free dependencies and is suitable
// for the F-Droid app store.
namespace 'com.tailscale.ipn'
buildTypes {
applicationTest {
initWith debug
manifestPlaceholders.leanbackRequired = false
buildConfigField "String", "GITHUB_USERNAME", "\"" + getLocalProperty("githubUsername", "")+"\""
buildConfigField "String", "GITHUB_PASSWORD", "\"" + getLocalProperty("githubPassword", "")+"\""
buildConfigField "String", "GITHUB_2FA_SECRET", "\"" + getLocalProperty("github2FASecret", "")+"\""
}
play {
// The play flavor contains all features and is for the Play Store.
debug {
manifestPlaceholders.leanbackRequired = false
}
release {
manifestPlaceholders.leanbackRequired = false
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile(
'proguard-android-optimize.txt'),
'proguard-rules.pro'
}
namespace 'com.tailscale.ipn'
release_tv {
initWith release
manifestPlaceholders.leanbackRequired = true
}
}
testBuildType "applicationTest"
}
dependencies {
// Android dependencies.
implementation "androidx.core:core:1.12.0"
implementation 'androidx.core:core-ktx:1.12.0'
implementation "androidx.core:core:1.13.1"
implementation 'androidx.core:core-ktx:1.13.1'
implementation "androidx.browser:browser:1.8.0"
implementation "androidx.security:security-crypto:1.1.0-alpha06"
implementation "androidx.work:work-runtime:2.9.0"
// Kotlin dependencies.
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-android:1.8.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1"
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-reflect:$kotlin_version"
// Compose dependencies.
def composeBom = platform('androidx.compose:compose-bom:2023.06.01')
def composeBom = platform('androidx.compose:compose-bom:2024.06.00')
implementation composeBom
androidTestImplementation composeBom
implementation 'androidx.compose.material3:material3:1.2.1'
implementation 'androidx.compose.material:material-icons-core:1.6.3'
implementation "androidx.compose.ui:ui:1.6.3"
implementation "androidx.compose.ui:ui-tooling:1.6.3"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
implementation 'androidx.activity:activity-compose:1.8.2'
implementation 'androidx.compose.material:material-icons-core:1.6.8'
implementation "androidx.compose.ui:ui:1.6.8"
implementation "androidx.compose.ui:ui-tooling:1.6.8"
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3'
implementation 'androidx.activity:activity-compose:1.9.0'
implementation "com.google.accompanist:accompanist-permissions:$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.
def nav_version = "2.7.7"
implementation "androidx.navigation:navigation-fragment:$nav_version"
implementation "androidx.navigation:navigation-ui:$nav_version"
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
implementation "androidx.navigation:navigation-dynamic-features-fragment:$nav_version"
implementation "androidx.navigation:navigation-compose:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
// Supporting libraries.
implementation("io.coil-kt:coil-compose:2.6.0")
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.
implementation ':libtailscale@aar'
// Tests
testImplementation "junit:junit:4.12"
androidTestImplementation "androidx.navigation:navigation-testing:$nav_version"
// Integration Tests
androidTestImplementation composeBom
androidTestImplementation 'androidx.test:runner:1.6.1'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
implementation 'androidx.test.uiautomator:uiautomator:2.3.0'
// Non-free dependencies.
playImplementation 'com.google.android.gms:play-services-auth:20.7.0'
// Authentication only for tests
androidTestImplementation 'dev.turingcomplete:kotlin-onetimepassword:2.4.0'
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, defaultValue) {
try {
Properties properties = new Properties()
properties.load(project.file('local.properties').newDataInputStream())
return properties.getProperty(key) ?: defaultValue
} catch(Throwable ignored) {
return defaultValue
}
}

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

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=38f66cd6eef217b4c35855bb11ea4e9fbc53594ccccb5fb82dfd317ef8c2c5a3
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
distributionSha256Sum=544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME
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.**

@ -0,0 +1,160 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.os.Build
import android.util.Log
import android.widget.Button
import android.widget.EditText
import androidx.test.ext.junit.rules.activityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiSelector
import dev.turingcomplete.kotlinonetimepassword.HmacAlgorithm
import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordConfig
import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordGenerator
import org.apache.commons.codec.binary.Base32
import org.junit.After
import org.junit.Assert
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@RunWith(AndroidJUnit4::class)
@LargeTest
class MainActivityTest {
companion object {
const val TAG = "MainActivityTest"
}
@get:Rule val activityRule = activityScenarioRule<MainActivity>()
@Before fun setUp() {}
@After fun tearDown() {}
/**
* This test starts with a clean install, logs the user in to a tailnet using credentials provided
* through a build config, and then makes sure we can hit https://hello.ts.net.
*/
@Test
fun loginAndVisitHello() {
val githubUsername = BuildConfig.GITHUB_USERNAME
val githubPassword = BuildConfig.GITHUB_PASSWORD
val github2FASecret = Base32().decode(BuildConfig.GITHUB_2FA_SECRET)
val config =
TimeBasedOneTimePasswordConfig(
codeDigits = 6,
hmacAlgorithm = HmacAlgorithm.SHA1,
timeStep = 30,
timeStepUnit = TimeUnit.SECONDS)
val githubTOTP = TimeBasedOneTimePasswordGenerator(github2FASecret, config)
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
Log.d(TAG, "Wait for VPN permission prompt and accept")
device.find(By.text("Connection request"))
device.find(By.text("OK")).click()
Log.d(TAG, "Click through Get Started screen")
device.find(By.text("Get Started"))
device.find(By.text("Get Started")).click()
asNecessary(
timeout = 2.minutes,
{
Log.d(TAG, "Log in")
device.find(By.text("Log in")).click()
},
{
Log.d(TAG, "Accept Chrome terms and conditions (if necessary)")
device.find(By.text("Welcome to Chrome"))
val dismissIndex =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) 1 else 0
device.find(UiSelector().instance(dismissIndex).className(Button::class.java)).click()
},
{
Log.d(TAG, "Don't turn on sync")
device.find(By.text("Turn on sync?"))
device.find(By.text("No thanks")).click()
},
{
Log.d(TAG, "Log in with GitHub")
device.find(By.text("Sign in with GitHub")).click()
},
{
Log.d(TAG, "Make sure GitHub page has loaded")
device.find(By.text("New to GitHub"))
device.find(By.text("Username or email address"))
device.find(By.text("Sign in"))
},
{
Log.d(TAG, "Enter credentials")
device
.find(UiSelector().instance(0).className(EditText::class.java))
.setText(githubUsername)
device
.find(UiSelector().instance(1).className(EditText::class.java))
.setText(githubPassword)
device.find(By.text("Sign in")).click()
},
{
Log.d(TAG, "Enter 2FA")
device.find(By.text("Two-factor authentication"))
device
.find(UiSelector().instance(0).className(EditText::class.java))
.setText(githubTOTP.generate())
device.find(UiSelector().instance(0).className(Button::class.java)).click()
},
{
Log.d(TAG, "Accept Tailscale app")
device.find(By.text("Learn more about OAuth"))
// Sleep a little to give button time to activate
Thread.sleep(5.seconds.inWholeMilliseconds)
device.find(UiSelector().instance(1).className(Button::class.java)).click()
},
{
Log.d(TAG, "Connect device")
device.find(By.text("Connect device"))
device.find(UiSelector().instance(0).className(Button::class.java)).click()
},
)
try {
Log.d(TAG, "Accept Permission (Either Storage or Notifications)")
device.find(By.text("Continue")).click()
device.find(By.text("Allow")).click()
} catch (t: Throwable) {
// we're not always prompted for permissions, that's okay
}
Log.d(TAG, "Wait for VPN to connect")
device.find(By.text("Connected"))
val helloResponse = helloTSNet
Assert.assertTrue(
"Response from hello.ts.net should show success",
helloResponse.contains("You're connected over Tailscale!"))
}
}
private val helloTSNet: String
get() {
return URL("https://hello.ts.net").run {
openConnection().run {
this as HttpURLConnection
connectTimeout = 30000
readTimeout = 5000
inputStream.bufferedReader().readText()
}
}
}

@ -0,0 +1,92 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.util.Log
import androidx.test.uiautomator.BySelector
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.UiSelector
import androidx.test.uiautomator.Until
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
private val defaultTimeout = 10.seconds
private val threadLocalTimeout = ThreadLocal<Duration>()
/**
* Wait until the specified timeout for the given selector and return the matching UiObject2.
* Timeout defaults to 10 seconds.
*
* @throws Exception if selector is not found within timeout.
*/
fun UiDevice.find(
selector: BySelector,
timeout: Duration = threadLocalTimeout.get() ?: defaultTimeout
): UiObject2 {
wait(Until.findObject(selector), timeout.inWholeMilliseconds)?.let {
return it
} ?: run { throw Exception("not found") }
}
/**
* Wait until the specified timeout for the given selector and return the matching UiObject. Timeout
* defaults to 10 seconds.
*
* @throws Exception if selector is not found within timeout.
*/
fun UiDevice.find(
selector: UiSelector,
timeout: Duration = threadLocalTimeout.get() ?: defaultTimeout
): UiObject {
val obj = findObject(selector)
if (!obj.waitForExists(timeout.inWholeMilliseconds)) {
throw Exception("not found")
}
return obj
}
/**
* Execute an ordered collection of steps as necessary. If an earlier step fails but a subsequent
* step succeeds, this skips the earlier step. This is useful for interruptible sequences like
* logging in that may resume in an intermediate state.
*/
fun asNecessary(timeout: Duration, vararg steps: () -> Unit) {
val interval = 250.milliseconds
// Use a short timeout to avoid waiting on actions that can be skipped
threadLocalTimeout.set(interval)
try {
val start = System.currentTimeMillis()
var furthestSuccessful = -1
while (System.currentTimeMillis() - start < timeout.inWholeMilliseconds) {
for (i in furthestSuccessful + 1 ..< steps.size) {
val step = steps[i]
try {
step()
furthestSuccessful = i
Log.d("TestUtil.asNecessary", "SUCCESS!")
// Going forward, use the normal timeout on the assumption that subsequent steps will
// succeed.
threadLocalTimeout.remove()
} catch (t: Throwable) {
Log.d("TestUtil.asNecessary", t.toString())
// Going forward, use a short timeout to avoid waiting on actions that can be skipped
threadLocalTimeout.set(interval)
}
}
if (furthestSuccessful == steps.size - 1) {
// All steps have completed successfully
return
}
// Still some steps left to run
Thread.sleep(interval.inWholeMilliseconds)
}
throw Exception("failed to complete within timeout")
} finally {
threadLocalTimeout.remove()
}
}

@ -1,15 +1,20 @@
<?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_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission
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.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 -->
<uses-feature
@ -19,7 +24,7 @@
<!-- Signal support for Android TV -->
<uses-feature
android:name="android.software.leanback"
android:required="false" />
android:required="${leanbackRequired}" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
@ -30,6 +35,7 @@
android:banner="@drawable/tv_banner"
android:icon="@mipmap/ic_launcher"
android:label="Tailscale"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.App.SplashScreen">
<activity
@ -55,6 +61,7 @@
android:exported="true"
android:label="@string/app_name"
android:launchMode="singleTask"
android:theme="@style/Theme.MaterialComponents.DayNight.NoActionBar"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.SEND" />
@ -82,19 +89,20 @@
</intent-filter>
</activity>
<receiver
android:name="IPNReceiver"
android:exported="true">
<intent-filter>
<action android:name="com.tailscale.ipn.CONNECT_VPN" />
<action android:name="com.tailscale.ipn.DISCONNECT_VPN" />
<action android:name="com.tailscale.ipn.USE_EXIT_NODE" />
</intent-filter>
</receiver>
<service
android:name=".IPNService"
android:exported="false"
android:foregroundServiceType="systemExempted"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@ -3,97 +3,84 @@
package com.tailscale.ipn
import android.Manifest
import android.app.Activity
import android.app.Application
import android.app.DownloadManager
import android.app.Fragment
import android.app.FragmentTransaction
import android.app.Notification
import android.app.NotificationChannel
import android.app.PendingIntent
import android.app.UiModeManager
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
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.Environment
import android.provider.MediaStore
import android.provider.Settings
import android.util.Log
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
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.MasterKey
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.localapi.Request
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.viewModel.VpnViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import libtailscale.Libtailscale
import java.io.File
import java.io.IOException
import java.net.InetAddress
import java.net.NetworkInterface
import java.security.GeneralSecurityException
import java.util.Locale
class App : Application(), libtailscale.AppContext {
class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
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_NOTIFICATION_ID = 3
private const val TAG = "App"
private val networkConnectivityRequest =
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)
}
private lateinit var appInstance: App
/**
* 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
fun getApplication(): App {
fun get(): App {
appInstance.initOnce()
return appInstance
}
}
val dns = DnsConfig()
var autoConnect = false
var vpnReady = false
private lateinit var connectivityManager: ConnectivityManager
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 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) {
Log.d(s, s1)
@ -101,6 +88,42 @@ class App : Application(), libtailscale.AppContext {
override fun 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
// Set this to enable direct mode for taildrop whereby downloads will be saved directly
@ -108,90 +131,45 @@ class App : Application(), libtailscale.AppContext {
// an app local directory "Taildrop" if we cannot create that. This mode does not support
// user notifications for incoming files.
val directFileDir = this.prepareDownloadsFolder()
app = Libtailscale.start(dataDir, directFileDir.absolutePath, this)
Request.setApp(app)
Notifier.setApp(app)
Notifier.start(applicationScope)
healthNotifier = HealthNotifier(Notifier.health, applicationScope)
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
setAndRegisterNetworkCallbacks()
createNotificationChannel(
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
NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
initViewModels()
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() {
super.onTerminate()
Notifier.stop()
applicationScope.cancel()
private fun initViewModels() {
vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java)
}
fun setWantRunning(wantRunning: Boolean) {
fun setWantRunning(wantRunning: Boolean, onSuccess: (() -> Unit)? = null) {
val callback: (Result<Ipn.Prefs>) -> Unit = { result ->
result.fold(
onSuccess = { _ -> setTileStatus(wantRunning) },
onSuccess = { onSuccess?.invoke() },
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)
.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
// library and writes it to a global encrypted preference store.
@Throws(IOException::class, GeneralSecurityException::class)
@ -218,30 +196,15 @@ class App : Application(), libtailscale.AppContext {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
}
fun setTileReady(ready: Boolean) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return
}
QuickToggleService.setReady(this, ready)
Log.d("App", "Set Tile Ready: $ready $autoConnect")
vpnReady = ready
if (ready && autoConnect) {
startVPN()
}
}
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
/*
* setAbleToStartVPN remembers whether or not we're able to start the VPN
* by storing this in a shared preference. This allows us to check this
* value without needing a fully initialized instance of the application.
*/
private fun updateConnStatus(ableToStartVPN: Boolean) {
setAbleToStartVPN(ableToStartVPN)
QuickToggleService.updateTile()
TSLog.d("App", "Set Tile Ready: $ableToStartVPN")
}
override fun getModelName(): String {
@ -257,138 +220,10 @@ class App : Application(), libtailscale.AppContext {
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 {
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 {
val interfaces: ArrayList<NetworkInterface> =
java.util.Collections.list(NetworkInterface.getNetworkInterfaces())
@ -424,12 +259,7 @@ class App : Application(), libtailscale.AppContext {
return sb.toString()
}
fun isTV(): Boolean {
val mm = getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
return mm.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
}
fun prepareDownloadsFolder(): File {
private fun prepareDownloadsFolder(): File {
var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
try {
@ -437,18 +267,271 @@ class App : Application(), libtailscale.AppContext {
downloads.mkdirs()
}
} 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")
try {
if (!downloads.exists()) {
downloads.mkdirs()
}
} catch (e: Exception) {
Log.e(TAG, "Failed to create Taildrop folder: $e")
TSLog.e(TAG, "Failed to create Taildrop folder: $e")
downloads = File("")
}
}
return downloads
}
@Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyBooleanValue(key: String): Boolean {
return getSyspolicyStringValue(key) == "true"
}
@Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyStringValue(key: String): String {
val setting = MDMSettings.allSettingsByKey[key]?.flow?.value
if (setting?.isSet != true) {
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.Context;
import android.content.Intent;
import androidx.work.Data;
import androidx.work.OneTimeWorkRequest;
import androidx.work.WorkManager;
import java.util.Objects;
/**
* IPNReceiver allows external applications to start the VPN.
*/
public class IPNReceiver extends BroadcastReceiver {
public static final String INTENT_CONNECT_VPN = "com.tailscale.ipn.CONNECT_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
public void onReceive(Context context, Intent intent) {
WorkManager workManager = WorkManager.getInstance(context);
@ -27,5 +33,13 @@ public class IPNReceiver extends BroadcastReceiver {
} else if (Objects.equals(intent.getAction(), INTENT_DISCONNECT_VPN)) {
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.os.Build
import android.system.OsConstants
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.util.TSLog
import libtailscale.Libtailscale
import java.util.UUID
open class IPNService : VpnService(), libtailscale.IPNService {
private val TAG = "IPNService"
private val randomID: String = UUID.randomUUID().toString()
private lateinit var app: App
override fun id(): String {
return randomID
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent != null && ACTION_STOP_VPN == intent.action) {
(applicationContext as App).autoConnect = false
override fun updateVpnStatus(status: Boolean) {
app.getAppScopedViewModel().setVpnActive(status)
}
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()
return START_NOT_STICKY
}
val app = applicationContext as App
if (intent != null && "android.net.VpnService" == intent.action) {
// Start VPN and connect to it due to Always-on VPN
val i = Intent(IPNReceiver.INTENT_CONNECT_VPN)
i.setPackage(packageName)
i.setClass(applicationContext, IPNReceiver::class.java)
sendBroadcast(i)
START_NOT_STICKY
}
ACTION_START_VPN -> {
showForegroundNotification()
app.setWantRunning(true)
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)
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)
if (app.vpnReady && app.autoConnect) {
app.setWantRunning(true)
START_STICKY
} else {
START_NOT_STICKY
}
}
return START_STICKY
}
override public fun close() {
stopForeground(true)
override fun close() {
app.setWantRunning(false) { updateVpnStatus(false) }
Notifier.setState(Ipn.State.Stopping)
stopForeground(STOP_FOREGROUND_REMOVE)
Libtailscale.serviceDisconnect(this)
val app = applicationContext as App
app.setWantRunning(false)
}
override fun onDestroy() {
@ -61,6 +87,20 @@ open class IPNService : VpnService(), libtailscale.IPNService {
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 {
return PendingIntent.getActivity(
this,
@ -72,7 +112,9 @@ open class IPNService : VpnService(), libtailscale.IPNService {
private fun disallowApp(b: Builder, name: String) {
try {
b.addDisallowedApplication(name)
} catch (e: PackageManager.NameNotFoundException) {}
} catch (e: PackageManager.NameNotFoundException) {
TSLog.d(TAG, "Failed to add disallowed application: $e")
}
}
override fun newBuilder(): VPNServiceBuilder {
@ -81,59 +123,36 @@ open class IPNService : VpnService(), libtailscale.IPNService {
.setConfigureIntent(configIntent())
.allowFamily(OsConstants.AF_INET)
.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.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
}
b.setUnderlyingNetworks(null) // Use all available networks.
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
disallowApp(b, "com.google.android.apps.messaging")
// Stadia https://github.com/tailscale/tailscale/issues/3460
disallowApp(b, "com.google.stadia.android")
// Android Auto https://github.com/tailscale/tailscale/issues/3828
disallowApp(b, "com.google.android.projection.gearhead")
// GoPro https://github.com/tailscale/tailscale/issues/2554
disallowApp(b, "com.gopro.smarty")
// Sonos https://github.com/tailscale/tailscale/issues/2548
disallowApp(b, "com.sonos.acr")
disallowApp(b, "com.sonos.acr2")
// Google Chromecast https://github.com/tailscale/tailscale/issues/3636
disallowApp(b, "com.google.android.apps.chromecast.app")
return VPNServiceBuilder(b)
val includedPackages: List<String> =
MDMSettings.includedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
if (includedPackages.isNotEmpty()) {
// If an admin defined a list of packages that are exclusively allowed to be used via
// Tailscale,
// then only allow those apps.
for (packageName in includedPackages) {
TSLog.d(TAG, "Including app: $packageName")
b.addAllowedApplication(packageName)
}
} else {
// Otherwise, prevent certain apps from getting their traffic + DNS routed via Tailscale:
// - any app that the user manually disallowed in the GUI
// - any app that we disallowed via hard-coding
for (disallowedPackageName in UninitializedApp.get().disallowedPackageNames()) {
TSLog.d(TAG, "Disallowing app: $disallowedPackageName")
disallowApp(b, disallowedPackageName)
}
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?) {
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())
return VPNServiceBuilder(b)
}
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"
}
}

@ -3,75 +3,100 @@
package com.tailscale.ipn
import android.annotation.SuppressLint
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.content.RestrictionsManager
import android.content.pm.ActivityInfo
import android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE
import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK
import android.net.Uri
import android.net.VpnService
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.navigation.navigation
import com.tailscale.ipn.Peer.RequestCodes
import com.tailscale.ipn.mdm.MDMSettings
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.util.AndroidTVUtil
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.util.universalFit
import com.tailscale.ipn.ui.view.AboutView
import com.tailscale.ipn.ui.view.BackNavigation
import com.tailscale.ipn.ui.view.BugReportView
import com.tailscale.ipn.ui.view.DNSSettingsView
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.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.MainView
import com.tailscale.ipn.ui.view.MainViewNavigation
import com.tailscale.ipn.ui.view.ManagedByView
import com.tailscale.ipn.ui.view.MullvadExitNodePicker
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.PermissionsView
import com.tailscale.ipn.ui.view.RunExitNodeView
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.UserSwitcherNav
import com.tailscale.ipn.ui.view.UserSwitcherView
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 kotlinx.coroutines.CoroutineScope
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private lateinit var requestVpnPermission: ActivityResultLauncher<Unit>
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 {
// Request codes for Android callbacks.
// requestPrepareVPN is for when Android's VpnService.prepare completes.
@JvmStatic val requestPrepareVPN: Int = 1001
const val WRITE_STORAGE_RESULT = 1000
private const val TAG = "Main Activity"
private const val START_AT_ROOT = "startAtRoot"
}
private fun Context.isLandscapeCapable(): Boolean {
@ -79,9 +104,19 @@ class MainActivity : ComponentActivity() {
SCREENLAYOUT_SIZE_LARGE
}
// The loginQRCode is used to track whether or not we should be rendering a QR code
// to the user. This is used only on TV platforms with no browser in lieu of
// simply opening the URL. This should be consumed once it has been handled.
private val loginQRCode: StateFlow<String?> = MutableStateFlow(null)
@SuppressLint("SourceLockedOrientationActivity")
override fun onCreate(savedInstanceState: Bundle?) {
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
// proper landscape layout support
if (!isLandscapeCapable()) {
@ -90,9 +125,29 @@ class MainActivity : ComponentActivity() {
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 {
AppTheme {
val navController = rememberNavController()
navController = rememberNavController()
Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox
Surface(modifier = Modifier.universalFit()) { // Letterbox for AndroidTV
NavHost(
navController = navController,
startDestination = "main",
@ -108,6 +163,10 @@ class MainActivity : ComponentActivity() {
popExitTransition = {
slideOutHorizontally(animationSpec = tween(150), targetOffsetX = { it })
}) {
fun backTo(route: String): () -> Unit = {
navController.popBackStack(route = route, inclusive = false)
}
val mainViewNav =
MainViewNavigation(
onNavigateToSettings = { navController.navigate("settings") },
@ -115,69 +174,88 @@ class MainActivity : ComponentActivity() {
navController.navigate("peerDetails/${it.StableID}")
},
onNavigateToExitNodes = { navController.navigate("exitNodes") },
)
onNavigateToHealth = { navController.navigate("health") })
val settingsNav =
SettingsNav(
onNavigateToBugReport = { navController.navigate("bugReport") },
onNavigateToAbout = { navController.navigate("about") },
onNavigateToDNSSettings = { navController.navigate("dnsSettings") },
onNavigateToSplitTunneling = { navController.navigate("splitTunneling") },
onNavigateToTailnetLock = { navController.navigate("tailnetLock") },
onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") },
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
onNavigateToPermissions = { navController.navigate("permissions") },
onBackPressed = { navController.popBackStack() },
)
val backNav = BackNavigation(onBack = { navController.popBackStack() })
onBackToSettings = backTo("settings"),
onNavigateBackHome = backTo("main"))
val exitNodePickerNav =
ExitNodePickerNav(
onNavigateHome = {
onNavigateBackHome = {
navController.popBackStack(route = "main", inclusive = false)
},
onNavigateBack = { navController.popBackStack() },
onNavigateToExitNodePicker = { navController.popBackStack() },
onNavigateBackToExitNodes = backTo("exitNodes"),
onNavigateToMullvad = { navController.navigate("mullvad") },
onNavigateToMullvadInfo = { navController.navigate("mullvad_info") },
onNavigateBackToMullvad = backTo("mullvad"),
onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") },
onNavigateToRunAsExitNode = { navController.navigate("runExitNode") })
composable("main") { MainView(navigation = mainViewNav) }
val userSwitcherNav =
UserSwitcherNav(
backToSettings = backTo("settings"),
onNavigateHome = backTo("main"),
onNavigateCustomControl = {
navController.navigate("loginWithCustomControl")
},
onNavigateToAuthKey = { navController.navigate("loginWithAuthKey") })
composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) {
MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel)
}
composable("settings") { SettingsView(settingsNav) }
navigation(startDestination = "list", route = "exitNodes") {
composable("list") { ExitNodePicker(exitNodePickerNav) }
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
composable("health") { HealthView(backTo("main")) }
composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) }
composable("mullvad_info") { MullvadInfoView(exitNodePickerNav) }
composable(
"mullvad/{countryCode}",
arguments = listOf(navArgument("countryCode") { type = NavType.StringType })) {
arguments =
listOf(navArgument("countryCode") { type = NavType.StringType })) {
MullvadExitNodePicker(
it.arguments!!.getString("countryCode")!!, exitNodePickerNav)
}
composable("runExitNode") { RunExitNodeView(exitNodePickerNav) }
}
composable(
"peerDetails/{nodeId}",
arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) {
PeerDetails(nav = backNav, it.arguments?.getString("nodeId") ?: "")
}
composable("bugReport") { BugReportView(nav = backNav) }
composable("dnsSettings") { DNSSettingsView(nav = backNav) }
composable("tailnetLock") { TailnetLockSetupView(nav = backNav) }
composable("about") { AboutView(nav = backNav) }
composable("mdmSettings") { MDMSettingsDebugView(nav = backNav) }
composable("managedBy") { ManagedByView(nav = backNav) }
composable("userSwitcher") {
UserSwitcherView(
nav = backNav,
onNavigateHome = {
navController.popBackStack(route = "main", inclusive = false)
})
}
PeerDetails(
backTo("main"),
it.arguments?.getString("nodeId") ?: "",
PingViewModel())
}
composable("bugReport") { BugReportView(backTo("settings")) }
composable("dnsSettings") { DNSSettingsView(backTo("settings")) }
composable("splitTunneling") { SplitTunnelAppPickerView(backTo("settings")) }
composable("tailnetLock") { TailnetLockSetupView(backTo("settings")) }
composable("about") { AboutView(backTo("settings")) }
composable("mdmSettings") { MDMSettingsDebugView(backTo("settings")) }
composable("managedBy") { ManagedByView(backTo("settings")) }
composable("userSwitcher") { UserSwitcherView(userSwitcherNav) }
composable("permissions") {
PermissionsView(nav = backNav, openApplicationSettings = ::openApplicationSettings)
PermissionsView(backTo("settings"), ::openApplicationSettings)
}
composable("intro", exitTransition = { fadeOut(animationSpec = tween(150)) }) {
IntroView(backTo("main"))
}
composable("loginWithAuthKey") {
LoginWithAuthKeyView(onNavigateHome = backTo("main"), backTo("userSwitcher"))
}
composable("loginWithCustomControl") {
LoginWithCustomControlURLView(
onNavigateHome = backTo("main"), backTo("userSwitcher"))
}
composable("intro") { IntroView { navController.popBackStack() } }
}
// Show the intro screen one time
@ -187,26 +265,81 @@ class MainActivity : ComponentActivity() {
}
}
}
lifecycleScope.launch {
Notifier.readyToPrepareVPN.collect { isReady ->
if (isReady)
App.getApplication().prepareVPN(this@MainActivity, RequestCodes.requestPrepareVPN)
// Login actions are app wide. If we are told about a browse-to-url, we should render it
// over whatever screen we happen to be on.
loginQRCode.collectAsState().value?.let {
LoginQRView(onDismiss = { loginQRCode.set(null) })
}
}
}
}
init {
// Watch the model's browseToURL and launch the browser when it changes
// This will trigger the login flow
// Watch the model's browseToURL and launch the browser when it changes or
// pop up a QR code to scan
lifecycleScope.launch {
Notifier.browseToURL.collect { url -> url?.let { Dispatchers.Main.run { login(it) } } }
Notifier.browseToURL.collect { url ->
url?.let {
when (useQRCodeLogin()) {
false -> Dispatchers.Main.run { login(it) }
true -> loginQRCode.set(it)
}
}
}
}
// Once we see a loginFinished event, clear the QR code which will dismiss the QR dialog.
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
// for login requests
private fun useQRCodeLogin(): Boolean {
return AndroidTVUtil.isAndroidTV()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if (intent.getBooleanExtra(START_AT_ROOT, false)) {
if (this::navController.isInitialized) {
navController.popBackStack(route = "main", inclusive = false)
}
}
}
private fun login(urlString: String) {
// Launch coroutine to listen for state changes. When the user completes login, relaunch
// MainActivity to bring the app back to focus.
App.getApplication().applicationScope.launch {
App.get().applicationScope.launch {
try {
Notifier.state.collect { state ->
if (state > Ipn.State.NeedsMachineAuth) {
@ -215,6 +348,7 @@ class MainActivity : ComponentActivity() {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
action = Intent.ACTION_MAIN
addCategory(Intent.CATEGORY_LAUNCHER)
putExtra(START_AT_ROOT, true)
}
startActivity(intent)
@ -223,7 +357,7 @@ class MainActivity : ComponentActivity() {
}
}
} catch (e: Exception) {
Log.e(TAG, "Login: failed to start MainActivity: $e")
TSLog.e(TAG, "Login: failed to start MainActivity: $e")
}
}
@ -237,7 +371,7 @@ class MainActivity : ComponentActivity() {
val fallbackIntent = Intent(Intent.ACTION_VIEW, url)
startActivity(fallbackIntent)
} catch (e: Exception) {
Log.e(TAG, "Login: failed to open browser: $e")
TSLog.e(TAG, "Login: failed to open browser: $e")
}
}
}
@ -246,45 +380,25 @@ class MainActivity : ComponentActivity() {
super.onResume()
val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
lifecycleScope.launch(Dispatchers.IO) {
MDMSettings.update(App.getApplication(), restrictionsManager)
}
lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
}
override fun 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() {
super.onStop()
val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
lifecycleScope.launch(Dispatchers.IO) {
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)
}
lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
}
private fun openApplicationSettings() {
val intent =
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", packageName, null))
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
}
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
@ -301,9 +415,9 @@ class MainActivity : ComponentActivity() {
}
}
class VpnPermissionContract : ActivityResultContract<Unit, Boolean>() {
override fun createIntent(context: Context, input: Unit): Intent {
return VpnService.prepare(context) ?: Intent()
class VpnPermissionContract : ActivityResultContract<Intent, Boolean>() {
override fun createIntent(context: Context, input: Intent): Intent {
return input
}
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;
import android.content.Context;
import android.app.PendingIntent;
import android.content.Intent;
import android.os.Build;
import android.service.quicksettings.Tile;
import android.service.quicksettings.TileService;
public class QuickToggleService extends TileService {
// lock protects the static fields below it.
private static final Object lock = new Object();
// Active tracks whether the VPN is active.
private static boolean active;
// Ready tracks whether the tailscale backend is
// ready to switch on/off.
private static boolean ready;
// isRunning tracks whether the VPN is running.
private static boolean isRunning;
// currentTile tracks getQsTile while service is listening.
private static Tile currentTile;
private static void updateTile() {
public static void updateTile() {
var app = UninitializedApp.get();
Tile t;
boolean act;
synchronized (lock) {
t = currentTile;
act = active && ready;
act = isRunning && app.isAbleToStartVPN();
}
if (t == null) {
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.updateTile();
}
static void setReady(Context ctx, boolean rdy) {
static void setVPNRunning(boolean running) {
synchronized (lock) {
ready = rdy;
}
updateTile();
}
static void setStatus(Context ctx, boolean act) {
synchronized (lock) {
active = act;
isRunning = running;
}
updateTile();
}
@ -62,29 +60,40 @@ public class QuickToggleService extends TileService {
}
}
@SuppressWarnings("deprecation")
@Override
public void onClick() {
boolean r;
synchronized (lock) {
r = ready;
r = UninitializedApp.get().isAbleToStartVPN();
}
if (r) {
// Get the application to make sure it initializes
App.get();
onTileClick();
} else {
// Start main activity.
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);
}
}
}
private void onTileClick() {
boolean act;
UninitializedApp app = UninitializedApp.get();
boolean needsToStop;
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,17 +8,21 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.OpenableColumns
import android.util.Log
import android.webkit.MimeTypeMap
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.lifecycle.lifecycleScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
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.util.set
import com.tailscale.ipn.ui.util.universalFit
import com.tailscale.ipn.ui.view.TaildropView
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlin.random.Random
// ShareActivity is the entry point for Taildrop share intents
class ShareActivity : ComponentActivity() {
@ -26,25 +30,27 @@ class ShareActivity : ComponentActivity() {
private val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>> = MutableStateFlow(emptyList())
override fun onCreate(state: Bundle?) {
super.onCreate(state)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AppTheme { TaildropView(requestedTransfers, (application as App).applicationScope) }
AppTheme {
Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox
Surface(modifier = Modifier.universalFit()) {
TaildropView(requestedTransfers, (application as App).applicationScope)
}
}
}
}
}
override fun onStart() {
super.onStart()
Notifier.start(lifecycleScope)
// Ensure our app instance is initialized
App.get()
loadFiles()
}
override fun onStop() {
super.onStop()
Notifier.stop()
}
override fun onNewIntent(intent: Intent?) {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
loadFiles()
@ -53,19 +59,19 @@ class ShareActivity : ComponentActivity() {
// Loads the files from the intent.
fun loadFiles() {
if (intent == null) {
Log.e(TAG, "Share failure - No intent found")
TSLog.e(TAG, "Share failure - No intent found")
return
}
val act = intent.action
val uris: List<Uri?>?
uris =
val uris: List<Uri?>? =
when (act) {
Intent.ACTION_SEND -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java))
} else {
@Suppress("DEPRECATION")
listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)
}
}
@ -73,11 +79,11 @@ class ShareActivity : ComponentActivity() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra<Uri?>(Intent.EXTRA_STREAM, Uri::class.java)
} else {
intent.getParcelableArrayListExtra<Uri?>(Intent.EXTRA_STREAM)
@Suppress("DEPRECATION") intent.getParcelableArrayListExtra<Uri?>(Intent.EXTRA_STREAM)
}
}
else -> {
Log.e(TAG, "No extras found in intent - nothing to share")
TSLog.e(TAG, "No extras found in intent - nothing to share")
null
}
}
@ -88,7 +94,20 @@ class ShareActivity : ComponentActivity() {
val nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeCol = c.getColumnIndex(OpenableColumns.SIZE)
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)
c.close()
val file = Ipn.OutgoingFile(Name = name, DeclaredSize = size)
@ -98,7 +117,7 @@ class ShareActivity : ComponentActivity() {
} ?: emptyList()
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)

@ -11,56 +11,55 @@ import android.content.Intent;
import android.net.VpnService;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import com.tailscale.ipn.util.TSLog;
/**
* A worker that exists to support IPNReceiver.
*/
public final class StartVPNWorker extends Worker {
public StartVPNWorker(
Context appContext,
WorkerParameters workerParams) {
public StartVPNWorker(Context appContext, WorkerParameters workerParams) {
super(appContext, workerParams);
}
@NonNull
@Override
public Result doWork() {
App app = ((App) getApplicationContext());
// We will start the VPN from the background
app.setAutoConnect(true);
// We need to make sure we prepare the VPN Service, just in case it isn't prepared.
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
UninitializedApp app = UninitializedApp.get();
boolean ableToStartVPN = app.isAbleToStartVPN();
if (ableToStartVPN) {
if (VpnService.prepare(app) == null) {
// We're ready and have permissions, start the VPN
app.startVPN();
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
NotificationManager notificationManager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE);
String channelId = "start_vpn_channel";
// 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);
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);
Notification notification = new Notification.Builder(app, channelId)
.setContentTitle("Tailscale Connection Failed")
.setContentText("Tap here to renew permission.")
.setSmallIcon(R.drawable.ic_notification)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build();
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();
notificationManager.notify(1, notification);
return Result.failure();
}
}
}

@ -5,9 +5,13 @@ package com.tailscale.ipn;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
/**
* A worker that exists to support IPNReceiver.
*/
public final class StopVPNWorker extends Worker {
public StopVPNWorker(
@ -16,9 +20,10 @@ public final class StopVPNWorker extends Worker {
super(appContext, workerParams);
}
@NonNull
@Override
public Result doWork() {
App.getApplication().setWantRunning(false);
UninitializedApp.get().stopVPN();
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 libtailscale.ParcelFileDescriptor
import java.net.InetAddress
import android.net.IpPrefix as AndroidIpPrefix
class VPNServiceBuilder(private val builder: VpnService.Builder) : libtailscale.VPNServiceBuilder {
override fun addAddress(p0: String, p1: Int) {
@ -19,6 +21,12 @@ class VPNServiceBuilder(private val builder: VpnService.Builder) : libtailscale.
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) {
builder.addSearchDomain(p0)
}

@ -11,42 +11,89 @@ import kotlin.reflect.full.isSubclassOf
import kotlin.reflect.jvm.jvmErasure
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")
// Handled on the backed
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 loginURL = StringMDMSetting("LoginURL", "Custom control server URL")
val managedByCaption = StringMDMSetting("ManagedByCaption", "Managed By - Caption")
val managedByOrganizationName =
StringMDMSetting("ManagedByOrganizationName", "Managed By - Organization Name")
val managedByURL = StringMDMSetting("ManagedByURL", "Managed By - Support URL")
// Handled on the backend
val tailnet = StringMDMSetting("Tailnet", "Recommended/Required Tailnet Name")
val hiddenNetworkDevices =
StringArrayListMDMSetting("HiddenNetworkDevices", "Hidden Network Device Categories")
// Unused on Android
val allowIncomingConnections =
AlwaysNeverUserDecidesMDMSetting("AllowIncomingConnections", "Allow Incoming Connections")
// Unused on Android
val detectThirdPartyAppConflicts =
AlwaysNeverUserDecidesMDMSetting(
"DetectThirdPartyAppConflicts", "Detect potentially problematic third-party apps")
val exitNodeAllowLANAccess =
AlwaysNeverUserDecidesMDMSetting(
"ExitNodeAllowLANAccess", "Allow LAN Access when using an exit node")
// Handled on the backend
val postureChecking =
AlwaysNeverUserDecidesMDMSetting("PostureChecking", "Enable Posture Checking")
val useTailscaleDNSSettings =
AlwaysNeverUserDecidesMDMSetting("UseTailscaleDNSSettings", "Use Tailscale DNS Settings")
// Unused on Android
val useTailscaleSubnets =
AlwaysNeverUserDecidesMDMSetting("UseTailscaleSubnets", "Use Tailscale Subnets")
val exitNodesPicker = ShowHideMDMSetting("ExitNodesPicker", "Exit Nodes Picker")
val manageTailnetLock = ShowHideMDMSetting("ManageTailnetLock", "“Manage Tailnet lock” menu item")
// Unused on Android
val resetToDefaults = ShowHideMDMSetting("ResetToDefaults", "“Reset to Defaults” menu item")
val runExitNode = ShowHideMDMSetting("RunExitNode", "Run as Exit Node")
// Unused on Android
val testMenu = ShowHideMDMSetting("TestMenu", "Show Debug Menu")
// Unused on Android
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 {
MDMSettings::class
.declaredMemberProperties
@ -57,8 +104,12 @@ object MDMSettings {
.map { it.call(MDMSettings) as MDMSetting<*> }
}
val allSettingsByKey by lazy { allSettings.associateBy { it.key } }
fun update(app: App, restrictionsManager: RestrictionsManager?) {
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
import android.content.SharedPreferences
import android.os.Bundle
import com.tailscale.ipn.App
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
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) {
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) {
val v = getFrom(bundle, app)
flow.set(v)
fun getFrom(bundle: Bundle?, prefs: Lazy<SharedPreferences>): T? {
return when {
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) :
MDMSetting<Boolean>(false, key, localizedTitle) {
override fun getFrom(bundle: Bundle?, app: App) =
bundle?.getBoolean(key) ?: app.getEncryptedPrefs().getBoolean(key, false)
override fun getFromBundle(bundle: Bundle) = bundle.getBoolean(key)
override fun getFromPrefs(prefs: SharedPreferences) = prefs.getBoolean(key, false)
}
class StringMDMSetting(key: String, localizedTitle: String) :
MDMSetting<String?>(null, key, localizedTitle) {
override fun getFrom(bundle: Bundle?, app: App) =
bundle?.getString(key) ?: app.getEncryptedPrefs().getString(key, null)
override fun getFromBundle(bundle: Bundle) = bundle.getString(key)
override fun getFromPrefs(prefs: SharedPreferences) = prefs.getString(key, null)
}
class StringArrayListMDMSetting(key: String, localizedTitle: String) :
MDMSetting<List<String>?>(null, key, localizedTitle) {
override fun getFrom(bundle: Bundle?, app: App) =
bundle?.getStringArrayList(key)
?: app.getEncryptedPrefs().getStringSet(key, HashSet<String>())?.toList()
override fun getFromBundle(bundle: Bundle) = bundle.getStringArrayList(key)
override fun getFromPrefs(prefs: SharedPreferences) =
prefs.getStringSet(key, HashSet<String>())?.toList()
}
class AlwaysNeverUserDecidesMDMSetting(key: String, localizedTitle: String) :
MDMSetting<AlwaysNeverUserDecides>(AlwaysNeverUserDecides.UserDecides, key, localizedTitle) {
override fun getFrom(bundle: Bundle?, app: App): AlwaysNeverUserDecides {
val storedString =
bundle?.getString(key)
?: App.getApplication().getEncryptedPrefs().getString(key, null)
?: "user-decides"
return when (storedString) {
"always" -> {
AlwaysNeverUserDecides.Always
}
"never" -> {
AlwaysNeverUserDecides.Never
}
else -> {
AlwaysNeverUserDecides.UserDecides
}
}
}
override fun getFromBundle(bundle: Bundle) =
AlwaysNeverUserDecides.fromString(bundle.getString(key))
override fun getFromPrefs(prefs: SharedPreferences) =
AlwaysNeverUserDecides.fromString(prefs.getString(key, null))
}
class ShowHideMDMSetting(key: String, localizedTitle: String) :
MDMSetting<ShowHide>(ShowHide.Show, key, localizedTitle) {
override fun getFrom(bundle: Bundle?, app: App): ShowHide {
val storedString =
bundle?.getString(key)
?: App.getApplication().getEncryptedPrefs().getString(key, null)
?: "show"
return when (storedString) {
"hide" -> {
ShowHide.Hide
}
else -> {
ShowHide.Show
}
}
}
override fun getFromBundle(bundle: Bundle) =
ShowHide.fromString(bundle.getString(key))
override fun getFromPrefs(prefs: SharedPreferences) =
ShowHide.fromString(prefs.getString(key, null))
}
enum class AlwaysNeverUserDecides(val value: String) {
Always("always"),
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) {
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
import android.content.Context
import android.util.Log
import com.tailscale.ipn.ui.model.BugReportID
import com.tailscale.ipn.ui.model.Errors
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.IpnState
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.util.TSLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -46,6 +47,7 @@ private object Endpoint {
const val FILES = "files"
const val FILE_PUT = "file-put"
const val TAILFS_SERVER_ADDRESS = "tailfs/fileserver-address"
const val ENABLE_EXIT_NODE = "set-use-exit-node-enabled"
}
typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit
@ -56,6 +58,8 @@ typealias BugReportIdHandler = (Result<BugReportID>) -> 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
* corresponding method on this Client.
@ -72,6 +76,17 @@ class Client(private val scope: CoroutineScope) {
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) {
post(Endpoint.BUG_REPORT, responseHandler = responseHandler)
}
@ -85,6 +100,11 @@ class Client(private val scope: CoroutineScope) {
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) {
get(Endpoint.PROFILES, responseHandler = responseHandler)
}
@ -155,7 +175,7 @@ class Client(private val scope: CoroutineScope) {
})
} catch (e: Exception) {
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))
return
}
@ -200,6 +220,7 @@ class Client(private val scope: CoroutineScope) {
private inline fun <reified T> post(
path: String,
body: ByteArray? = null,
timeoutMillis: Long = 30000,
noinline responseHandler: (Result<T>) -> Unit
) {
Request(
@ -207,6 +228,7 @@ class Client(private val scope: CoroutineScope) {
method = "POST",
path = path,
body = body,
timeoutMillis = timeoutMillis,
responseType = typeOf<T>(),
responseHandler = responseHandler)
.execute()
@ -285,7 +307,7 @@ class Request<T>(
@OptIn(ExperimentalSerializationApi::class)
fun execute() {
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 {
val resp =
if (parts != null) app.callLocalAPIMultipart(timeoutMillis, method, fullPath, parts)
@ -298,6 +320,8 @@ class Request<T>(
// TODO: use the streaming body for performance
// An empty body is a perfectly valid response and indicates success
val respData = resp.bodyBytes() ?: ByteArray(0)
@Suppress("UNCHECKED_CAST")
val response: Result<T> =
when (responseType) {
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
scope.launch { responseHandler(response) }
} 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)) }
}
}

@ -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),
Stopped(4),
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 {
fun fromInt(value: Int): State {
@ -46,6 +50,7 @@ class Ipn {
var IncomingFiles: List<PartialFile>? = null,
var ClientVersion: Tailcfg.ClientVersion? = null,
var TailFSShares: List<String>? = null,
var Health: Health.State? = null,
)
@Serializable
@ -65,7 +70,22 @@ class Ipn {
var ForceDaemon: Boolean = false,
var HostName: String = "",
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
data class MaskedPrefs(
@ -79,6 +99,7 @@ class Ipn {
var AdvertiseRoutesSet: Boolean? = null,
var ForceDaemonSet: Boolean? = null,
var HostnameSet: Boolean? = null,
var InternalExitNodePriorSet: Boolean? = null,
) {
var ControlURL: String? = null
@ -105,6 +126,12 @@ class Ipn {
ExitNodeIDSet = true
}
var InternalExitNodePrior: String? = null
set(value) {
field = value
InternalExitNodePriorSet = true
}
var ExitNodeAllowLANAccess: Boolean? = null
set(value) {
field = value
@ -135,7 +162,7 @@ class Ipn {
ForceDaemonSet = true
}
var Hostname: Boolean? = null
var Hostname: String? = null
set(value) {
field = value
HostnameSet = true
@ -194,7 +221,8 @@ class Ipn {
@Serializable
data class Options(
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 PrimaryRoutes: List<String>? = null,
val Addrs: List<String>? = null,
val CurAddr: String? = null,
val Relay: String? = null,
val Online: Boolean,
val ExitNode: Boolean,
val ExitNodeOption: Boolean,

@ -22,7 +22,7 @@ object Permissions {
@Composable
get() {
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
}
}

@ -7,11 +7,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.off
import com.tailscale.ipn.ui.theme.on
import com.tailscale.ipn.ui.util.ComposableStringFormatter
import com.tailscale.ipn.ui.util.DisplayAddress
import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.viewModel.PeerSettingInfo
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@ -91,6 +93,16 @@ class Tailcfg {
Capabilities?.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
val isExitNode: Boolean =
AllowedIPs?.contains("0.0.0.0/0") ?: false && AllowedIPs?.contains("::/0") ?: false
@ -101,6 +113,22 @@ class Tailcfg {
val displayName: String
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 connectedOrSelfNode(nm: Netmap.NetworkMap?) =
Online == true || StableID == nm?.SelfNode?.StableID
@ -129,7 +157,13 @@ class Tailcfg {
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)))
}
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
import android.util.Log
import com.tailscale.ipn.App
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.Notify
import com.tailscale.ipn.ui.model.Netmap
@ -17,6 +19,7 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
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
// 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 decoder = Json { ignoreUnknownKeys = true }
// Global App State
val tileReady: StateFlow<Boolean> = MutableStateFlow(false)
val readyToPrepareVPN: StateFlow<Boolean> = MutableStateFlow(false)
// 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 prefs: StateFlow<Ipn.Prefs?> = MutableStateFlow(null)
val engineStatus: StateFlow<Ipn.EngineStatus?> = MutableStateFlow(null)
@ -43,6 +43,7 @@ object Notifier {
val browseToURL: StateFlow<String?> = MutableStateFlow(null)
val loginFinished: StateFlow<String?> = MutableStateFlow(null)
val version: StateFlow<String?> = MutableStateFlow(null)
val health: StateFlow<Health.State?> = MutableStateFlow(null)
// Taildrop-specific State
val outgoingFiles: StateFlow<List<Ipn.OutgoingFile>?> = MutableStateFlow(null)
@ -59,12 +60,16 @@ object Notifier {
@OptIn(ExperimentalSerializationApi::class)
fun start(scope: CoroutineScope) {
Log.d(TAG, "Starting")
TSLog.d(TAG, "Starting Notifier")
if (!::app.isInitialized) {
App.get()
}
scope.launch(Dispatchers.IO) {
val mask =
NotifyWatchOpt.Netmap.value or
NotifyWatchOpt.Prefs.value or
NotifyWatchOpt.InitialState.value
NotifyWatchOpt.InitialState.value or
NotifyWatchOpt.InitialHealthState.value
manager =
app.watchNotifications(mask.toLong()) { notification ->
val notify = decoder.decodeFromStream<Notify>(notification.inputStream())
@ -79,16 +84,13 @@ object Notifier {
notify.OutgoingFiles?.let(outgoingFiles::set)
notify.FilesWaiting?.let(filesWaiting::set)
notify.IncomingFiles?.let(incomingFiles::set)
}
state.collect { currstate ->
readyToPrepareVPN.set(currstate > Ipn.State.Stopped)
tileReady.set(currstate >= Ipn.State.Stopped)
notify.Health?.let(health::set)
}
}
}
fun stop() {
Log.d(TAG, "Stopping")
TSLog.d(TAG, "Stopping Notifier")
manager?.let {
it.stop()
manager = null
@ -103,6 +105,12 @@ object Notifier {
Prefs(4),
Netmap(8),
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.TopAppBarDefaults
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@ -27,7 +28,12 @@ import com.google.accompanist.systemuicontroller.rememberSystemUiController
@Composable
fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
val colors = LightColors
val colors =
if (useDarkTheme) {
DarkColors
} else {
LightColors
}
val typography =
Typography(
@ -38,7 +44,8 @@ fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable
// margins in list items.
bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp))
val systemUiController = rememberSystemUiController()
// TODO: Migrate to Activity.enableEdgeToEdge
@Suppress("deprecation") val systemUiController = rememberSystemUiController()
DisposableEffect(systemUiController, useDarkTheme) {
systemUiController.setStatusBarColor(color = colors.surfaceContainer)
@ -75,11 +82,46 @@ private val LightColors =
outlineVariant = Color(0xFFEDEBEA), // gray-200
inverseSurface = Color(0xFF232222), // gray-800
inverseOnSurface = Color(0xFFFFFFFF), // white
scrim = Color(0xFF000000), // 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
get() = Color(0xFFD97916) // yellow-300
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFFBB5504) // yellow-400
} else {
Color(0xFFD97917) // yellow-300
}
val ColorScheme.onWarning: Color
get() = Color(0xFFFFFFFF) // white
@ -106,11 +148,35 @@ val ColorScheme.on: Color
get() = Color(0xFF1CA672) // green-300
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
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.
*/
@ -207,7 +273,24 @@ val ColorScheme.warningListItem: ListItemColors
containerColor = MaterialTheme.colorScheme.warning,
headlineColor = 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,
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
disabledHeadlineColor = default.disabledHeadlineColor,
@ -231,12 +314,131 @@ val ColorScheme.secondaryButton: ButtonColors
@Composable
get() {
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(
containerColor = Color(0xFFB22D30), // red-500
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
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(0xFF6D94EC), // blue-400
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
get() = Color(0xFFAFACAB) // gray-400
@ -245,16 +447,15 @@ val ColorScheme.disabled: Color
val ColorScheme.searchBarColors: TextFieldColors
@Composable
get() {
val defaults = OutlinedTextFieldDefaults.colors()
return OutlinedTextFieldDefaults.colors(
focusedLeadingIconColor = MaterialTheme.colorScheme.onSurface,
unfocusedLeadingIconColor = MaterialTheme.colorScheme.onSurface,
focusedTextColor = MaterialTheme.colorScheme.onSurface,
unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.background,
unfocusedContainerColor = MaterialTheme.colorScheme.background,
disabledContainerColor = MaterialTheme.colorScheme.background,
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
disabledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
focusedBorderColor = 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
}
}
}

@ -0,0 +1,30 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import android.content.pm.PackageManager
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.UninitializedApp
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
object AndroidTVUtil {
fun isAndroidTV(): Boolean {
val pm = UninitializedApp.get().packageManager
return (pm.hasSystemFeature(@Suppress("deprecation") PackageManager.FEATURE_TELEVISION) ||
pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
}
}
// Applies a letterbox effect iff we're running on Android TV to reduce the overall width
// of the UI.
fun Modifier.universalFit(): Modifier {
return when (isAndroidTV()) {
true -> this.padding(horizontal = 150.dp, vertical = 10.dp).clip(RoundedCornerShape(10.dp))
false -> this
}
}

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

@ -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
class DisplayAddress(val ip: String) {
class DisplayAddress(ip: String) {
enum class addrType {
V4,
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,18 +4,22 @@
package com.tailscale.ipn.ui.util
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
@ -25,7 +29,7 @@ object Lists {
@Composable
fun SectionDivider(title: String? = null) {
Box(Modifier.size(0.dp, 16.dp))
title?.let { SectionTitle(title) }
title?.let { LargeTitle(title) }
}
@Composable
@ -34,11 +38,12 @@ object Lists {
}
@Composable
fun SectionTitle(
fun LargeTitle(
title: String,
bottomPadding: Dp = 0.dp,
style: TextStyle = MaterialTheme.typography.titleMedium,
fontWeight: FontWeight? = null
fontWeight: FontWeight? = null,
focusable: Boolean = false
) {
Box(
modifier =
@ -47,13 +52,52 @@ object Lists {
Text(
title,
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,
fontWeight = fontWeight)
}
}
@Composable
fun MutedHeader(text: String) {
Box(
modifier =
Modifier.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.surface, shape = RectangleShape)) {
Text(
modifier = Modifier.padding(start = 16.dp, top = 16.dp),
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
fun InfoItem(text: CharSequence, onClick: (() -> Unit)? = null) {
val style =
MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant)
ListItem(
headlineContent = {
Box(modifier = Modifier.padding(vertical = 4.dp)) {
onClick?.let {
ClickableText(text = text as AnnotatedString, style = style, onClick = { onClick() })
} ?: run { Text(text as String, style = style) }
}
})
}
@Composable
fun MultilineDescription(headlineContent: @Composable () -> Unit) {
ListItem(
headlineContent = {
Box(modifier = Modifier.padding(vertical = 8.dp)) { headlineContent() }
})
}
}
/** Similar to items() but includes a horizontal divider between items. */
/** Similar to items() but includes a horizontal divider between items. */
inline fun <T> LazyListScope.itemsWithDividers(
items: List<T>,

@ -12,9 +12,11 @@ import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
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.unit.dp
import com.tailscale.ipn.ui.view.TailscaleLogoView
@ -39,7 +41,7 @@ object LoadingIndicator {
contentAlignment = Alignment.Center,
) {
content()
val isLoading = loading.collectAsState().value
val isLoading by loading.collectAsState()
if (isLoading) {
Box(Modifier.clickable {}.matchParentSize().background(Color.Gray.copy(alpha = 0.0f)))
@ -53,7 +55,8 @@ object LoadingIndicator {
Column(
modifier = Modifier.fillMaxWidth(),
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
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.Tailcfg
import com.tailscale.ipn.ui.model.UserID
@ -19,27 +21,58 @@ class PeerCategorizer {
val selfNode = netmap.SelfNode
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)) {
// (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 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)) {
grouped[userId] = mutableListOf()
}
grouped[userId]?.add(peer)
}
}
val me = netmap.currentUserProfile()
peerSets =
grouped
.map { (userId, peers) ->
val profile = netmap.userProfile(userId)
PeerSet(profile, peers.sortedBy { it.ComputedName })
PeerSet(
profile,
peers.sortedWith { a, b ->
when {
a.StableID == b.StableID -> 0
a.isSelfNode(netmap) -> -1
b.isSelfNode(netmap) -> 1
else ->
(a.ComputedName?.lowercase() ?: "").compareTo(
b.ComputedName?.lowercase() ?: "")
}
})
}
.sortedBy {
if (it.user?.ID == me?.ID) {
@ -79,7 +112,10 @@ class PeerCategorizer {
}
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()) {
PeerSet(user, matchingPeers)
} else {

@ -4,11 +4,15 @@
package com.tailscale.ipn.ui.util
import com.tailscale.ipn.R
import com.tailscale.ipn.util.TSLog
import java.time.Duration
import java.time.Instant
import java.time.format.DateTimeFormatter
import java.util.Date
object TimeUtil {
val TAG = "TimeUtil"
fun keyExpiryFromGoTime(goTime: String?): ComposableStringFormatter {
val time = goTime ?: return ComposableStringFormatter(R.string.empty)
@ -70,10 +74,51 @@ object TimeUtil {
return Date.from(i)
}
// Returns true if the given time is within 24 hours from now or in the past.
fun isWithin24Hours(goTime: String): Boolean {
// Returns true if the given Go time string is in the past, or will occur within the given
// duration from now.
fun isWithinExpiryNotificationWindow(window: Duration, goTime: String): Boolean {
val expTime = epochMillisFromGoTime(goTime)
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
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
@ -12,7 +12,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@ -20,32 +22,41 @@ import androidx.compose.runtime.Composable
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.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.BuildConfig
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.logoBackground
@Composable
fun AboutView(nav: BackNavigation) {
Scaffold(topBar = { Header(R.string.about_view_title, onBack = nav.onBack) }) { innerPadding ->
fun AboutView(backToSettings: BackNavigation) {
val localClipboardManager = LocalClipboardManager.current
Scaffold(topBar = { Header(R.string.about_view_header, onBack = backToSettings) }) { innerPadding
->
Column(
verticalArrangement =
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(innerPadding)) {
Image(
modifier =
Modifier.fillMaxWidth()
.fillMaxHeight()
.padding(innerPadding)
.verticalScroll(rememberScrollState())) {
TailscaleLogoView(
usesOnBackgroundColors = true,
modifier =
Modifier.width(100.dp)
.height(100.dp)
.clip(RoundedCornerShape(50))
.background(MaterialTheme.colorScheme.onSurface)
.padding(15.dp),
painter = painterResource(id = R.drawable.androidicon),
contentDescription = stringResource(R.string.app_icon_content_description))
.background(MaterialTheme.colorScheme.logoBackground)
.padding(25.dp))
Column(
verticalArrangement =
@ -56,6 +67,10 @@ fun AboutView(nav: BackNavigation) {
fontWeight = FontWeight.SemiBold,
fontSize = MaterialTheme.typography.titleLarge.fontSize)
Text(
modifier =
Modifier.clickable {
localClipboardManager.setText(AnnotatedString(BuildConfig.VERSION_NAME))
},
text = "${stringResource(R.string.version)} ${BuildConfig.VERSION_NAME}",
fontWeight = MaterialTheme.typography.bodyMedium.fontWeight,
fontSize = MaterialTheme.typography.bodyMedium.fontSize)
@ -75,3 +90,9 @@ fun AboutView(nav: BackNavigation) {
}
}
}
@Preview
@Composable
fun AboutPreview() {
AboutView({})
}

@ -17,9 +17,11 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.IpnLocal
@OptIn(ExperimentalCoilApi::class)
@ -34,7 +36,10 @@ fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50, action: (() -> Unit)
indication = rememberRipple(bounded = false),
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 ->
AsyncImage(model = url, modifier = Modifier.size((size * 1.2f).dp), contentDescription = null)

@ -7,12 +7,14 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.ListItem
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
@ -21,32 +23,40 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
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.util.ClipboardValueView
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.BugReportViewModel
@Composable
fun BugReportView(nav: BackNavigation, model: BugReportViewModel = viewModel()) {
fun BugReportView(backToSettings: BackNavigation, model: BugReportViewModel = viewModel()) {
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 = nav.onBack) }) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding).fillMaxWidth().fillMaxHeight()) {
ListItem(
headlineContent = {
Scaffold(topBar = { Header(R.string.bug_report_title, onBack = backToSettings) }) { innerPadding
->
Column(
modifier =
Modifier.padding(innerPadding)
.fillMaxWidth()
.fillMaxHeight()
.verticalScroll(rememberScrollState())) {
Lists.MultilineDescription {
ClickableText(
text = contactText(),
style = MaterialTheme.typography.bodyMedium,
onClick = { handler.openUri(Links.SUPPORT_URL) })
})
}
ClipboardValueView(bugReportID, title = stringResource(R.string.bug_report_id))
ClipboardValueView(
bugReportID,
title = stringResource(R.string.bug_report_id),
subtitle = stringResource(id = R.string.bug_report_id_desc))
Lists.InfoItem(stringResource(id = R.string.bug_report_id_desc))
}
}
}
@ -54,7 +64,9 @@ fun BugReportView(nav: BackNavigation, model: BugReportViewModel = viewModel())
@Composable
fun contactText(): AnnotatedString {
val annotatedString = buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
append(stringResource(id = R.string.bug_report_instructions_prefix))
}
pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL)
withStyle(
@ -66,7 +78,17 @@ fun contactText(): AnnotatedString {
}
pop()
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
append(stringResource(id = R.string.bug_report_instructions_suffix))
}
}
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.RowScope
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextDecoration
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.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.DnsType
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.ClipboardValueView
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator
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.DNSSettingsViewModel
import com.tailscale.ipn.ui.viewModel.DNSSettingsViewModelFactory
@ -35,10 +39,10 @@ data class ViewableRoute(val name: String, val resolvers: List<DnsType.Resolver>
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DNSSettingsView(
nav: BackNavigation,
backToSettings: BackNavigation,
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 domains = model.dnsConfig.collectAsState().value?.Domains ?: emptyList()
val routes: List<ViewableRoute> =
@ -46,8 +50,9 @@ fun DNSSettingsView(
entry.value?.let { resolvers -> ViewableRoute(name = entry.key, resolvers) } ?: run { null }
} ?: emptyList()
val useCorpDNS = Notifier.prefs.collectAsState().value?.CorpDNS == true
val dnsSettingsMDMDisposition by MDMSettings.useTailscaleDNSSettings.flow.collectAsState()
Scaffold(topBar = { Header(R.string.dns_settings, onBack = nav.onBack) }) { innerPadding ->
Scaffold(topBar = { Header(R.string.dns_settings, onBack = backToSettings) }) { innerPadding ->
LoadingIndicator.Wrap {
LazyColumn(Modifier.padding(innerPadding)) {
item("state") {
@ -64,6 +69,7 @@ fun DNSSettingsView(
},
supportingContent = { Text(stringResource(state.caption)) })
if (!dnsSettingsMDMDisposition.value.hiddenFromUser) {
Lists.ItemDivider()
Setting.Switch(
R.string.use_ts_dns,
@ -73,6 +79,7 @@ fun DNSSettingsView(
model.toggleCorpDNS { LoadingIndicator.stop() }
})
}
}
if (resolvers.isNotEmpty()) {
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.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.AppTheme
enum class ErrorDialogType {
@ -17,7 +19,8 @@ enum class ErrorDialogType {
SWITCH_USER_FAILED,
ADD_PROFILE_FAILED,
SHARE_DEVICE_NOT_CONNECTED,
SHARE_FAILED;
SHARE_FAILED,
INVALID_AUTH_KEY;
val message: Int
get() {
@ -28,6 +31,7 @@ enum class ErrorDialogType {
ADD_PROFILE_FAILED -> R.string.add_profile_failed
SHARE_DEVICE_NOT_CONNECTED -> R.string.share_device_not_connected
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
SHARE_DEVICE_NOT_CONNECTED -> R.string.share_device_not_connected_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,
onDismiss: () -> Unit = {}
) {
AppTheme {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(id = title)) },
@ -66,4 +72,11 @@ fun ErrorDialog(
confirmButton = {
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.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
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.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHide
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.disabledListItem
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.ExitNodePickerViewModelFactory
import com.tailscale.ipn.ui.viewModel.selected
import kotlinx.coroutines.flow.MutableStateFlow
@Composable
fun ExitNodePicker(
@ -38,25 +43,43 @@ fun ExitNodePicker(
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) {
LoadingIndicator.Wrap {
Scaffold(topBar = { Header(R.string.choose_exit_node, onBack = nav.onNavigateBack) }) {
Scaffold(topBar = { Header(R.string.choose_exit_node, onBack = nav.onNavigateBackHome) }) {
innerPadding ->
val tailnetExitNodes = model.tailnetExitNodes.collectAsState().value
val mullvadExitNodesByCountryCode = model.mullvadExitNodesByCountryCode.collectAsState().value
val mullvadExitNodeCount = model.mullvadExitNodeCount.collectAsState().value
val anyActive = model.anyActive.collectAsState()
val tailnetExitNodes by model.tailnetExitNodes.collectAsState()
val mullvadExitNodesByCountryCode by model.mullvadExitNodesByCountryCode.collectAsState()
val mullvadExitNodeCount by model.mullvadExitNodeCount.collectAsState()
val anyActive by model.anyActive.collectAsState()
val shouldShowMullvadInfo by model.shouldShowMullvadInfo.collectAsState()
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)) {
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(
model,
ExitNodePickerViewModel.ExitNode(
label = stringResource(R.string.none),
online = true,
selected = !anyActive.value,
online = MutableStateFlow(true),
selected = !anyActive,
))
}
if (showRunAsExitNode.value == ShowHide.Show) {
Lists.ItemDivider()
RunAsExitNodeItem(nav = nav, viewModel = model)
RunAsExitNodeItem(nav = nav, viewModel = model, anyActive)
}
}
item(key = "divider1") { Lists.SectionDivider() }
@ -66,11 +89,17 @@ fun ExitNodePicker(
if (mullvadExitNodeCount > 0) {
item(key = "mullvad") {
Lists.SectionDivider()
MullvadItem(nav, mullvadExitNodeCount, mullvadExitNodesByCountryCode.selected)
MullvadItem(
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") {
Lists.SectionDivider()
@ -82,6 +111,7 @@ fun ExitNodePicker(
}
}
}
}
}
@Composable
@ -89,27 +119,31 @@ fun ExitNodeItem(
viewModel: ExitNodePickerViewModel,
node: ExitNodePickerViewModel.ExitNode,
) {
val online by node.online.collectAsState()
val isRunningExitNode = viewModel.isRunningExitNode.collectAsState().value
val forcedExitNodeId = MDMSettings.exitNodeID.flow.collectAsState().value.value
Box {
var modifier: Modifier = Modifier
if (node.online) {
if (online && !isRunningExitNode && forcedExitNodeId == null) {
modifier = modifier.clickable { viewModel.setExitNode(node) }
}
ListItem(
modifier = modifier,
colors =
if (node.online) MaterialTheme.colorScheme.listItem
if (online && !isRunningExitNode) MaterialTheme.colorScheme.listItem
else MaterialTheme.colorScheme.disabledListItem,
headlineContent = {
Text(node.city.ifEmpty { node.label }, style = MaterialTheme.typography.bodyMedium)
},
supportingContent = {
if (!node.online)
if (!online)
Text(stringResource(R.string.offline), style = MaterialTheme.typography.bodyMedium)
},
trailingContent = {
Row {
if (node.selected) {
Icon(Icons.Outlined.Check, contentDescription = stringResource(R.string.selected))
Icon(Icons.Outlined.Check, null)
}
}
})
@ -128,24 +162,53 @@ fun MullvadItem(nav: ExitNodePickerNav, count: Int, selected: Boolean) {
},
supportingContent = {
Text(
"$count ${stringResource(R.string.exit_nodes_available)}",
"$count ${stringResource(R.string.countries)}",
style = MaterialTheme.typography.bodyMedium)
},
trailingContent = {
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
fun RunAsExitNodeItem(nav: ExitNodePickerNav, viewModel: ExitNodePickerViewModel) {
fun RunAsExitNodeItem(
nav: ExitNodePickerNav,
viewModel: ExitNodePickerViewModel,
anyActive: Boolean
) {
val isRunningExitNode = viewModel.isRunningExitNode.collectAsState().value
Box {
var modifier: Modifier = Modifier
if (!anyActive) {
modifier = modifier.clickable { nav.onNavigateToRunAsExitNode() }
}
ListItem(
modifier = Modifier.clickable { nav.onNavigateToRunAsExitNode() },
modifier = modifier,
colors =
if (!anyActive) MaterialTheme.colorScheme.listItem
else MaterialTheme.colorScheme.disabledListItem,
headlineContent = {
Text(
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,15 +3,17 @@
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@ -19,23 +21,20 @@ 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.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.AppTheme
@Composable
fun IntroView(onContinue: () -> Unit) {
Surface {
Column(
modifier = Modifier.fillMaxHeight(),
modifier = Modifier.fillMaxHeight().fillMaxWidth().verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center) {
Image(
modifier = Modifier.width(80.dp).height(80.dp),
painter = painterResource(id = R.drawable.androidicon_light),
contentDescription = stringResource(R.string.app_icon_content_description))
TailscaleLogoView(modifier = Modifier.width(60.dp).height(60.dp))
Spacer(modifier = Modifier.height(40.dp))
Text(
modifier = Modifier.padding(start = 40.dp, end = 40.dp, bottom = 40.dp),
@ -49,7 +48,6 @@ fun IntroView(onContinue: () -> Unit) {
fontSize = MaterialTheme.typography.titleMedium.fontSize)
}
Spacer(modifier = Modifier.height(40.dp))
}
Box(
modifier = Modifier.fillMaxHeight().padding(start = 20.dp, end = 20.dp, bottom = 40.dp),
@ -62,3 +60,9 @@ fun IntroView(onContinue: () -> Unit) {
}
}
}
@Composable
@Preview
fun IntroViewPreview() {
AppTheme { Surface { IntroView({}) } }
}

@ -0,0 +1,88 @@
// 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.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewmodel.compose.viewModel
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel()) {
Surface(color = MaterialTheme.colorScheme.scrim, modifier = Modifier.fillMaxSize()) {
Dialog(onDismissRequest = onDismiss) {
val image by model.qrCode.collectAsState()
val numCode by model.numCode.collectAsState()
Column(
modifier =
Modifier.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResource(R.string.scan_to_connect_to_your_tailnet),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface)
Box(
modifier =
Modifier.size(200.dp)
.background(MaterialTheme.colorScheme.onSurface)
.fillMaxWidth(),
contentAlignment = Alignment.Center) {
image?.let {
Image(
bitmap = it,
contentDescription = "Scan to login",
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)) }
}
}
}
}
@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.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@ -24,9 +25,12 @@ import com.tailscale.ipn.ui.viewModel.IpnViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MDMSettingsDebugView(nav: BackNavigation, model: IpnViewModel = viewModel()) {
Scaffold(topBar = { Header(R.string.current_mdm_settings, onBack = nav.onBack) }) { innerPadding
->
fun MDMSettingsDebugView(
backToSettings: BackNavigation,
@Suppress("UNUSED_PARAMETER") model: IpnViewModel = viewModel()
) {
Scaffold(topBar = { Header(R.string.current_mdm_settings, onBack = backToSettings) }) {
innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
itemsWithDividers(MDMSettings.allSettings.sortedBy { "${it::class.java.name}|${it.key}" }) {
setting ->
@ -38,7 +42,7 @@ fun MDMSettingsDebugView(nav: BackNavigation, model: IpnViewModel = viewModel())
@Composable
fun MDMSettingView(setting: MDMSetting<*>) {
val value = setting.flow.collectAsState().value
val value by setting.flow.collectAsState()
ListItem(
headlineContent = { Text(setting.localizedTitle, maxLines = 3) },
supportingContent = {
@ -49,7 +53,7 @@ fun MDMSettingView(setting: MDMSetting<*>) {
},
trailingContent = {
Text(
value.toString(),
if (value.isSet) value.value.toString() else "[not set]",
fontFamily = FontFamily.Monospace,
maxLines = 1,
fontWeight = FontWeight.SemiBold)

@ -6,6 +6,8 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
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.Box
import androidx.compose.foundation.layout.Column
@ -20,33 +22,43 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.Clear
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.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
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.res.painterResource
import androidx.compose.ui.res.stringResource
@ -55,16 +67,23 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.tailscale.ipn.App
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.IpnLocal
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.Permissions
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.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.minTextSize
import com.tailscale.ipn.ui.theme.primaryListItem
@ -72,42 +91,72 @@ import com.tailscale.ipn.ui.theme.searchBarColors
import com.tailscale.ipn.ui.theme.secondaryButton
import com.tailscale.ipn.ui.theme.short
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.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.AutoResizingText
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator
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.set
import com.tailscale.ipn.ui.viewModel.IpnViewModel.NodeState
import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModel
// Navigation actions for the MainView
data class MainViewNavigation(
val onNavigateToSettings: () -> Unit,
val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
val onNavigateToExitNodes: () -> Unit
val onNavigateToExitNodes: () -> Unit,
val onNavigateToHealth: () -> Unit
)
@OptIn(ExperimentalPermissionsApi::class)
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@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 {
Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets ->
Column(
modifier = Modifier.fillMaxWidth().padding(paddingInsets),
verticalArrangement = Arrangement.Center) {
val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState).value
val user = viewModel.loggedInUser.collectAsState(initial = null).value
val stateVal = viewModel.stateRes.collectAsState(initial = R.string.placeholder).value
// Assume VPN has been prepared for optimistic UI. Whether or not it has been prepared
// cannot be known
// 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 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(
colors = MaterialTheme.colorScheme.surfaceContainerListItem,
leadingContent = {
val isOn = viewModel.vpnToggleState.collectAsState(initial = false)
TintedSwitch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value)
if (!hideHeader) {
TintedSwitch(
onCheckedChange = {
if (!disableToggle.value) {
viewModel.toggleVpn()
}
},
enabled = !disableToggle.value,
checked = isOn)
}
},
headlineContent = {
user?.NetworkProfile?.DomainName?.let { domain ->
@ -119,14 +168,45 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
}
},
supportingContent = {
if (!hideHeader) {
Row(verticalAlignment = Alignment.CenterVertically) {
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 = {
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) {
null -> SettingsButton { navigation.onNavigateToSettings() }
else ->
Avatar(profile = user, size = 36) { navigation.onNavigateToSettings() }
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier.size(42.dp).clip(CircleShape).focusable().clickable {
navigation.onNavigateToSettings()
}) {
Avatar(profile = user, size = 36) {
navigation.onNavigateToSettings()
}
}
}
}
})
@ -136,10 +216,16 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
PromptPermissionsIfNecessary()
ExpiryNotificationIfNecessary(
netmap = netmap.value, action = { viewModel.login {} })
viewModel.showVPNPermissionLauncherIfUnauthorized()
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(
viewModel = viewModel,
@ -148,7 +234,26 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
}
Ipn.State.NoState,
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)
}
}
}
@ -157,15 +262,47 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
@Composable
fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
val prefs = viewModel.prefs.collectAsState()
val netmap = viewModel.netmap.collectAsState()
val exitNodeId = prefs.value?.ExitNodeID
val peer = exitNodeId?.let { id -> netmap.value?.Peers?.find { it.StableID == id } }
val location = peer?.Hostinfo?.Location
val name = peer?.ComputedName
val active = peer != null
val nodeState by viewModel.nodeState.collectAsState()
val maybePrefs by viewModel.prefs.collectAsState()
val netmap by viewModel.netmap.collectAsState()
// There's nothing to render if we haven't loaded the prefs yet
val prefs = maybePrefs ?: return
// 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(
modifier =
Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp)
@ -174,11 +311,25 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
ListItem(
modifier = Modifier.clickable { navAction() },
colors =
if (active) MaterialTheme.colorScheme.primaryListItem
else ListItemDefaults.colors(),
when (nodeState) {
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 = {
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,
)
},
@ -186,9 +337,12 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text =
location?.let { "${it.CountryCode?.flag()} ${it.Country}: ${it.City}" }
?: name
?: stringResource(id = R.string.none),
when (nodeState) {
NodeState.NONE -> stringResource(id = R.string.none)
NodeState.RUNNING_AS_EXIT_NODE ->
stringResource(id = R.string.running_exit_node)
else -> name ?: ""
},
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis)
@ -196,17 +350,40 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
imageVector = Icons.Outlined.ArrowDropDown,
contentDescription = null,
tint =
if (active) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
else MaterialTheme.colorScheme.onSurfaceVariant,
if (nodeState == NodeState.NONE)
MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
)
}
},
trailingContent = {
if (peer != null) {
if (nodeState != NodeState.NONE) {
Button(
colors = MaterialTheme.colorScheme.secondaryButton,
onClick = { viewModel.disableExitNode() }) {
Text(stringResource(R.string.stop))
colors =
when (nodeState) {
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)
})
}
}
})
@ -216,13 +393,11 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
@Composable
fun SettingsButton(action: () -> Unit) {
// (jonathan) TODO: On iOS this is the users avatar or a letter avatar.
IconButton(modifier = Modifier.size(24.dp), onClick = { action() }) {
Icon(
Icons.Outlined.Settings,
null,
)
contentDescription = "Open settings",
tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@ -232,17 +407,28 @@ fun StartingView() {
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
TailscaleLogoView(animated = true, Modifier.size(40.dp))
TailscaleLogoView(
animated = true, usesOnBackgroundColors = false, Modifier.size(40.dp).alpha(0.3f))
}
}
@Composable
fun ConnectView(
state: Ipn.State,
isPrepared: Boolean,
shouldStartAutomatically: Boolean,
user: IpnLocal.LoginProfile?,
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()) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
Column(
@ -250,7 +436,45 @@ fun ConnectView(
verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically),
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(
painter = painterResource(id = R.drawable.power),
contentDescription = null,
@ -311,13 +535,24 @@ fun PeerList(
onNavigateToPeerDetails: (Tailcfg.Node) -> 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 showNoResults =
remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value
val netmap = viewModel.netmap.collectAsState()
val focusManager = LocalFocusManager.current
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)) {
OutlinedTextField(
modifier =
@ -327,7 +562,9 @@ fun PeerList(
singleLine = true,
shape = MaterialTheme.shapes.extraLarge,
colors = MaterialTheme.colorScheme.searchBarColors,
leadingIcon = { Icon(imageVector = Icons.Outlined.Search, contentDescription = "search") },
leadingIcon = {
Icon(imageVector = Icons.Outlined.Search, contentDescription = "search")
},
trailingIcon = {
if (isFocussed) {
IconButton(
@ -353,32 +590,49 @@ fun PeerList(
value = searchTermStr,
onValueChange = { onSearch(it) })
}
LazyColumn(
modifier = Modifier.fillMaxSize().background(color = MaterialTheme.colorScheme.surface)) {
var first = true
peerList.value.forEach { peerSet ->
if (!first) {
item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() }
}
first = false
stickyHeader {
LazyColumn(
modifier =
Modifier.fillMaxSize()
.onFocusChanged { isListFocussed = it.isFocused }
.background(color = MaterialTheme.colorScheme.surface)) {
if (showNoResults) {
item {
Spacer(
Modifier.height(16.dp)
.fillMaxSize()
.focusable(false)
.background(color = MaterialTheme.colorScheme.surface))
Lists.SectionTitle(
peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user),
Lists.LargeTitle(
stringResource(id = R.string.no_results),
bottomPadding = 8.dp,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold)
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Light)
}
}
var first = true
peerList.forEach { peerSet ->
if (!first) {
item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() }
}
first = false
// Sticky headers are a bit broken on Android TV - they hide their content
if (isAndroidTV()) {
item { NodesSectionHeader(peerSet = peerSet) }
} else {
stickyHeader { NodesSectionHeader(peerSet = peerSet) }
}
itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer ->
ListItem(
modifier = Modifier.clickable { onNavigateToPeerDetails(peer) },
modifier =
Modifier.combinedClickable(
onClick = { onNavigateToPeerDetails(peer) },
onLongClick = { viewModel.expandedMenuPeer.set(peer) }),
colors = MaterialTheme.colorScheme.listItem,
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
@ -391,6 +645,38 @@ fun PeerList(
shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp))
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 = {
@ -406,12 +692,20 @@ fun PeerList(
}
@Composable
fun ExpiryNotificationIfNecessary(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) {
// Key expiry warning shown only if the key is expiring within 24 hours (or has already expired)
val networkMap = netmap ?: return
if (!TimeUtil.isWithin24Hours(networkMap.SelfNode.KeyExpiry)) {
return
}
fun NodesSectionHeader(peerSet: PeerSet) {
Spacer(Modifier.height(16.dp).fillMaxSize().background(color = MaterialTheme.colorScheme.surface))
Lists.LargeTitle(
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(
@ -424,7 +718,7 @@ fun ExpiryNotificationIfNecessary(netmap: Netmap.NetworkMap?, action: () -> Unit
colors = MaterialTheme.colorScheme.warningListItem,
headlineContent = {
Text(
networkMap.SelfNode.expiryLabel(),
netmap.SelfNode.expiryLabel(),
style = MaterialTheme.typography.titleMedium,
)
},
@ -449,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)
}

@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -14,24 +16,27 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.viewModel.IpnViewModel
@Suppress("UNUSED_PARAMETER")
@Composable
fun ManagedByView(nav: BackNavigation, model: IpnViewModel = viewModel()) {
Scaffold(topBar = { Header(R.string.managed_by, onBack = nav.onBack) }) { innerPadding ->
fun ManagedByView(backToSettings: BackNavigation, model: IpnViewModel = viewModel()) {
Scaffold(topBar = { Header(R.string.managed_by, onBack = backToSettings) }) { _ ->
Column(
verticalArrangement =
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.Start,
modifier = Modifier.fillMaxWidth().safeContentPadding()) {
modifier =
Modifier.fillMaxWidth().safeContentPadding().verticalScroll(rememberScrollState())) {
val managedByOrganization =
MDMSettings.managedByOrganizationName.flow.collectAsState().value
val managedByCaption = MDMSettings.managedByCaption.flow.collectAsState().value
val managedByURL = MDMSettings.managedByURL.flow.collectAsState().value
MDMSettings.managedByOrganizationName.flow.collectAsState().value.value
val managedByCaption = MDMSettings.managedByCaption.flow.collectAsState().value.value
val managedByURL = MDMSettings.managedByURL.flow.collectAsState().value.value
managedByOrganization?.let {
Text(stringResource(R.string.managed_by_explainer_orgName, it))
} ?: run { Text(stringResource(R.string.managed_by_explainer)) }
@ -44,3 +49,10 @@ fun ManagedByView(nav: BackNavigation, model: IpnViewModel = viewModel()) {
}
}
}
@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.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
@ -29,10 +30,10 @@ fun MullvadExitNodePicker(
nav: ExitNodePickerNav,
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) {
val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState()
val bestAvailableByCountry = model.mullvadBestAvailableByCountry.collectAsState()
val mullvadExitNodes by model.mullvadExitNodesByCountryCode.collectAsState()
val bestAvailableByCountry by model.mullvadBestAvailableByCountry.collectAsState()
mullvadExitNodes.value[countryCode]?.toList()?.let { nodes ->
mullvadExitNodes[countryCode]?.toList()?.let { nodes ->
val any = nodes.first()
LoadingIndicator.Wrap {
@ -40,11 +41,11 @@ fun MullvadExitNodePicker(
topBar = {
Header(
title = { Text("${countryCode.flag()} ${any.country}") },
onBack = nav.onNavigateBack)
onBack = nav.onNavigateBackToMullvad)
}) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
if (nodes.size > 1) {
val bestAvailableNode = bestAvailableByCountry.value[countryCode]!!
val bestAvailableNode = bestAvailableByCountry[countryCode]!!
item {
ExitNodeItem(
model,

@ -17,6 +17,7 @@ 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.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
@ -36,20 +37,23 @@ fun MullvadExitNodePickerList(
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) {
LoadingIndicator.Wrap {
Scaffold(topBar = { Header(R.string.choose_mullvad_exit_node, onBack = nav.onNavigateBack) }) {
innerPadding ->
val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState()
Scaffold(
topBar = {
Header(R.string.choose_mullvad_exit_node, onBack = nav.onNavigateBackToExitNodes)
}) { innerPadding ->
val mullvadExitNodes by model.mullvadExitNodesByCountryCode.collectAsState()
LazyColumn(modifier = Modifier.padding(innerPadding)) {
val sortedCountries =
mullvadExitNodes.value.entries.toList().sortedBy {
mullvadExitNodes.entries.toList().sortedBy {
it.value.first().country.lowercase()
}
itemsWithDividers(sortedCountries) { (countryCode, nodes) ->
val first = nodes.first()
// TODO(oxtoacart): the modifier on the ListItem occasionally causes a crash
// with java.lang.ClassCastException: androidx.compose.ui.ComposedModifier cannot be cast
// with java.lang.ClassCastException: androidx.compose.ui.ComposedModifier cannot be
// cast
// to androidx.compose.runtime.RecomposeScopeImpl
// Wrapping it in a Box eliminates this. It appears to be some kind of
// interaction between the LazyList and the modifier.

@ -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,26 +5,28 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
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.graphics.RectangleShape
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
@ -35,19 +37,26 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.listItem
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.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModelFactory
import com.tailscale.ipn.ui.viewModel.PingViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PeerDetails(
nav: BackNavigation,
backToHome: BackNavigation,
nodeId: String,
pingViewModel: PingViewModel,
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.node.collectAsState().value?.let { node ->
Scaffold(
@ -74,24 +83,21 @@ fun PeerDetails(
}
}
},
onBack = { nav.onBack() })
actions = {
IconButton(onClick = { model.startPing() }) {
Icon(
painter = painterResource(R.drawable.timer),
contentDescription = "Ping device")
}
},
onBack = backToHome)
},
) { innerPadding ->
LazyColumn(
modifier = Modifier.padding(innerPadding),
) {
item(key = "tailscaleAddresses") {
Box(
modifier =
Modifier.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surface, shape = RectangleShape)) {
Text(
modifier = Modifier.padding(start = 16.dp, top = 16.dp),
text = stringResource(R.string.tailscale_addresses),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Lists.MutedHeader(stringResource(R.string.tailscale_addresses))
}
itemsWithDividers(node.displayAddresses, key = { it.address }) {
@ -104,6 +110,11 @@ fun PeerDetails(
ValueRow(title = stringResource(id = it.titleRes), value = it.value.getString())
}
}
if (isPinging) {
ModalBottomSheet(onDismissRequest = { model.onPingDismissal() }) {
PingView(model = model.pingViewModel)
}
}
}
}
}
@ -113,14 +124,24 @@ fun PeerDetails(
fun AddressRow(address: String, type: String) {
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(
modifier = Modifier.clickable { localClipboardManager.setText(AnnotatedString(address)) },
modifier = modifier,
colors = MaterialTheme.colorScheme.listItem,
headlineContent = { Text(text = address) },
supportingContent = { Text(text = type) },
trailingContent = {
// TODO: there is some overlap with other uses of clipboard, DRY
if (!isAndroidTV()) {
Icon(painter = painterResource(id = R.drawable.clipboard), null)
}
})
}

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

@ -25,10 +25,10 @@ import com.tailscale.ipn.ui.util.itemsWithDividers
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun PermissionsView(nav: BackNavigation, openApplicationSettings: () -> Unit) {
fun PermissionsView(backToSettings: BackNavigation, openApplicationSettings: () -> Unit) {
val permissions = Permissions.withGrantedStatus
Scaffold(topBar = { Header(titleRes = R.string.permissions, onBack = nav.onBack) }) { innerPadding
->
Scaffold(topBar = { Header(titleRes = R.string.permissions, onBack = backToSettings) }) {
innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
itemsWithDividers(permissions) { (permission, granted) ->
ListItem(

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

@ -10,6 +10,8 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
import androidx.compose.material3.Button
@ -19,6 +21,7 @@ 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.res.painterResource
@ -29,25 +32,25 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.RunExitNodeViewModel
import com.tailscale.ipn.ui.viewModel.RunExitNodeViewModelFactory
import com.tailscale.ipn.ui.viewModel.IpnViewModel
@Composable
fun RunExitNodeView(
nav: ExitNodePickerNav,
model: RunExitNodeViewModel = viewModel(factory = RunExitNodeViewModelFactory())
) {
val isRunningExitNode = model.isRunningExitNode.collectAsState().value
fun RunExitNodeView(nav: ExitNodePickerNav, model: IpnViewModel = viewModel()) {
val isRunningExitNode by model.isRunningExitNode.collectAsState()
Scaffold(
topBar = { Header(R.string.run_as_exit_node, onBack = nav.onNavigateToExitNodePicker) }) {
topBar = { Header(R.string.run_as_exit_node, onBack = nav.onNavigateBackToExitNodes) }) {
innerPadding ->
LoadingIndicator.Wrap {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement =
Arrangement.spacedBy(24.dp, alignment = Alignment.CenterVertically),
modifier = Modifier.padding(innerPadding).padding(24.dp).fillMaxHeight()) {
modifier =
Modifier.padding(innerPadding)
.padding(24.dp)
.fillMaxHeight()
.verticalScroll(rememberScrollState())) {
RunExitNodeGraphic()
if (isRunningExitNode) {

@ -6,13 +6,15 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.Color
import androidx.compose.ui.platform.LocalUriHandler
@ -21,47 +23,83 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.BuildConfig
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.theme.link
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.set
import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.SettingsViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.ui.notifier.Notifier
@Composable
fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel()) {
fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel(), vpnViewModel: VpnViewModel = viewModel()) {
val handler = LocalUriHandler.current
val user = viewModel.loggedInUser.collectAsState().value
val isAdmin = viewModel.isAdmin.collectAsState().value
val managedByOrganization = viewModel.managedByOrganization.collectAsState().value
val user by viewModel.loggedInUser.collectAsState()
val isAdmin by viewModel.isAdmin.collectAsState()
val managedByOrganization by viewModel.managedByOrganization.collectAsState()
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(
topBar = {
Header(titleRes = R.string.settings_title, onBack = settingsNav.onBackPressed)
Header(titleRes = R.string.settings_title, onBack = settingsNav.onNavigateBackHome)
}) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
Column(modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) {
if (isVPNPrepared) {
UserView(
profile = user,
actionState = UserActionState.NAV,
onClick = settingsNav.onNavigateToUserSwitcher)
}
if (isAdmin) {
if (isAdmin && !isAndroidTV()) {
Lists.ItemDivider()
AdminTextView { handler.openUri(Links.ADMIN_URL) }
}
Lists.SectionDivider()
Setting.Text(R.string.dns_settings, onClick = settingsNav.onNavigateToDNSSettings)
Setting.Text(
R.string.dns_settings,
subtitle =
corpDNSEnabled?.let {
stringResource(
if (it) R.string.using_tailscale_dns else R.string.not_using_tailscale_dns)
},
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()
Setting.Text(R.string.tailnet_lock, onClick = settingsNav.onNavigateToTailnetLock)
Setting.Text(
R.string.tailnet_lock,
subtitle =
tailnetLockEnabled?.let {
stringResource(if (it) R.string.enabled else R.string.disabled)
},
onClick = settingsNav.onNavigateToTailnetLock)
}
Lists.ItemDivider()
Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions)
managedByOrganization?.let {
managedByOrganization.value?.let {
Lists.ItemDivider()
Setting.Text(
title = stringResource(R.string.managed_by_orgName, it),
@ -72,11 +110,15 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
Setting.Text(R.string.bug_report, onClick = settingsNav.onNavigateToBugReport)
Lists.ItemDivider()
Setting.Text(R.string.about_tailscale, onClick = settingsNav.onNavigateToAbout)
Setting.Text(
R.string.about_tailscale,
subtitle = "${stringResource(id = R.string.version)} ${BuildConfig.VERSION_NAME}",
onClick = settingsNav.onNavigateToAbout)
// TODO: put a heading for the debug section
if (BuildConfig.DEBUG) {
Lists.SectionDivider()
Lists.MutedHeader(text = stringResource(R.string.internal_debug_options))
Setting.Text(R.string.mdm_settings, onClick = settingsNav.onNavigateToMDMSettings)
}
}
@ -88,6 +130,7 @@ object Setting {
fun Text(
titleRes: Int = 0,
title: String? = null,
subtitle: String? = null,
destructive: Boolean = false,
enabled: Boolean = true,
onClick: (() -> Unit)? = null
@ -105,7 +148,15 @@ object Setting {
style = MaterialTheme.typography.bodyMedium,
color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified)
},
)
supportingContent =
subtitle?.let {
{
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
})
}
@Composable
@ -133,9 +184,7 @@ object Setting {
@Composable
fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
val adminStr = buildAnnotatedString {
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.onSurfaceVariant)) {
append(stringResource(id = R.string.settings_admin_prefix))
}
pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL)
withStyle(
@ -145,14 +194,18 @@ fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
textDecoration = TextDecoration.Underline)) {
append(stringResource(id = R.string.settings_admin_link))
}
pop()
}
ListItem(
headlineContent = {
ClickableText(
text = adminStr,
style = MaterialTheme.typography.bodyMedium,
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,16 +22,18 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
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.unit.dp
import com.tailscale.ipn.ui.theme.topAppBar
import com.tailscale.ipn.ui.theme.ts_color_light_blue
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
data class BackNavigation(
val onBack: () -> Unit,
)
typealias BackNavigation = () -> Unit
// Header view for all secondary screens
// @see TopAppBar actions for additional actions (usually a row of icons)
@ -43,6 +45,12 @@ fun Header(
actions: @Composable RowScope.() -> Unit = {},
onBack: (() -> Unit)? = null
) {
val f = FocusRequester()
if (isAndroidTV()) {
LaunchedEffect(Unit) { f.requestFocus() }
}
TopAppBar(
title = {
title?.let { title() }
@ -53,18 +61,20 @@ fun Header(
},
colors = MaterialTheme.colorScheme.topAppBar,
actions = actions,
navigationIcon = { onBack?.let { BackArrow(action = it) } },
navigationIcon = { onBack?.let { BackArrow(action = it, focusRequester = f) } },
)
}
@Composable
fun BackArrow(action: () -> Unit) {
fun BackArrow(action: () -> Unit, focusRequester: FocusRequester) {
Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
null,
contentDescription = "Go back to the previous screen",
modifier =
Modifier.clickable(
Modifier.focusRequester(focusRequester)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false),
onClick = { action() }))
@ -73,7 +83,7 @@ fun BackArrow(action: () -> Unit) {
@Composable
fun CheckedIndicator() {
Icon(Icons.Default.CheckCircle, "selected", tint = ts_color_light_blue)
Icon(Icons.Default.CheckCircle, null, tint = ts_color_light_blue)
}
@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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
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.platform.LocalContext
@ -30,6 +32,7 @@ import coil.compose.AsyncImage
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Ipn
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.viewModel.TaildropViewModel
import com.tailscale.ipn.ui.viewModel.TaildropViewModelFactory
@ -43,7 +46,9 @@ fun TaildropView(
viewModel: TaildropViewModel =
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
// Show the error overlay
@ -53,15 +58,16 @@ fun TaildropView(
FileShareHeader(
fileTransfers = requestedTransfers.collectAsState().value,
totalSize = viewModel.totalSize)
Spacer(modifier = Modifier.size(8.dp))
when (viewModel.state.collectAsState().value) {
Ipn.State.Running -> {
val peers = viewModel.myPeers.collectAsState().value
val peers by viewModel.myPeers.collectAsState()
val context = LocalContext.current
FileSharePeerList(
peers = peers,
stateViewGenerator = { peerId -> viewModel.TrailingContentForPeer(peerId = peerId) },
stateViewGenerator = { peerId ->
viewModel.TrailingContentForPeer(peerId = peerId)
},
onShare = { viewModel.share(context, it) })
}
else -> {
@ -78,9 +84,7 @@ fun FileSharePeerList(
stateViewGenerator: @Composable (String) -> Unit,
onShare: (Tailcfg.Node) -> Unit
) {
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
Text(stringResource(R.string.my_devices), style = MaterialTheme.typography.titleMedium)
}
SectionDivider(stringResource(R.string.my_devices))
when (peers.isEmpty()) {
true -> {
@ -112,8 +116,8 @@ fun FileSharePeerList(
@Composable
fun FileShareConnectView(onToggle: () -> Unit) {
Column(
modifier = Modifier.padding(horizontal = 8.dp).fillMaxHeight(),
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(horizontal = 16.dp).fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(6.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally) {
Text(
stringResource(R.string.connect_to_your_tailnet_to_share_files),
@ -129,7 +133,7 @@ fun FileShareConnectView(onToggle: () -> Unit) {
@Composable
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) {
IconForTransfer(fileTransfers)
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())
Text(size, style = MaterialTheme.typography.titleMedium)
Text(
size,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.secondary)
}
}
HorizontalDivider()
}
}

@ -3,11 +3,13 @@
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.Box
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
@ -15,6 +17,7 @@ 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.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
@ -24,32 +27,40 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
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.util.ClipboardValueView
import com.tailscale.ipn.ui.util.Lists
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.TailnetLockSetupViewModelFactory
@Composable
fun TailnetLockSetupView(
nav: BackNavigation,
backToSettings: BackNavigation,
model: TailnetLockSetupViewModel = viewModel(factory = TailnetLockSetupViewModelFactory())
) {
val statusItems = model.statusItems.collectAsState().value
val nodeKey = model.nodeKey.collectAsState().value
val tailnetLockKey = model.tailnetLockKey.collectAsState().value
val statusItems by model.statusItems.collectAsState()
val nodeKey by model.nodeKey.collectAsState()
val tailnetLockKey by model.tailnetLockKey.collectAsState()
val tailnetLockTlPubKey = tailnetLockKey.replace("nlpub", "tlpub")
Scaffold(topBar = { Header(R.string.tailnet_lock, onBack = nav.onBack) }) { innerPadding ->
Scaffold(topBar = { Header(R.string.tailnet_lock, onBack = backToSettings) }) { innerPadding ->
LoadingIndicator.Wrap {
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "header") { ExplainerView() }
Column(
modifier =
Modifier.padding(innerPadding)
.focusable()
.verticalScroll(rememberScrollState())
.fillMaxSize()) {
ExplainerView()
items(items = statusItems, key = { "status_${it.title}" }) { statusItem ->
statusItems.forEach { statusItem ->
Lists.ItemDivider()
ListItem(
@ -61,48 +72,42 @@ fun TailnetLockSetupView(
},
headlineContent = { Text(stringResource(statusItem.title)) })
}
item(key = "nodeKey") {
// Node key
Lists.SectionDivider()
ClipboardValueView(
value = nodeKey,
title = stringResource(R.string.node_key),
subtitle = stringResource(R.string.node_key_explainer))
}
item(key = "tailnetLockKey") {
// Tailnet lock key
Lists.SectionDivider()
ClipboardValueView(
value = tailnetLockKey,
value = tailnetLockTlPubKey,
title = stringResource(R.string.tailnet_lock_key),
subtitle = stringResource(R.string.tailnet_lock_key_explainer))
}
}
}
}
}
@Composable
private fun ExplainerView() {
val handler = LocalUriHandler.current
ListItem(
headlineContent = {
Box(modifier = Modifier.padding(vertical = 8.dp)) {
Lists.MultilineDescription {
ClickableText(
explainerText(),
onClick = { handler.openUri(Links.TAILNET_LOCK_KB_URL) },
style = MaterialTheme.typography.bodyMedium)
}
})
}
@Composable
fun explainerText(): AnnotatedString {
val annotatedString = buildAnnotatedString {
return buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
append(stringResource(id = R.string.tailnet_lock_explainer))
}
pushStringAnnotation(tag = "tailnetLockSupportURL", annotation = Links.TAILNET_LOCK_KB_URL)
@ -115,5 +120,13 @@ fun explainerText(): AnnotatedString {
}
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.ui.Modifier
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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -31,10 +35,24 @@ val logoDotsMatrix: DotsMatrix =
)
@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 secondaryColor: Color = primaryColor.copy(alpha = 0.1f)
val primaryColor: Color =
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)
var currentDotsMatrixIndex = 0

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

@ -5,13 +5,20 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemColors
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.theme.minTextSize
@ -33,13 +40,17 @@ enum class UserActionState {
@Composable
fun UserView(
profile: IpnLocal.LoginProfile?,
onClick: () -> Unit = {},
actionState: UserActionState = UserActionState.NONE
onClick: (() -> Unit)? = null,
colors: ListItemColors = ListItemDefaults.colors(),
actionState: UserActionState = UserActionState.NONE,
) {
Box {
var modifier: Modifier = Modifier
onClick?.let { modifier = modifier.clickable { it() } }
profile?.let {
ListItem(
modifier = Modifier.clickable { onClick() },
modifier = modifier,
colors = colors,
leadingContent = { Avatar(profile = profile, size = 36) },
headlineContent = {
AutoResizingText(
@ -59,14 +70,17 @@ fun UserView(
when (actionState) {
UserActionState.CURRENT -> CheckedIndicator()
UserActionState.SWITCHING -> SimpleActivityIndicator(size = 26)
UserActionState.NAV -> Unit
UserActionState.NAV ->
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight, null, Modifier.offset(x = 6.dp))
UserActionState.NONE -> Unit
}
})
}
?: run {
ListItem(
modifier = Modifier.clickable { onClick() },
modifier = modifier,
colors = colors,
headlineContent = {
Text(
text = stringResource(id = R.string.accounts),

@ -15,7 +15,9 @@ class BugReportViewModel : ViewModel() {
init {
Client(viewModelScope).bugReportId { result ->
result.onSuccess { bugReportID.set(it) }.onFailure { bugReportID.set("(Error fetching ID)") }
result
.onSuccess { bugReportID.set(it.trim()) }
.onFailure { bugReportID.set("(Error fetching ID)") }
}
}
}

@ -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
import android.util.Log
import androidx.annotation.StringRes
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@ -24,14 +23,16 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
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 {
return DNSSettingsViewModel() as T
}
}
class DNSSettingsViewModel() : IpnViewModel() {
class DNSSettingsViewModel : IpnViewModel() {
val enablementState: StateFlow<DNSEnablementState> =
MutableStateFlow(DNSEnablementState.NOT_RUNNING)
val dnsConfig: StateFlow<Tailcfg.DNSConfig?> = MutableStateFlow(null)
@ -42,7 +43,7 @@ class DNSSettingsViewModel() : IpnViewModel() {
.combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) }
.stateIn(viewModelScope)
.collect { (netmap, prefs) ->
Log.d("DNSSettingsViewModel", "prefs: CorpDNS=" + prefs?.CorpDNS.toString())
TSLog.d("DNSSettingsViewModel", "prefs: CorpDNS=" + prefs?.CorpDNS.toString())
prefs?.let {
if (it.CorpDNS) {
enablementState.set(DNSEnablementState.ENABLED)

@ -20,16 +20,18 @@ import kotlinx.coroutines.launch
import java.util.TreeMap
data class ExitNodePickerNav(
val onNavigateHome: () -> Unit,
val onNavigateBack: () -> Unit,
val onNavigateToExitNodePicker: () -> Unit,
val onNavigateBackHome: () -> Unit,
val onNavigateBackToExitNodes: () -> Unit,
val onNavigateToMullvad: () -> Unit,
val onNavigateToMullvadInfo: () -> Unit,
val onNavigateBackToMullvad: () -> Unit,
val onNavigateToMullvadCountry: (String) -> Unit,
val onNavigateToRunAsExitNode: () -> Unit,
)
class ExitNodePickerViewModelFactory(private val nav: ExitNodePickerNav) :
ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ExitNodePickerViewModel(nav) as T
}
@ -39,7 +41,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
data class ExitNode(
val id: StableNodeID? = null,
val label: String,
val online: Boolean,
val online: StateFlow<Boolean>,
val selected: Boolean,
val mullvad: Boolean = false,
val priority: Int = 0,
@ -54,7 +56,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
val mullvadBestAvailableByCountry: StateFlow<Map<String, ExitNode>> = MutableStateFlow(TreeMap())
val mullvadExitNodeCount: StateFlow<Int> = MutableStateFlow(0)
val anyActive: StateFlow<Boolean> = MutableStateFlow(false)
val isRunningExitNode: StateFlow<Boolean> = MutableStateFlow(false)
val shouldShowMullvadInfo: StateFlow<Boolean> = MutableStateFlow(false)
init {
viewModelScope.launch {
@ -62,8 +64,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
.combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) }
.stateIn(viewModelScope)
.collect { (netmap, prefs) ->
isRunningExitNode.set(prefs?.let { AdvertisedRoutesHelper.exitNodeOnFromPrefs(it) })
val exitNodeId = prefs?.ExitNodeID
val exitNodeId = prefs?.activeExitNodeID ?: prefs?.selectedExitNodeID
netmap?.Peers?.let { peers ->
val allNodes =
peers
@ -72,7 +73,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
ExitNode(
id = it.StableID,
label = it.displayName,
online = it.Online ?: false,
online = MutableStateFlow(it.Online ?: false),
selected = it.StableID == exitNodeId,
mullvad = it.Name.endsWith(".mullvad.ts.net."),
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) })
val allMullvadExitNodes =
allNodes.filter {
allNodes.filter { node ->
// 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 =
allMullvadExitNodes
@ -128,6 +130,13 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
mullvadBestAvailableByCountry.set(bestAvailableByCountry)
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,8 +146,9 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
LoadingIndicator.start()
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeID = node.id
Client(viewModelScope).editPrefs(prefsOut) {
nav.onNavigateHome()
nav.onNavigateBackHome()
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
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.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.IPNReceiver
import com.tailscale.ipn.UninitializedApp
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.UserID
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.util.TSLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
/**
@ -32,9 +31,36 @@ open class IpnViewModel : ViewModel() {
val loggedInUser: StateFlow<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.
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 {
viewModelScope.launch {
Notifier.state.collect {
@ -56,81 +82,174 @@ open class IpnViewModel : ViewModel() {
}
}
viewModelScope.launch { loadUserProfiles() }
Log.d(TAG, "Created")
viewModelScope.launch {
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() {
Client(viewModelScope).profiles { result ->
result.onSuccess(loginProfiles::set).onFailure {
Log.e(TAG, "Error loading profiles: ${it.message}")
viewModelScope.launch { loadUserProfiles() }
viewModelScope.launch {
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
}
}
Client(viewModelScope).currentProfile { result ->
result
.onSuccess { loggedInUser.set(if (it.isEmpty()) null else it) }
.onFailure { Log.e(TAG, "Error loading current profile: ${it.message}") }
exitNodePeer != null -> {
if (!validPrefs.activeExitNodeID.isNullOrEmpty()) {
NodeState.ACTIVE_AND_RUNNING
} else {
NodeState.ACTIVE_NOT_RUNNING
}
}
fun toggleVpn() {
when (Notifier.state.value) {
Ipn.State.Running -> stopVPN()
else -> startVPN()
isRunningExitNode == true -> {
NodeState.RUNNING_AS_EXIT_NODE
}
else -> {
NodeState.NONE
}
}
}
.collect { nodeState -> _nodeState.value = nodeState }
}
TSLog.d(TAG, "Created")
}
// VPN Control
fun startVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_CONNECT_VPN
context.sendBroadcast(intent)
UninitializedApp.get().startVPN()
}
fun stopVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_DISCONNECT_VPN
context.sendBroadcast(intent)
UninitializedApp.get().stopVPN()
}
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 ->
result
.onSuccess { Log.d(TAG, "Login started: $it") }
.onFailure { Log.e(TAG, "Error starting login: ${it.message}") }
.onSuccess { TSLog.d(TAG, "Login started: $it") }
.onFailure { TSLog.e(TAG, "Error starting login: ${it.message}") }
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 = {}) {
Client(viewModelScope).logout { result ->
result
.onSuccess { Log.d(TAG, "Logout started: $it") }
.onFailure { Log.e(TAG, "Error starting logout: ${it.message}") }
.onSuccess { TSLog.d(TAG, "Logout started: $it") }
.onFailure { TSLog.e(TAG, "Error starting logout: ${it.message}") }
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) {
val switchProfile = {
Client(viewModelScope).switchProfile(profile) {
startVPN()
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) {
Client(viewModelScope).addProfile {
if (it.isSuccess) {
login {}
login()
}
startVPN()
completionHandler(it)
@ -144,51 +263,58 @@ open class IpnViewModel : ViewModel() {
}
}
// The below handle all types of preference modifications typically invoked by the UI.
// 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
// value of the pref setting in the back end (and will match the value returned here).
// 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.
//
// Usage:
// - User/Interface changed to new value. Render the new value.
// - Submit the new value to the PrefsEditor
// - Observe the prefs on the IpnModel and update the UI when/if the value changes.
// For a typical flow, the changed value should reflect the value already shown.
// - Inform the user of any error which may have occurred
//
// The "toggle' functions here will attempt to set the pref value to the inverse of
// 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
Client(viewModelScope).editPrefs(Ipn.MaskedPrefs(), callback)
}
fun toggleShieldsUp(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs =
Notifier.prefs.value
?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleShieldsUp
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ShieldsUp = !prefs.ShieldsUp
Client(viewModelScope).editPrefs(prefsOut, callback)
}
fun toggleRouteAll(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs =
Notifier.prefs.value
?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleRouteAll
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.RouteAll = !prefs.RouteAll
Client(viewModelScope).editPrefs(prefsOut, callback)
// Exit Node Manipulation
fun toggleExitNode() {
val prefs = prefs.value ?: return
LoadingIndicator.start()
if (prefs.activeExitNodeID != null) {
// We have an active exit node so we should keep it, but disable it
Client(viewModelScope).setUseExitNode(false) { LoadingIndicator.stop() }
} else if (prefs.selectedExitNodeID != null) {
// We have a prior exit node to enable
Client(viewModelScope).setUseExitNode(true) { LoadingIndicator.stop() }
} else {
// This should not be possible. In this state the button is hidden
TSLog.e(TAG, "No exit node to disable and no prior exit node to enable")
}
}
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()
TSLog.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
}
}

@ -0,0 +1,78 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import android.graphics.Bitmap
import android.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.lifecycle.viewModelScope
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.WriterException
import com.google.zxing.qrcode.QRCodeWriter
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class LoginQRViewModel : IpnViewModel() {
val numCode: StateFlow<String?> = MutableStateFlow(null)
val qrCode: StateFlow<ImageBitmap?> = MutableStateFlow(null)
init {
viewModelScope.launch {
Notifier.browseToURL.collect { url ->
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)
}
}
}
}
fun generateQRCode(content: String, size: Int, padding: Int): ImageBitmap? {
val qrCodeWriter = QRCodeWriter()
val encodeHints = mapOf<EncodeHintType, Any?>(EncodeHintType.MARGIN to padding)
val bitmapMatrix =
try {
qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, size, size, encodeHints)
} catch (ex: WriterException) {
return null
}
val qrCode =
Bitmap.createBitmap(
size,
size,
Bitmap.Config.ARGB_8888,
)
for (x in 0 until size) {
for (y in 0 until size) {
val shouldColorPixel = bitmapMatrix?.get(x, y) ?: false
val pixelColor = if (shouldColorPixel) Color.BLACK else Color.WHITE
qrCode.setPixel(x, y, pixelColor)
}
}
return qrCode.asImageBitmap()
}
}

@ -3,27 +3,56 @@
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 com.tailscale.ipn.App
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.State
import com.tailscale.ipn.ui.model.Tailcfg
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.PeerSet
import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
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
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
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
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
val ipnState = Notifier.state
val prefs = Notifier.prefs
val netmap = Notifier.netmap
// The active search term for filtering peers
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()
init {
viewModelScope.launch {
Notifier.state.collect { state ->
stateRes.set(state.userStringRes())
vpnToggleState.set(
(state == State.Running || state == State.Starting || state == State.NoState))
var previousState: State? = null
combine(Notifier.state, isVpnActive) { state, active -> state to active }
.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 ->
peerCategorizer.regenerateGroupedPeers(netmap)
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 {
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)
}
fun disableExitNode() {
LoadingIndicator.start()
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeID = null
Client(viewModelScope).editPrefs(prefsOut) { LoadingIndicator.stop() }
fun setVpnPermissionLauncher(launcher: ActivityResultLauncher<Intent>) {
// No intent means we're already authorized
vpnPermissionLauncher = launcher
}
}
private fun State?.userStringRes(): Int {
return when (this) {
State.NoState -> R.string.waiting
State.InUseOtherUser -> R.string.placeholder
State.NeedsLogin -> R.string.please_login
State.NeedsMachineAuth -> R.string.placeholder
State.Stopped -> R.string.stopped
State.Starting -> R.string.starting
State.Running -> R.string.connected
private fun userStringRes(currentState: State?, previousState: State?, vpnActive: Boolean): Int {
return when {
previousState == State.NoState && currentState == State.Starting -> R.string.starting
currentState == State.NoState -> R.string.placeholder
currentState == State.InUseOtherUser -> R.string.placeholder
currentState == State.NeedsLogin ->
if (vpnActive) R.string.please_login else R.string.connect_to_vpn
currentState == State.NeedsMachineAuth -> R.string.needs_machine_auth
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
}
}

@ -6,7 +6,6 @@ package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.notifier.Notifier
@ -19,16 +18,24 @@ import java.io.File
data class PeerSettingInfo(val titleRes: Int, val value: ComposableStringFormatter)
class PeerDetailsViewModelFactory(private val nodeId: StableNodeID, private val filesDir: File) :
ViewModelProvider.Factory {
class PeerDetailsViewModelFactory(
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 {
return PeerDetailsViewModel(nodeId, filesDir) as T
return PeerDetailsViewModel(nodeId, filesDir, pingViewModel) as T
}
}
class PeerDetailsViewModel(val nodeId: StableNodeID, val filesDir: File) : IpnViewModel() {
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
class PeerDetailsViewModel(
val nodeId: StableNodeID,
val filesDir: File,
val pingViewModel: PingViewModel
) : IpnViewModel() {
val node: StateFlow<Tailcfg.Node?> = MutableStateFlow(null)
val isPinging: StateFlow<Boolean> = MutableStateFlow(false)
init {
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,8 +4,9 @@
package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.localapi.Client
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
@ -15,22 +16,39 @@ data class SettingsNav(
val onNavigateToBugReport: () -> Unit,
val onNavigateToAbout: () -> Unit,
val onNavigateToDNSSettings: () -> Unit,
val onNavigateToSplitTunneling: () -> Unit,
val onNavigateToTailnetLock: () -> Unit,
val onNavigateToMDMSettings: () -> Unit,
val onNavigateToManagedBy: () -> Unit,
val onNavigateToUserSwitcher: () -> Unit,
val onNavigateToPermissions: () -> Unit,
val onBackPressed: () -> Unit,
val onNavigateBackHome: () -> Unit,
val onBackToSettings: () -> Unit,
)
class SettingsViewModel() : IpnViewModel() {
class SettingsViewModel : IpnViewModel() {
// Display name for the logged in user
val isAdmin: StateFlow<Boolean> = MutableStateFlow(false)
val managedByOrganization = MDMSettings.managedByOrganizationName.flow
// True if tailnet lock is enabled. nil if not yet known.
val tailNetLockEnabled: StateFlow<Boolean?> = MutableStateFlow(null)
// True if tailscaleDNS is enabled. nil if not yet known.
val corpDNSEnabled: StateFlow<Boolean?> = MutableStateFlow(null)
init {
viewModelScope.launch {
Notifier.netmap.collect { netmap -> isAdmin.set(netmap?.SelfNode?.isAdmin ?: false) }
}
Client(viewModelScope).tailnetLockStatus { result ->
result.onSuccess { status -> tailNetLockEnabled.set(status.Enabled) }
LoadingIndicator.stop()
}
viewModelScope.launch {
Notifier.prefs.collect {
it?.let { 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
import android.content.Context
import android.util.Log
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
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.CheckedIndicator
import com.tailscale.ipn.ui.view.ErrorDialogType
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -36,13 +36,14 @@ class TaildropViewModelFactory(
private val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>>,
private val applicationScope: CoroutineScope
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return TaildropViewModel(requestedTransfers, applicationScope) as T
}
}
class TaildropViewModel(
val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>>,
private val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>>,
private val applicationScope: CoroutineScope
) : IpnViewModel() {
@ -143,7 +144,7 @@ class TaildropViewModel(
allSharablePeers.filter { !(it.Online ?: false) }.sortedBy { it.Name }
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) {
// Check our outgoing files for the peer and determine the state of the transfer.
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

@ -16,7 +16,8 @@ import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
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 {
return TailnetLockSetupViewModel() as T
}
@ -24,7 +25,7 @@ class TailnetLockSetupViewModelFactory() : ViewModelProvider.Factory {
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 nodeKey: StateFlow<String> = MutableStateFlow("unknown")

@ -3,11 +3,6 @@
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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -19,49 +14,4 @@ class UserSwitcherViewModel : IpnViewModel() {
// True if we should render the kebab menu
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

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

Loading…
Cancel
Save