Compare commits

...

836 Commits

Author SHA1 Message Date
kari-ts 08a062bfcf
android: show hex code for TV log in (#557)
Remove flag gating the display of a hex code for Android TV users, now that the change allowing hex code input in the admin console is merged.
Fixes tailscale/tailscale#13277

Signed-off-by: kari-ts <kari@tailscale.com>
3 days ago
kari-ts 4c4148bd8e
android: fix issue where default avatar wasn't shown (#558)
Always render the default icon first so that if the profile picture is not loaded or has an issue, the default is shown.

Fixes tailscale/corp#24217

Signed-off-by: kari-ts <kari@tailscale.com>
7 days ago
kari-ts 18ca09d0f3
android: fix MainActivityTest (#550)
-Permissions are shown after 'Get Started' screen, fix ordering in test
-Tap 'Authorize Tailscale'
-Re-add instrumentation test runner in build.gradle

Updates tailscale/corp#24242

Signed-off-by: kari-ts <kari@tailscale.com>
7 days ago
kari-ts bd745b5254
android: fix avatar padding (#559)
Update Avatar to take isFocusable as a parameter, allowing us to make the avatar focusable in the main view but not in the settings / user switcher view. This fixes the issue where the padding is too big in the settings / user switcher view.

Fixes tailscale/corp#24370

Signed-off-by: kari-ts <kari@tailscale.com>
1 week ago
kari-ts ba306bf883
android: use a coroutine for loadfiles (#551)
contentResolver.query is attempting to perform a network query on the main thread. Move this to a coroutine to prevent blocking.

Fixes tailscale/corp#24293

Signed-off-by: kari-ts <kari@tailscale.com>
2 weeks ago
James Tucker e89c259749 Makefile: fix clean action dependencies
The explicit target was removed during patch production, but the
dependency wasn't removed from the clean action.

Updates #546
Updates tailscale/tailscale#13850

Signed-off-by: James Tucker <james@tailscale.com>
2 weeks ago
Andrea Gottardo c1ef8b5f20
android: bump OSS to 1.77.65-t698536947-ge7325f7d5 (#552)
android: bump OSS

OSS and Version updated to 1.77.65-t698536947-ge7325f7d5

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
2 weeks ago
James Tucker e7325f7d5f Makefile,*: use tailscale.com/cmd/mkversion
We've suffered misalignment in versioning and toolchain usage due to the
shell invocations downstream of ./version/tailscale-version.sh, but also
the whole version data scheme in the Makefile was quite complicated, and
required synchronization in the build.grade.

- Makfile no longer needs to be version aware itself.
- A Makefile target tailscale.version refreshes a local cached output
  from tailscale.com/cmd/mkversion which is updated when go.mod / go.sum
  change.
- build.gradle loads tailscale.version to get the version string.
- ldflags are produced from tailscale.version via version-ldflags.sh

Updates tailscale/tailscale#13850

Signed-off-by: James Tucker <james@tailscale.com>
2 weeks ago
kari-ts c7b1362451
android: use native search (#547)
-Add dynamic suggestions
-Use search bar with expanded view showing suggestions
-dpad: only open keyboard when clicked on and not on scroll

Updates tailscale/corp#18973
Fixes tailscale/corp#19231

Signed-off-by: kari-ts <kari@tailscale.com>
2 weeks ago
kari-ts 0bd4ef932b
android: bump OSS (#549)
OSS and Version updated to 1.77.44-tc0a1ed86c-gcafb114ae0a

Signed-off-by: kari-ts <kari@tailscale.com>
3 weeks ago
kari-ts cafb114ae0
android: don't show permissions for TV (#548)
Android TV has limited support for notifications compared to mobile - notifications are not show in the system UI to provide a leanback experience. Remove 'Permissions' from Settings menu.

Fixes tailscale/corp/#21034

Signed-off-by: kari-ts <kari@tailscale.com>
3 weeks ago
kari-ts af98b14770
android: hide disconnect action if force enabled (#539)
In notification, don't show 'Disconnect' button if MDM force enable is on.

Fixes tailscale/corp#23764

Signed-off-by: kari-ts <kari@tailscale.com>
3 weeks ago
Brad Fitzpatrick 2e9f6b735e Makefile: fix typo in comment
Updates #cleanup

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
4 weeks ago
kari-ts 354a903ee1
android: make tailnet lock setup view focusable and clickable (#544)
-use a shared InteractionSource for focusing and clicking to ensure they rely on the same state and to coordinate so that visual feedback is shown on scroll without affecting the click InteractionSource
-use LocalIndication to ensure that the click interaction maintains the visual feedback when combined with focusable
-use onFocusChanged to explicitly track the focus state

Updates tailscale/corp#21737

Signed-off-by: kari-ts <kari@tailscale.com>
4 weeks ago
kari-ts 6ec54234ef
android: fix avatar focusable (#538)
-Apply focusable() directly to outer Box instead of nested with clickable()
-Explicitly handle focus
-Simplify focusable area

Fixes tailscale/corp/#23762

Signed-off-by: kari-ts <kari@tailscale.com>
Signed-off-by: kari-ts <135075563+kari-ts@users.noreply.github.com>
4 weeks ago
kari-ts 18b8c78754
android: update PR description for bumposs to use "bump" (#543)
Updates #cleanup

Signed-off-by: kari-ts <kari@tailscale.com>
1 month ago
kari-ts 83f3f737ad
android: bump OSS (#542)
OSS and Version updated to 1.77.12-ta8f9c0d6e-g753b8d3fb4b

Signed-off-by: kari-ts <kari@tailscale.com>
1 month ago
kari-ts 753b8d3fb4
android: handle multiple redundant intents (#541)
Use FLAG_UPDATE_CURRENT for managing multiple calls to startForegroundService. This ensures only one instance of the intent is active and replaces any previously pending intents with the latest one.

Fixes tailscale/corp#23828

Signed-off-by: kari-ts <kari@tailscale.com>
1 month ago
Jonathan Nobels 8ff0672ec7
android: bumping OSS (#540)
OSS and Version updated to 1.77.0-tacb4a22dc-g5f19730c7a4

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
1 month ago
Andrea Gottardo 47cde89984
android: update dependencies (#535)
Updates #cleanup

Bumps our project dependencies to the latest versions. Verified that the project builds properly.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
1 month ago
Keli 5f19730c7a
android: bumping OSS (#537)
OSS and Version updated to 1.75.104-tf6d4d0335-gd309f31b5ab

Signed-off-by: Keli Velazquez <keli@tailscale.com>
1 month ago
kari-ts d309f31b5a
android: don't show hex code yet (#536)
Hold off on showing the code until there is a place in the admin console for the user to input the code.

Updates tailscale/tailscale#13277

Signed-off-by: kari-ts <kari@tailscale.com>
1 month ago
Andrea Gottardo cd993fee43
ui: hide commit hashes in user-facing version string (#534)
We currently show the full version number everywhere. This pointlessly causes confusion for users, and is only really useful for Tailscale employees. Let's show the marketing version everywhere instead.

Users can still tap on the version number to copy the full version string. The extended version is also available in the Android settings, when inspecting Tailscale from the Apps list.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
1 month ago
Keli a32c2aa0df
android: bumping OSS (#532)
OSS and Version updated to 1.75.81-t4ad3f0122-g0126db799b1

Signed-off-by: Keli Velazquez <keli@tailscale.com>
1 month ago
Andrea Gottardo 0126db799b
ui/model: adjust default control server URL (#531)
Updates tailscale/corp#23660

I screwed up by not including 'https://' in a last-minute refactoring :-)
1 month ago
Andrea Gottardo 4ca757bb75
android: bumping OSS to 1.75.80 (#530)
android: bumping OSS

OSS and Version updated to 1.75.80-t8fdffb8da-g2daeee584df

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
1 month ago
Andrea Gottardo 2daeee584d
ui/UserView: show custom control server URL in account switcher (#529) 1 month ago
kari-ts be89cb10fe
android: bumping OSS (#528)
OSS and Version updated to 1.75.58-t262c526c4-gf5ecca3c967

Signed-off-by: kari-ts <kari@tailscale.com>
1 month ago
kari-ts f5ecca3c96
android: StringArrayListMDMSetting should check for String[] (#527)
getFromBundle should check for both String[] and ArrayList<String>

Fixes tailscale/corp#23557

Signed-off-by: kari-ts <kari@tailscale.com>
1 month ago
Kristoffer Dalby 8eabe8d6dd
android: bumping OSS (#526)
android: bumping OSS

OSS and Version updated to 1.75.56-t1eaad7d3d-g625f6f02352

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2 months ago
Kristoffer Dalby 2f08e2f02d
libtailscale: add metrics to NewUserspaceEngine (#525)
Updates tailscale/corp#22075

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2 months ago
kari-ts 9572541648
android: bumping OSS (#524)
OSS and Version updated to 1.75.51-ta70287d32-gc10aca720b8

Signed-off-by: kari-ts <kari@tailscale.com>
2 months ago
kari-ts c10aca720b
android: don't set vpnService to nil when state is Stopped (#523)
We are currently setting vpnService.service to nil:
-any time there’s an error with updateTUN
-when we exit out of runBackend
-if the config is the default config (aka when the ipn state is Stopped)

When it gets set to nil, we don’t handle state or config updates by calling updateTUN until after startVPN is called again.
The second case never happens because there’s no condition to break out of the loop in runBackend and ctx is uncancelable per the doc for context.Background()
In the third case, we should not establish the VPN; the state is already in the correct Stopped state, but there’s no need to set the service to nil and prevent updateTUN from being called. The quick settings tile bug is caused by this third case, where because the saved prefs starts the app up in the Stopped state, the config is set to the default config, and the service is set to nil, and we can't updateTUN until there’s another startVPN call.

This PR:
-cleans up the updateTUN error handling to be more consistent
-removes the IPNService parameter from updateTUN so that vpnService.service is not set to nil in the third case
-updates IPNService to use stopSelf and not stopForeground when we disconnect the VPN; the latter only disconnects if there is a memory need

Fixes tailscale/tailscale#12489

Signed-off-by: kari-ts <kari@tailscale.com>
2 months ago
kari-ts 25e7681c32
android: set VPN status in service APIs (#522)
This is mainly a no-op; right now we are setting the VPN status when we successfully edit prefs with wantRunning=false, but the VPN status is separate from tailscaled status and reflects the status of the VPN interface. This change moves that status update into the Android Service APIs.

Updates tailscale/tailscale#12850
Updates tailscale/tailscale#12489

Signed-off-by: kari-ts <kari@tailscale.com>
2 months ago
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 months 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>
2 months 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>
2 months 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>
2 months ago
kari-ts 9654bb5d9d
android: include hex in LoginQRView (#502)
Updates tailscale/tailscale#13277

Signed-off-by: kari-ts <kari@tailscale.com>
2 months 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>
2 months 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>
2 months ago
kari-ts fc8ccc0057
go/toolchain: use ed9dc37b2b000f376a3e819cbb159e2c17a2dac6 (#514)
Updates #cleanup

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

OSS and Version updated to 1.75.11-t8b962f23d-gf07d419a125

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

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

Updates tailscale/tailscale#13173

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

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

Signed-off-by: James Tucker <james@tailscale.com>
2 months 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 months 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 months 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 months 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 months 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 months 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 months 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 months 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 months 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 months ago
Brad Fitzpatrick 33f79deb3a tool/go: fix typo in comment
Updates #cleanup

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2 months 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 months 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 months 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 months 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>
2 months 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>
2 months 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>
2 months ago
Anton Tolchanov 095dae1195 android: exclude MDM classes from ProGuard optimizations
Updates tailscale/corp#22797

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
2 months 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>
3 months ago
kari-ts 18e4b176c6
android: fix missing '}' issue (#487)
also run linter

Updates #cleanup

Signed-off-by: kari-ts <kari@tailscale.com>
3 months 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>
3 months ago
yin kaisheng a9ff204ae4
android: fix Hostname type in MaskedPrefs, it should be String type (#482) 3 months 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>
3 months ago
kari-ts d94125e767
android: make settings button focusable and clickable (#484)
Fixes tailscale/corp#22717

Signed-off-by: kari-ts <kari@tailscale.com>
3 months 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>
3 months 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>
3 months 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>
3 months 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>
3 months 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>
3 months 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>
3 months 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>
3 months 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>
3 months 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>
3 months 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>
3 months 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>
3 months ago
Andrea Gottardo a843c93669
Revert "VPNServiceBuilder: document excludeRoute not supported on pre-33 API" (#471) 3 months 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.
3 months 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>
3 months 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>
3 months 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>
3 months 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>
3 months 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>
3 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>
3 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>
3 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>
3 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>
3 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>
3 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>
3 months ago
Andrea Gottardo ea0c1e960d
android: remove Google Stadia from hardcoded exclusions list (#457) 4 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>
4 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>
4 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>
4 months ago
Jonathan Nobels 23454e9bc6
android: bump oss and version code (#452)
Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
4 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>
4 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>
4 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>
4 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>
4 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>
4 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>
4 months ago
Jonathan Nobels 65a025007f
android: bump version code (#446)
Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
4 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>
4 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>
4 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>
4 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>
4 months ago
Jonathan Nobels 2a8d07c5f6
android: bump version code (#439)
Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
4 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>
4 months ago
Andrea Gottardo a120eb2fe1
ipn: update dependencies (#432)
Updates project dependencies.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
5 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>
5 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>
5 months ago
Andrea Gottardo b6cacdfd6a
go.mod: bump OSS to 20240625185613 (#433)
Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
5 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>
5 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>
5 months ago
Andrea Gottardo 9ae30c06bf
repo: add .DS_Store to .gitignore (#427)
See title :)

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

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
5 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>
5 months ago
Andrea Gottardo 8dc1a13f77
android: bump OSS to 20240619155934 (#428)
Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
5 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>
5 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>
5 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>
5 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>
5 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>
5 months ago
Jonathan Nobels 5b121c1876
android: bump oss to 1.69 (#421)
Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
5 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>
5 months ago
Jonathan Nobels ef21753763
android: bump version code (#419)
Bumping for 1.67 testing

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
5 months ago
Jonathan Nobels 0e82e54ffb
android: bump version code (#418)
Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
5 months ago
Jonathan Nobels 64fca2a712
android: bump OSS (#417)
Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
5 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>
5 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>
5 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>
6 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>
6 months ago
kari-ts 8f62f0da79
IpnViewModel: fix NPE (#413)
Fixes tailscale/tailscale#12281

Signed-off-by: kari-ts <kari@tailscale.com>
6 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>
6 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>
6 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>
6 months ago
Andrea Gottardo 75db9e64c8
gradle: update to 8.6 (#405) 6 months ago
kari-ts e826a173aa
android: enable proguard (#399)
Signed-off-by: kari-ts <kari@tailscale.com>
6 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>
6 months ago
kari-ts 72f35cd318
ExitNodePicker: recompose when connection status changes (#410)
Updates tailscale/corp#19122

Signed-off-by: kari-ts <kari@tailscale.com>
6 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>
6 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>
6 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>
6 months ago
kari-ts 999c6f2357
Notifier: init app if uninitialized (#404)
Fixes tailscale/corp#20087

Signed-off-by: kari-ts <kari@tailscale.com>
6 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.
6 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>
6 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>
6 months ago
kari-ts 0ff47f7ab5
android: fix import (#400)
Updates #cleanup

Signed-off-by: kari-ts <kari@tailscale.com>
6 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>
6 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>
6 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>
6 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
6 months ago
kari-ts c47ead9412
android: bump version code (#393)
Signed-off-by: kari-ts <kari@tailscale.com>
6 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>
6 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>
6 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>
6 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>
6 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>
6 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>
6 months ago
Percy Wegmann 698fb868a7 android: only navigate to main if navController is initialized
Updates #cleanup

Signed-off-by: Percy Wegmann <percy@tailscale.com>
6 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>
6 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>
6 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>
6 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>
6 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>
6 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>
6 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>
6 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>
6 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>
6 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>
6 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>
7 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>
7 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>
7 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>
7 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>
7 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>
7 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>
7 months ago
kari-ts 22c129ee1c
android: accessibility fixes (#359)
Updates tailscale/corp#18976

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

Signed-off-by: Percy Wegmann <percy@tailscale.com>
7 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>
7 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>
7 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>
7 months ago
Percy Wegmann 07d04ca750 android: pull latest OSS
Updates tailscale/corp#16827

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

Signed-off-by: Percy Wegmann <percy@tailscale.com>
7 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>
7 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>
7 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.
7 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>
7 months ago
Percy Wegmann bc8985126d android: enable Taildrive
Updates tailscale/corp#16827

Signed-off-by: Percy Wegmann <percy@tailscale.com>
7 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>
7 months ago
kari-ts 81acaef5b7
android: rip android_legacy (#335)
Updates #cleanup
Signed-off-by: kari-ts <kari@tailscale.com>
7 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>
7 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>
7 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>
7 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>
7 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>
7 months ago
Andrea Gottardo 56da7b66d0
android: bump OSS and bump version code to 206 (#329)
Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
7 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>
7 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>
7 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>
7 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>
7 months ago
Andrea Gottardo a73025b36f
mdm: throw ErrNoSuchKey when a value not defined in Android syspolicy handler (#325) 7 months ago
Andrea Gottardo 4d86c1a6f6
ui: don't show key expiry warning if key doesn't expire (#320) 7 months ago
Andrea Gottardo a1d97baeb0
Update README.md (#323) 7 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>
7 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>
7 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>
7 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>
7 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>
7 months ago
Andrea Gottardo 164a243b77
ui: reintroduce dark mode theme (#315) 7 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>
7 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>
7 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>
7 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>
7 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>
7 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>
7 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>
7 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>
7 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>
7 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>
7 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>
7 months ago
kari-ts a325a90558
Revert "ui: port syspolicy handler code to new app (#302)" (#303)
This reverts commit f14836a750.
7 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>
7 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>
7 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>
7 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>
7 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>
7 months ago
Percy Wegmann 2e237e375e
android/ui: stop treating settings as data (#294)
* android/ui: reflect latest state of allow LAN access setting

Updates tailscale/corp#18983

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

* android/ui: stop treating settings as data

Updates tailscale/corp#18983

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

---------

Signed-off-by: Percy Wegmann <percy@tailscale.com>
7 months ago
Percy Wegmann 71f03cf0d2 android: only reconfigure VPN when ready
This avoids reconfiguring the VPN both when routes changed and then
again when DNS changed.

Updates tailscale/corp#18928

Signed-off-by: Percy Wegmann <percy@tailscale.com>
7 months ago
kari-ts 5745854297
MainActivity: remove redundant Notifier start/stop (#293)
We're starting Notifier in App so that states are available outside of MA lifecycle.

Updates tailscale/corp#18202

Signed-off-by: kari-ts <kari@tailscale.com>
7 months ago
Jonathan Nobels b4c0a6931d
android/ui: restyle the run as exit node screen (#291)
Updates tailscale/corp#18202

Restyle the run as exit node screen per UX

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
Jonathan Nobels dbc809167e
android/ui: restyle the intro screen (#290)
Updates tailscale/corp#18202

Restyle the intro screen per UX

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
kari-ts f54e476328
IPNService: on close, edit prefs with WantRunning=false (#279)
This fixes the issue where when the VPN was turned off by system settings, the toggle was showing the user as conencted

Updates tailscale/corp#18202
Fixes tailscale/corp#18863

Signed-off-by: kari-ts <kari@tailscale.com>
8 months ago
Percy Wegmann ccda0499a7 android/ui: DNS and other styling tweaks
Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Percy Wegmann e7539f5ff3 android/ui: only show loading spinner if op takes more than 300 milliseconds
Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Percy Wegmann c0ffd5016b android/ui: DNS and other styling tweaks
Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Jonathan Nobels a0e7777958
android/ui: treat NoState similar to Starting (#285)
Updates tailscale/corp#18202

NoState is now treated similar to starting for the on/off switch (which matches behaviour of the string)

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
Percy Wegmann ef894fa8ca android/ui: new icon for about screen
Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Percy Wegmann c3dac5954e android/ui: permissions styling feedback
Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Percy Wegmann 54dccff232 android/ui: strip DWARF debug information from libtailscale
Reduces total size for 4 architectures from 59517778 to 33420646

Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Jonathan Nobels 31939cc855
android/ui: change exit node disable string to 'stop' (#281)
Updates tailscale/corp#18202

Updates the exit node stop button to 'stop' instead of 'disable' pending the required back end changes to remember the preferred exit node.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
Jonathan Nobels 75ad5cfef6
android/ui: show the switching-to user immediately on FUS switch (#280)
Updates tailscale/corp#18202

Reload the loggedInUser on all state transitions to ensure FUS switches are always properly represented

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
Jonathan Nobels d188da3a24
android/ui: add compose-style splash screen (#283)
Updates tailscale/corp#18202

Updates the splash screen to the modern themed jetpack compose variant.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
Jonathan Nobels 9fcc1ddfe1
android/ui: disable landscape on small screen devices (#278)
Updates tailscale/corp#18202

For our initial release, we will only support landscape on large screen devices (tablets, chromebooks).

Tested on a tabletizable chromebook and a variety of simulators and this seems to be a decent compromise until we have fully landscape capable layouts.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
Jonathan Nobels 3b21a06c8b
android/ui: add key expiry banner (#276)
fixes ENG-2912

copies and adds the a key expiry banner identical to the one on iOS.

fixes a couple of small layout issues with the search bar

fixes a potential json issue where ComputedName is optional in goland but it was not marked as so in Kotlin.  Switched to node.displayName everywhere, which uses ComputedName otherwise, Name.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
kari-ts 9b27516e96
MainActivity: bring activity to focus on login (#273)
Use CustomTabsIntent to launch login, but fall back to regular intent if Chrome is not installed
When logging in, on change in state to login complete, bring activity back to focus

Updates tailscale/corp#18202

Signed-off-by: kari-ts <kari@tailscale.com>
8 months ago
Percy Wegmann 1719d5d558 android/ui: auto-resize tailnet name to fit space
Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Percy Wegmann d332ce049e android/ui: more styling tweaks
Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Percy Wegmann 91c1a8d0f3 android/ui: distinguish Tailnet domains in user view
Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Jonathan Nobels e9465988dd
android/ui: restyle the search bar (#272)
Updates tailscale/corp#18202

restyles the search bar to look a little more iOS like.  switch to a normal text field which gives us much more flexibility over the look and all of the flexbiility of SearchBar.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
Percy Wegmann 6e503f29a9 android/ui: implement design feedback
Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Jonathan Nobels a321d84dba
android/ui: add support for logging in using a custom control server (#266)
fixes ENG-2871

Adds a menu option under the FUS for logging in with your custom control server.  Follows the general pattern used on macOS.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
Will Norris 77f720dba7 .github/workflows: remove go-licenses action
This is now handled by an action running in corp.

Updates tailscale/corp#18803

Signed-off-by: Will Norris <will@tailscale.com>
8 months ago
kari-ts 3f816eac4d
ui: fix PeerDetails connection status and conncolor (#268)
view model should also check if the node is self and if connected when determining connection status string and conncolor

Updates tailscale/corp#18202

Signed-off-by: kari-ts <kari@tailscale.com>
8 months ago
Percy Wegmann 9fb742bd8b android/ui: apply exit node selector design feedback
Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
kari-ts dca2fc3bf4
UserSwitcherView: hide logout option if not logged in (#264)
Updates tailscale/corp#18202

Signed-off-by: kari-ts <kari@tailscale.com>
8 months ago
Jonathan Nobels 67a9320d26
android/ui: hide mullvad exit nodes in peer list (#263)
Updates tailscale/corp#18202

hides the mullvad nodes (basesd on their Name) in the peers list.  This differs slightly from the iOS logic where we use the Location property, but it feels like a better approach since Location is optional in the HostInfo and may not always be present.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
Percy Wegmann 4897f09e50 android/ui: use faster sliding animations for nav transitions
Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Percy Wegmann 8105271d25 android/ui: speed up loading of SettingsView
Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Jonathan Nobels 2818195400
android/permissions: limit write permission request to api 30 (#261)
Updates tailscale/corp#18202

the WRITE_EXTERNAL_STORAGE permission is only requried for API level <30.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
Percy Wegmann e024c896c1 android/ui: display correct flag on exit node picker
Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Percy Wegmann cfd01af74a android/ui: navigate back home after switching users
Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
kari-ts facf6406c3
Clean up Google sign in (#258)
This is unnecessary; we are just using browser login

Updates tailscale/corp#18202

Signed-off-by: kari-ts <kari@tailscale.com>
8 months ago
kari-ts af2e33d130
MainView, strings: show toggle when logged out (#257)
Fix logged out strings

Updates tailscale/corp#18202

Signed-off-by: kari-ts <kari@tailscale.com>
8 months ago
Jonathan Nobels cf56dd6793
android/ui: add one-time intro screen (#253)
* android/ui: add one-time intro screen

fixes ENG-2910

adds a one-time intro screen mostly identical to the one presented in the legacy app.

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

* android/ui: string change

tailscale -> Tailscale

Co-authored-by: Andrea Gottardo <andrea@tailscale.com>
Signed-off-by: Jonathan Nobels <jnobels@gmail.com>

---------

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
Signed-off-by: Jonathan Nobels <jnobels@gmail.com>
Co-authored-by: Andrea Gottardo <andrea@tailscale.com>
8 months ago
kari-ts 4baec5ff80
App.kt: fix Quick tailscale (#256)
-Once we edit prefs with wantRunning, also update QuickToggleService
-Start Notifier in App so that we are observing tile status

Closes #ENG-2869

Updates tailscale/corp#18202

Signed-off-by: kari-ts <kari@tailscale.com>
8 months ago
Jonathan Nobels 61fb6bbf8e
android/taildrop: support direct mode for incoming taildrop (#251)
Updates tailscale/corp#18202

Implements direct mode support for incoming taildrop files.  None of the localAPI endpoints are implemented here but this will get taildrop files to the right places.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
Percy Wegmann 5599f2ddeb android/ui: add back fast user switching status and clean up avatar scaling
Updates #cleanup

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Jonathan Nobels e59112a8fb
android/ui: implement outgoing taildrop support (#242)
* android/ui: implement outgoing taildrop support

Updates tailscale/corp#18202

Adds share activity to handle outgoing taildrop requests.

This unbreaks the WaitingFiles notification for incoming files, but does not yet properly handle them.

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

* android/ui: add transfer ID to outgoing file transfers (#245)

Helps track status of transfers.

Updates #ENG-2868

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

* android/ui: taildrop string change

Updates tailscale/corp#18202

Co-authored-by: Andrea Gottardo <andrea@tailscale.com>
Signed-off-by: Jonathan Nobels <jnobels@gmail.com>

* android: bumping oss to pick up new taildrop support

Updates tailscale/corp#18202

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

* android: remove write storage permission check

Updates tailscale/corp#18202

This is not required and the jni callback does't actually do what we need in the new client.

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

---------

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
Signed-off-by: Percy Wegmann <percy@tailscale.com>
Signed-off-by: Jonathan Nobels <jnobels@gmail.com>
Co-authored-by: Percy Wegmann <percy@tailscale.com>
Co-authored-by: Andrea Gottardo <andrea@tailscale.com>
8 months ago
Percy Wegmann db3ba696eb android/ui: restyle DNS, bug report and tailnet lock settings to match material design
Also tweak Exit Node Picker.

Updates #cleanup

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Percy Wegmann 44ba20a24e android/ui: prompt for permissions and show list of permissions with statuses
Updates #ENG-2948

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Percy Wegmann 8e063051b6 android/ui: updated MDM settings screen to material design
Updates #cleanup

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Percy Wegmann 7392c7086e android/ui: updated main settings screen to material design
Updates #cleanup

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Percy Wegmann 9f3e871637 android/ui: prompt for write external storage permission and show error if necessary
Updates #ENG-2948

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Andrea Gottardo e511430f73
android: add app_restrictions.xml and manifest entry (#248)
Fixes ENG-2926

Adds an `app_restrictions.xml` file with our available MDM policies, and a new entry to the AndroidManifest.xml file to declare its availability.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
8 months ago
Percy Wegmann cf6a203f7a android/ui: change click target for exit node picker
This makes sure only the relevant UI control flashes on click

Updates #cleanup

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Percy Wegmann fb5635b8a5 android: style exit node picker per Material UI and add disable button
Updates #ENG-2911

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Andrea Gottardo 3fea68ef2e
android/ui: add game of life to show progress when connecting (ENG-2860) (#244)
Fixes ENG-2860

Adds a game of life animation with the Tailscale logo when launching the app and waiting for the VPN tunnel to be established.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
8 months ago
Andrea Gottardo bf74edd551
android: add ExitNodeAllowLANAccess toggle in exit node picker (#241)
Updates ENG-3011

Just like on iOS, we should show a switch to toggle the ExitNodeAllowLANAccess preference.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
8 months ago
Percy Wegmann 28d0ab4dd6 android: add Client.postMultipart
Supports multipart requests to localapi

Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Percy Wegmann 6a875e8854 android: correctly grab DNS settings
Closes #ENG-3005

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Percy Wegmann a15fdd44bf android: show login button when state == Ipn.State.NeedsLogin
Closes #ENG-2988

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Will Norris 9fcdcfe630 Makefile: add help text to make commands
Add `make help` command, which prints available commands and their
description.  I'm not 100% sure which commands should be documented, so
took an initial attempt at it.

This also makes `make help` the default command, aligning it with the
Makefiles in corp and oss.

Updates tailscale/corp#18202

Signed-off-by: Will Norris <will@tailscale.com>
8 months ago
Andrea Gottardo e187a8db81
android: add DNS Settings view (#233)
Updates ENG-2990

This PR adds a DNS Settings view with the same functionality and items as the iOS one. It also moves the 'Use Tailscale DNS Settings' item out of the main settings view into the detail view.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
8 months ago
Andrea Gottardo f96e9b923f
android: add Tailnet lock setup UI (#231)
Updates ENG-2981

Adds a view to see the Tailnet lock settings and copy node key and public key, resembling the iOS and macOS ones.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
8 months ago
Andrea Gottardo 19adff3077
Revert "Revert "android: add UI to run as exit node (#230)" (#235)" (#237)
This reverts commit 0d1a3cf415.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
8 months ago
Jonathan Nobels e953b19189
android/ui: address preliminary design feedback
Updates tailscale/corp#18202

Adds back navigation to all of the headers.
Corrects all padding and some colours
Adds separators to the device list
Adds the Compat theme so we don't have the black top and bottom bars.
Removes all of the chevrons.
Other minor tweaks

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
James Tucker 5454b34dd1 Revert "[568eb59] android/ui: address preliminary design feedback (#227)"
This reverts commit 910511d838.

Signed-off-by: James Tucker <james@tailscale.com>
8 months ago
Andrea Gottardo 0d1a3cf415
Revert "android: add UI to run as exit node (#230)" (#235)
This reverts commit c3b62124bb.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
8 months ago
Andrea Gottardo c3b62124bb
android: add UI to run as exit node (#230)
Updates ENG-2913

This PR provides UI to let the user toggle AdvertisedRoutes by adding/removing the zero routes, with a view to warn the user about battery life impact and potential cellular data charges. Language and graphics to mimic what we currently show on Apple TV, final designs will follow as per @sonovawolf.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
8 months ago
Jonathan Nobels 910511d838
[568eb59] android/ui: address preliminary design feedback (#227)
Updates tailscale/corp#18202

Adds back navigation to all of the headers.
Corrects all padding and some colours
Adds separators to the device list
Adds the Compat theme so we don't have the black top and bottom bars.
Removes all of the chevrons.
Other minor tweaks

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
Co-authored-by: James Tucker <james@tailscale.com>
8 months ago
kari-ts b346321078
android: fix vpn (#232)
-Move most of prepare and establish VPN logic out of Go into Android
-Fix prepareVPN argument to use request codes to differentiate sign in and prepare VPN
-Fix missing adapter implementation (setMtu)

Updates tailscale/corp#18202

Signed-off-by: kari-ts <kari@tailscale.com>
8 months ago
Jonathan Nobels 7b7f7254ba
android: make intent optional in onStartCommand (#229)
Updates tailscale/corp#18202

Kotlin requires a nullable optional here.  The rest of this is ktfmt

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
kari-ts 72753bb82a
android: add ktfmt to gradle (#226)
Updates tailscale/corp#18202

Signed-off-by: kari-ts <kari@tailscale.com>
8 months ago
kari-ts 7470fcc173
android: disconnect (#228)
* android: fix connect

Kotlinize IPNService and App
Call connect in IPNService
Add observers for readiness to prepare VPN, and quick tile readiness
Start Notifier in App, since new state flows need to be observed outside of activity lifecycle

Next: fixing quick tiles

Updates tailscale/corp#18202

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

* android: disconnect

Use localapi to disconnect

Updates tailscale/corp#18202

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

---------

Signed-off-by: kari-ts <kari@tailscale.com>
8 months ago
James Tucker 4e923a65c1 *: introduce tool/go following our common pattern
- This tool/go does not currently invoke gocross, as there's more work to
do in gocross to be fully compatible with gomobile.
- Use GOBIN to target the output destinations for gomobile and gobind.
- Remove the old toolchain targets from the Makefile.
- Use ~/.cache/tailscale-go, like the OSS repo does.
- Introduce go.toolchain.rev as we have in the OSS repo, and update it in
the bumposs task.

Updates tailscale/corp#18202

Signed-off-by: James Tucker <james@tailscale.com>
8 months ago
James Tucker 2a14964878 Makefile: cleanup the new builds more
- Provide version flags to the build
- Add the new build targets to all
- Move the old fdroid and release builds close to their siblings
- Build gomobile into android/build/go for a small reduction in linker
  wait time
- Fix the test target so it's now triggering the gomobile build
- Make the install target use the new android build
- Add missing items to the clean target

Updates tailscale/corp#18202

Signed-off-by: James Tucker <james@tailscale.com>
8 months ago
James Tucker 244221706f Makefile: cleanup go mobile builds some
No need to build into /tmp, the go cache will handle this for us, we can
just use go run.

Updates tailscale/corp#18202

Signed-off-by: James Tucker <james@tailscale.com>
8 months ago
Jonathan Nobels b4f1989b67
android: fix NPE for empty localAPI response bodies (#220)
Updates tailscale/corp#18202

Several API endpoints will return an empty body on success which was throwing a null pointer exception when we tried deserialize it.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
Percy Wegmann 5e7e36e3bc android: switch to using gomobile
gomobile replaces our custom JNI bindings

Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
kari-ts 98a72c2963 android: new backend
Create pkg/tailscale, a Go library for the new Android app which handles starting up and running the local backend
-On initialization, get the JVM and app context to make JNI work
-Send filesystem directory path

Add a logging bridge from Go to Android (copied from Gio)

Add connect function which sends request to edit prefs instead of setting prefs

Future:
-Make build.gradle more portable
-Fix connect and make sure Quick Tiles still works

Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Jonathan Nobels f12439f9a3
android: request VPN permissions on launch (#219)
Updates tailscale/corp#18202

The actual requesting of VPN permissions was lost in a rebase. This change should prompt you on start.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
Jonathan Nobels 113a7c6f9d
android: use ktfmt formatting and use scaffold consistently across all views (#217)
* android: use scaffold consistently across all views

Updates tailscale/corp#18202

Updates all the main view to remove the surface containers and replaces them with a Scaffold.  All view now use a common Header element (a TopAppBar with common styling).

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

* android: run ktfmt over all kt, java and xml source files

Updates tailscale/corp#18202

Standardize code formatting using ktfmt default settings.

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

* android: update readme for new code formatting guidelines

Updates tailscale/corp#18202

Mandate the use of ktfmt in the default configuration.

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

---------

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
Jonathan Nobels e4b0e1f8cd
android: implement fast user switching (#209)
Updates tailscale/corp#18202
Updates ENG-2875
Fixes ENG-2863

Adds everything we need to do fast user switching and support multiple accounts.

Some work here to make the settings rows and a few other composables common and reusable.

Correct the focus and clear behavior on the search bar and corrected the connected in state of SelfNode.

Quick fix for requesting VPN permissions on newer Android phones.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
Percy Wegmann e568741081 android: make ExitNodePickerViewModel reactive
Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Percy Wegmann a1e67ff1e9 android: ViewModel cleanup
- Replace IpnManager, IpnModel and PrefsEditor with IpnViewModel
- Use lazy StateFlows in Notifier
- Manage view model lifecycles using viewModel() function
- Stop watching IPN bus when MainActivity stops
- Pass IPN notifications as ByteArray instead of string

Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Percy Wegmann d42329e2e2 android: simplify local API client
Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Anton Tolchanov e16303e1d8 Makefile: update aab file path
Seems like something that was missed in #182

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
8 months ago
Percy Wegmann 9a6aecb454 android: implement exit node picker
Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Andrea Gottardo 06e850bbd5
ui: assorted UI tweaks + disconnected view (#203) 8 months ago
Jonathan Nobels 4df18951a6
android/ui: fix time formatting strings and main view states (#204)
* android: fix time display localizations and show magic dns name

Updates tailscale/corp#18202

Localizations and some simplifications of the "in x time" conversion strings for node expiry.

We'll also now render the magicDNS name in the list of addresses.

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

* android: move the composablestringformatter to it's own file

Updates tailscale/corp#18202

This class deserves it's own file and some documentation

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

* android: show selfNode as connected only when it is connected

Updates tailscale/corp#18202

The selfNode connected state is now properly shown in the nodes list now that we're showing the nodes even when you're not connected to your tailnet.

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

---------

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
Jonathan Nobels 2c694b7159 android: optimize peer search
Updates tailscale/corp#18202

Switch to LazyColumn so we're not redrawing the entire list.

Modify the search logic so we're searching progressively and doing all of the sorting and categorization up front on netmap changes.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
Andrea Gottardo 7c64091aab
ui: add ManagedByView, hide MDMSettingsView on non-debug builds (#201)
Updates tailscale/corp#18202

- Adds the "Managed by OrganizationName" view we currently offer on iOS.
- Hides the MDM settings debug pane on non-debug builds.
- Refactored SettingsViewModel to take an `IpnManager` instead of an `IpnModel` (@barnstar, let me know whether this makes sense given your future plans)

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
8 months ago
James Tucker bf7bf94b52 .github/workflows,Makefile: add a build check CI for the new app
Updates tailscale/tailscale#10992

Signed-off-by: James Tucker <james@tailscale.com>
8 months ago
Jonathan Nobels 16ec19757d
android: adds support for user avatars and some general cleanup (#202)
* android: show user avatars and styling fixes

Updates tailscale/corp#18202
fixes ENG-2852

Load and show the user avatar in the right places.  There's a universal Avatar composable for this that should work everywhere we need it.  This  uses the coil-compose lib which seems to be standard practice and will handle caching for us.

Restyles a few headers to match the about screen and corrects some layout issues with the height of columns.

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

* android: add localizations and view model cleanup to match IPNManager

Updates tailscale/corp#18202

Simplifies the view models a bit for readability and localizes a few things that weren't previously localized

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

* android: fix peer categorization

Updates tailscale/corp#18202

Fixes a null predicate issue for searching and removes the self nodes if there are no matches.

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

* android: rename avatar loader to avatar and add header

Updates tailscale/corp#18202

Rename the AvatarLoader class to Avatar and move it to views.  Add the proper headers.

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

---------

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
Co-authored-by: Andrea Gottardo <andrea@tailscale.com>
8 months ago
Jonathan Nobels f275656c25
ui: add view to debug MDM settings and add the syspolicy handlers (#199)
* mdm: add Android syspolicy handler (#195)

Updates tailscale/corp#18202

Adds a syspolicy handler for Android in cmd/tailscale. This allows the Go code to use the syspolicy package to read values set by a system administrator using the Android RestrictionsManager.

Out of the box, this adds supports for a number of MDM policies that are fully integrated on the Go side, such as `ExitNodeID` (forced exit node functionality).

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>

* ui: add view to debug MDM settings

Adds a view to see the currently set MDM settings, we're going to need this to debug actual MDM integrations more effectively.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>

---------

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
Co-authored-by: Andrea Gottardo <andrea@gottardo.me>
8 months ago
Jonathan Nobels 1f457399b8
android: code review feedback and stylistic improvements (#200)
Updates tailscale/corp#18202

Review feedback and stylistic improvements.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
Co-authored-by: Andrea Gottardo <andrea@tailscale.com>
8 months ago
Jonathan Nobels 94a4f55eb2
android: implement the bug reporting and about screen and localize (#198)
updates tailscale/corp#18202
fixes ENG-2876

Adds the bug reporting view.  Functional, but not properly styled.

Moves the various link URLs to a constants file and corrects link-opening in both but reporting and the settings screen.

Adds an AboutView with app icon and same content as the iOS version.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
Co-authored-by: Andrea Gottardo <andrea@tailscale.com>
8 months ago
Jonathan Nobels 0d867aedce
mdm: implement initial data structure to read from Android RestrictionsManager (#197)
updates tailscale/corp#18202
updates ENG-2849

Implements the basic data model for supporting MDM to allow us to add the hooks in the UI.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
Co-authored-by: Andrea Gottardo <andrea@gottardo.me>
8 months ago
Jonathan Nobels bf0e56469f
android: Add settings screen (#196)
updates tailscale/corp#18202
updates ENG-2854

Adds a basic settings screen.  This isn't correctly localized, but that's on the way.

Adds the required hooks to edit prefs via localAPI.

Adds basic but incomplete login/logout flow.

Fixes the sorting of nodes on the main screen and fixes the proper display of your current node details.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
Jonathan Nobels 3926cf4b56
android: add main screen device details and basic nav (#191)
updates tailscale/corp#18202
updates ENG-2835
updates ENG-2859

Adds the peer details view and some supporting utilities. Eliminates all of the singletons.

None of this is styled correctly, but the layouts match iOS.

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

---------

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
James Tucker 87a8003d39 *: add a CI check for license headers and fix all files
Updates tailscale/tailscale#10992

Signed-off-by: James Tucker <james@tailscale.com>
8 months ago
Jonathan Nobels 4f46c38c99
Jonathan/notifier (#179)
android: add notifier support a data model and compose dependencies

fixes ENG-2084
fixes ENG-2086

Adds support for the ipnBusWatcher directly via a JNI API rather than HTTP via LocalAPIClient

Adds a rudimentary controller class and a model from which we can construct ViewModels

Cleans up some of the JNI bindings.  Adds hooks for ensuring the JNI setup is complete before attempting to do LocalAPIClient things.

Cleans up some wildcard imports.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
Signed-off-by: Percy Wegmann <percy@tailscale.com>
Co-authored-by: Percy Wegmann <percy@tailscale.com>
8 months ago
Anton Tolchanov a0f87846fd android: bump version code
Signed-off-by: Anton Tolchanov <anton@tailscale.com>
8 months ago
Anton Tolchanov 7d25cf97f8 Update OSS and latest version
Updates tailscale/corp#18098

Signed-off-by: Anton Tolchanov <anton@tailscale.com>
8 months ago
Jonathan Nobels 9a206805df
cmd/tailscale/main: remove tracked aar lib (#183)
Updates tailscale/tailscale#10748

The built aar should not be checked into android_legacy.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
8 months ago
kari-ts 01ec98f29a
cmd/tailscale/main: restore persisted settings (#169)
Fixes tailscale/tailscale#10748
Fixes tailscale/corp#17470

Signed-off-by: kari-ts <kari@tailscale.com>
8 months ago
Aalok Kamble f23477e796
Feature: machine status icon added. (#167)
* Feature: machine status icon added.

Signed-off-by: Aalok Kamble <aalok.kamble@gmail.com>

* Update ui.go

Aligned dot vertically with lowercase letters for machine names.
Reverted 'Machine' to 'My devices'

Signed-off-by: Aalok Kamble <aalok.kamble@gmail.com>

* status dot changed from string to drawdisc

Signed-off-by: Aalok Kamble <aalok.kamble@gmail.com>

---------

Signed-off-by: Aalok Kamble <aalok.kamble@gmail.com>
8 months ago
kari-ts 464f089388
android: restructure app (#182)
Make android_legacy for the old app and remove some of the new models from it
Modify Makefile to build the legacy app and the new app

Updates tailscale/tailscale#10992

Signed-off-by: kari-ts <kari@tailscale.com>
8 months ago
kari-ts 9492b01946
cmd/tailscale, tailscale/ipn: fix alway-on VPN (#168)
=If a ConnectEvent is received before the first notification, (as happens when a connection is attempted due to always-on after device reboot) create state.Prefs.
-Create an intent to start the VPN worker in the case of an always-on intent received on device reboot
-Rename onConnect channel to onVPNRequested, since this isn't doing the actual connecting

Fixes tailscale/tailscale#2481

Signed-off-by: kari-ts <kari@tailscale.com>
8 months ago
Jonathan Nobels bb7ea7cf9f
android: add kotlin dependencies build the kotlin->go localAPIClient (#173)
updates ENG-2805

Adds all of the kotlin build dependencies and a partial implementation of a LocalAPIClient in the front end, wired up via JNI.  The general idea here is to mimic the architecture used on other Tailscale clients, where the front ends largely interact with the backend via "localapi".

The LocalAPIClient in go has been renamed to LocalAPIService to avoid confusion with the implementation on the future client side in Kotlin.  Some mild refactoring was done to make the localAPI invocations methods on the api service instead of App.

Streaming notifier endpoints like watch-ipn-bus are not supported.  We will build out a separate set of JNI methods for dealing with those.

The jni package is moved under cmd where it is used.

This constains mostly-complete implementation of the required localAPI data classes based on the pieces that are used by the iOS and macOS clients.  The LocalAPIClient itself does not implement all of the endpoints, but is ready to do so when those APIs are needed by a UI component.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
9 months ago
Percy Wegmann 37832a5b72 go.mod: pull in latest tailscale.com
Updates #cleanup

Signed-off-by: Percy Wegmann <percy@tailscale.com>
9 months ago
kari-ts 89e160bd08
cmd/tailscale/main: remove debugging comment (#175)
Updates #cleanup

Signed-off-by: kari-ts <kari@tailscale.com>
9 months ago
kari-ts bf9be063d7
Makefile: update go version for bumposs (#165)
Updates tailscale/tailscale#10992

Signed-off-by: kari-ts <kari@tailscale.com>
9 months ago
kari-ts f6b0734e49
cmd/tailscale/main: use localapi for logging out (#164)
Updates tailscale/tailscale#10992

Signed-off-by: kari-ts <kari@tailscale.com>
9 months ago
Percy Wegmann cbe8858427 cmd/tailscale: pass nil TailFSForLocal to netstack.Create
In the latest tailscale.com, netstack.Create has a new parameter
that must be supplied.

Updates #cleanup

Signed-off-by: Percy Wegmann <percy@tailscale.com>
9 months ago
kari-ts 60b9884aa2
cmd/tailscale/main: clean up unused event (#170)
Updates #cleanup

Signed-off-by: kari-ts <kari@tailscale.com>
9 months ago
kari-ts 98fe1e86e5
cmd/tailscale/main: use localapi for login and add tests. (#157) 9 months ago
Moritz Poldrack e90f39a58c
cmd/tailscale/main: apply custom control server on first start (#156)
Currently the custom control server is not applied on start. To remedy
this, check the stored preference and if they differ issue an event to
switch to the correct backend server.

Updates: tailscale/tailscale#17470

Signed-off-by: Moritz Poldrack <git@moritz.sh>
9 months ago
kari-ts f9310e7a1f
cmd/tailscale/main: use localapi for generating bug report (#155)
Fix logIDPublic and make localapiclient a package with a generic function for calling localapi that can be reused for all features

Updates tailscale/tailscale#10992
9 months ago
Nicola Beghin df9c75136b
Fixes Android quick settings tile - issue #2646 (#143)
* quicksettings - move to use intents com.tailscale.ipn.CONNECT_VPN and com.tailscale.ipn.DICONNECT_VPN - Fixes #2646

Signed-off-by: Nicola Beghin <nicolabeghin@gmail.com>

* cleanup imports - Fixes #2646

Signed-off-by: Nicola Beghin <nicolabeghin@gmail.com>

---------

Signed-off-by: Nicola Beghin <nicolabeghin@gmail.com>
9 months ago
kari-ts 915e4e3394
Dockerfile: update to use go 1.22 (#163)
Updates #cleanup
9 months ago
kari-ts b96df2b830 android: bump version code
Signed-off-by: kari-ts <kari@tailscale.com>
9 months ago
kari-ts 630a6069c4 go.mod: bump oss and version
Updates #cleanup

Signed-off-by: kari-ts <kari@tailscale.com>
9 months ago
Charlotte Brandhorst-Satzkorn 9e8dfbb2ab
cmd/tailscale: do not show location based exit nodes in main view (#158)
This change stops us from clogging up the main UI view with location
based exit nodes, which can be in their hundreds. They will still appear
in the exit node UI.

Updates tailscale/tailscale#9438

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
9 months ago
Charlotte Brandhorst-Satzkorn 3615398012
cmd/tailscale: improve exit node menu for location based exit nodes (#159)
This change provides minor improvements to the exit node menu when there
are location based exit nodes present. It will ensure that non location
based exit nodes are displayed at the top of the list, followed by a
the best node for a country/city combination, and followed by all
location based exit nodes.

Updates tailscale/tailscale#9421

Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>
9 months ago
kari-ts 813ca8adea
android: bump version code (#152)
* go.mod: update for 1.58.0

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

* android: bump version code

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

* Pulled OSS from HEAD and fixed version name

* Removed swp file

---------

Signed-off-by: kari-ts <kari@tailscale.com>
10 months ago
David Anderson 3255d55e39 Record DNS search domains as well as nameservers.
We accidentally removed this in the big connectivity monitor fix.

Updates tailscale/tailscale#10107

Signed-off-by: David Anderson <danderson@tailscale.com>
10 months ago
David Anderson 4c7d66701f cmd/tailscale: remove obsolete DNS config logging
Causes a JNI crash because 1b42117791
removed those methods when fixing our connectivity monitoring.

Updates tailscale/tailscale#10107

Signed-off-by: David Anderson <dave@natulte.net>
10 months ago
kari-ts a76b36506c
DnsConfig: get rid of unnecessary isEmpty check (#149)
* DnsConfig: remove unnecessary isEmpty check

Updates #cleanup

* DnsConfig: remove unnecessary isEmpty check

Updates #cleanup

* k
10 months ago
kari-ts 1b42117791
use network callback to update DNS config when network changes (#147)
* use network callback to update DNS config when network changes

-Use requestNetwork, which gets the best network matching the passed in network request, to listen for changes to network and cache DNS config
-Call netmon.InjectEvent on network change to indicate a change
Follow-up will fix issue in netmon where IsMajorChangeFrom doesn't identify major changes when a network is added

Fixes #10107

* use network callback to update DNS config when network changes

-Use requestNetwork, which gets the best network matching the passed in network request, to listen for changes to network and cache DNS config
-Call netmon.InjectEvent on network change to indicate a change
Follow-up will fix issue in netmon where IsMajorChangeFrom doesn't identify major changes when a network is added

Updates tailscale/tailscale/#10107

hi

* hi

* .

* use network callback to update DNS config when network changes

-Use requestNetwork, which gets the best network matching the passed in network request, to listen for changes to network and cache DNS config
-Call netmon.InjectEvent on network change to indicate a change
Follow-up will fix issue in netmon where IsMajorChangeFrom doesn't identify major changes when a network is added

Updates tailscale/tailscale/#10107

* fixed missing connectivity manager
10 months ago
kari-ts 99c54591e6
.gitignore: ignore IDE (#148)
Updates #cleanup
10 months ago
Denton Gentry 52601c0dff android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
10 months ago
Denton Gentry dcca09fe7f Update OSS 1.57.x.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
10 months ago
kari-ts 7a52cae96f
.gitignore: ignore Java profiling files (#146)
Updates #cleanup
10 months ago
kari-ts ae647625b0
build.gradle: increase JVM memory settings (#145)
This fixes the issue where building in Android Studio OOMs

Fixes #10714

Signed-off-by: kari-ts <kari@tailscale.com>
11 months ago
Denton Gentry 61453254df android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
11 months ago
Denton Gentry 5ef7bbaff0 Update OSS 1.55.x
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
11 months ago
Denton Gentry a6ef5424a7 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
1 year ago
Denton Gentry 0a44d50e8b update dependencies
- Update Tailscale OSS to 1.55.x
- set compileSdkVersion to 33
- Update androidx dependencies
- increase memory allocated to JVM when building, avoid OOM

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
1 year ago
Denton Gentry 33a2eb0dee
version: handle whitespace in ${mod_version} (#141)
5ca195b109 changed the shell code
to determine the OSS module version:
-mod_version=${go_list##* }
+mod_version=${go_list#tailscale.com}

The intent is fine but the original code also consumed a whitespace
from the go.mod content:
        tailscale.com v1.52.0
original: "v1.52.0"
updated:  " v1.52.0"

At least for me, "make tag_release" no longer works:
    error: pathspec ' v1.54.0' did not match any file(s) known to git
Note the extra space at the start of the pathspec.

It is apparently not broken for everyone, possibly related to
versions of other tools I have installed like maybe git.

I don't want to just put the whitespace trimming back the way it
was, that seems fragile. Instead, quote ${mod_version} in a way
which won't be sensitive to extra whitespace.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
1 year ago
Denton Gentry 318065c64f android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
1 year ago
Denton Gentry ab4a672a4e go.mod: update from OSS.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
1 year ago
Denton Gentry 8f766ba087 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
1 year ago
Denton Gentry ea2cd2ec86 Bump OSS for new unstable release
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
1 year ago
Denton Gentry 38d38b3af9 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
1 year ago
Denton Gentry b23dd78e99 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
1 year ago
Denton Gentry 3a305b158c android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
1 year ago
Denton Gentry a965ae0038
cmd/tailscale: add tun Wrapper and call Start() (#140)
Fixes https://github.com/tailscale/corp/issues/15388

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
1 year ago
Andrew Lytvynov 6684b3059c
android: bump version code (#139)
Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
1 year ago
Andrew Lytvynov d5646fb2aa
Bump version and OSS for new unstable release (#138)
Updates https://github.com/tailscale/corp/issues/15340
1 year ago
James Tucker 727e7e2b50 cmd/tailscale: avoid creating multiple netmon instances
This is a quick fix, we should come back and try to reorganize this
later.

Updates tailscale/tailscale#9374
1 year ago
Aaron Klotz 6a142b2f50 cmd/tailscale: fix OSS build breaks
For build #cleanup

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
1 year ago
Aaron Klotz 338abf2cfb go.mod: update oss
For build #cleanup

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
1 year ago
Brad Fitzpatrick c21bbc94a1 cmd/tailscale: prefer showing Sharer user over Owner user
Fixes tailscale/corp#14056
Updates tailscale/tailscale#8967

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
1 year ago
James Tucker cb2e7da117 Makefile: avoid setting empty JAVA_HOME to avoid SDK tools bugs
The Android SDK tools go into a CPU spin if $JAVA_HOME is empty and
never complete. This may occur if you have only an incomplete Android
SDK, and no Android Studio install, wherein we do not find an Android
JAVA_HOME.

Updates self
1 year ago
Brad Fitzpatrick b500bbdad6 go.mod: bump oss
And resulting API changes.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
1 year ago
Maisem Ali 50b5b851eb
go.mod: bump oss (#130)
Updates #oss

Signed-off-by: Maisem Ali <maisem@tailscale.com>
1 year ago
Denton Gentry c73f8533f0
build.gradle: update targetSdkVersion to 33. (#127)
"bluetooth_name" cannot be accessed after SDK 31, remove it from
getUserConfiguredDeviceName().

Fixes https://github.com/tailscale/tailscale/issues/8955

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
1 year ago
Charlotte Brandhorst-Satzkorn aba683bb61
cmd/tailscale: rebind magicsock.Conn onConnect (#126)
We have been getting into routing loops due to the timing of when we
bind sockets on starting the tailscale app. At this point, we do
not have access to `VpnService.protect()` and are unable to protect
the magicsock sockets, which causes a routing loop issue until we
forcibly rebind about 10 minutes into the service being started.

This change causes a rebind when the service is started, which restores
connectivity in cases where the socket was unprotected.

Updates tailscale/corp#13814
1 year ago
Andrew Lytvynov 88d006f6b9
android: bump version code (#125)
Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
1 year ago
Andrew Lytvynov 4b67f47e88
android: bump version for unstable release (#124)
* go.mod: bump OSS

* android: bump version code

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>

---------

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
1 year ago
James Tucker 11a0d21d2e android: upgrade gradle plugin runtime to 8.1
Another upgrade recommended by Android Studio, again this is just
upgrading the build tooling, and not yet changing any of the runtime
side dependencies or behaviors.

We switch from jcenter to mavenCentral as advised in the build output,
as jcenter has shutdown.

This includes a mandatory JDK bump from 11 to 20.

Updates #cleanup
1 year ago
James Tucker 561ec860ed Makefile: fix JAVA_HOME detection on macOS
I had tested against an old Android Studio, newer ones should prefer the
`jbr` directory. The Makefile also required proper quotation around the
file paths that will typically contain a space.

Fixes tailscale/tailscale#8799
1 year ago
Denton Gentry 5610486051 go.mod: update from OSS.
Also increment the build number past those used for
1.46.x releases.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
1 year ago
Brett Jenkins 6348bb254a
Prevent connecting loops when using connect intent more than once (#95)
* startvpnworker.java: prevent connecting loops

If start intent called more than once.

Turns out there were still some cases where the bug would occur, also it turns out checking the status of a VPN connection isn't foolproof in android, so this is a safer way to fix it, we just ensure that the autoConnect var is set to false when disconnecting.

Fixes: https://github.com/tailscale/tailscale/issues/8013

Signed-off-by: Brett Jenkins <brett@brettjenkins.co.uk>
1 year ago
James Tucker 8e748afc47 android: update Android Gradle Plugin as recommended by Android Studio
Updates #cleanup
1 year ago
James Tucker a5edb67c9d .gitignore: ignore Android Studio local state files
Updates #cleanup
1 year ago
James Tucker 6a6e80db47
go.mod,cmd/tailscale: bump OSS and update logtail transport setup (#119)
Updates #cleanup
1 year ago
James Tucker 926613ddae
Makefile,Dockerfile,README.md: improve build dependency setup and documentation (#114)
- Teach Makefile to install an Android SDK and the components we need
- Make Dockerfile delegate to Makefile for Android SDK setup
- Detect Android Studio and other known SDK paths, and use them if found.
- Update documentation to more consistently point to Android Studio & make.
- Add a task that checks for the SDK components and produces a useful error.
- Build an APK by default.
- Allow TOOLCHAINDIR to be passed in, and strip the first go/ component
  so that it is the path a user would expect.

Updates #cleanup
1 year ago
James Tucker 5ca195b109
version: fix result from tailscale-version.sh when not using a git source (#117)
If the developer has used `go mod vendor` or `go work use ../tailscale`
then the version script was returning "tailscale.com" instead of hitting
the error case.

This now makes the outcome with the Makefile slightly worse, which needs
to be fixed up next.

Updates #cleanup
1 year ago
Denton Gentry 31f2aa8097 cmd/tailscale/main.go: use ipn.NewPrefs(), not &ipn.Prefs{}
ipn.NewPrefs() sets accepting DNS and routes to true,
among other things. &ipn.Prefs{} initializes all fields
to false.

Fixes https://github.com/tailscale/corp/issues/13377

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
1 year ago
kari-ts 264aae3232
android: update gradle (#112)
-Update Gradle version
-Replace jcenter() with mavenCentral() because of jcenter deprecation
-Use testImplementation instead of the obsolete testCompile

Tested with docker
Fixes #12997

Signed-off-by: kari-ts <kari@tailscale.com>
1 year ago
Andrea Gottardo bb47fa593c
android: increment versionCode to 170 (#113) 1 year ago
kari-ts 04b79a2206
cmd/tailscale: if first notification, set hostname even if prefs is nil (#111)
Currently, if the notification (ipn.Notify) has a nil Prefs, the hostname defaults to localhost

Fixes #7875

Signed-off-by: kari-ts <kari@tailscale.com>
1 year ago
Brad Fitzpatrick f44692addd
cmd/tailscale: don't render ShareeNode peers (#98)
Fixes tailscale/corp#11006

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
1 year ago
Will Norris dc9c96278d Dockerfile: update build tools in dockerfile
Use a slightly more current version of Java (jdk-11), as well as more
recent versions of Android build tools and Go.

Fixes tailscale/tailscale#8404

Signed-off-by: Will Norris <will@tailscale.com>
1 year ago
Charlotte Brandhorst-Satzkorn 39717f946b
Revert "Add DNS/routing prefs like on desktop (#86)" (#108)
This reverts commit d316acaa3d.
1 year ago
dependabot[bot] 4789733d46
build(deps): bump github.com/gin-gonic/gin from 1.9.0 to 1.9.1 (#105)
Bumps [github.com/gin-gonic/gin](https://github.com/gin-gonic/gin) from 1.9.0 to 1.9.1.
- [Release notes](https://github.com/gin-gonic/gin/releases)
- [Changelog](https://github.com/gin-gonic/gin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gin-gonic/gin/compare/v1.9.0...v1.9.1)

---
updated-dependencies:
- dependency-name: github.com/gin-gonic/gin
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1 year ago
Denton Gentry 1e07536824 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
1 year ago
Denton Gentry 0ccb93e115 go.mod: update for 1.43.x development.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
1 year ago
Will Norris 566a890843 gitignore: ignore build artifact and signing key
Signed-off-by: Will Norris <will@tailscale.com>
1 year ago
Will Norris b68b3f2aeb Makefile: fix versionCode replacement and cleanup
I'm not sure why [[:digit:]] worked before but not now (BSD vs GNU sed
maybe?), but since we already have the old VERSIONCODE, just replace it
directly. Also cleanup the android/build.gradle.bak file.

Signed-off-by: Will Norris <will@tailscale.com>
1 year ago
Denton Gentry 8d6922285d android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 38061656a5 go.mod: update from OSS.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
dependabot[bot] f4489f4e0c
build(deps): bump github.com/gin-gonic/gin from 1.8.1 to 1.9.0 (#99)
Bumps [github.com/gin-gonic/gin](https://github.com/gin-gonic/gin) from 1.8.1 to 1.9.0.
- [Release notes](https://github.com/gin-gonic/gin/releases)
- [Changelog](https://github.com/gin-gonic/gin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gin-gonic/gin/compare/v1.8.1...v1.9.0)

---
updated-dependencies:
- dependency-name: github.com/gin-gonic/gin
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2 years ago
Brad Fitzpatrick 6b9a11c755 cmd/tailscale: fix regression from earlier commit
In 00a42702cb I bumped go.mod and adjusted the API, but only tested
that it compiled & tests (hah) passed.

I forgot this line. I still haven't tested this, but can't be worse! :)

Fixes 00a42702cb (commitcomment-112642284)

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2 years ago
M. J. Fromberger 049ee22764
.github: mark bots for exemption by issuebot (#101) 2 years ago
Brad Fitzpatrick 00a42702cb go.mod: bump tailscale.com dep, update for tsd.System API change
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2 years ago
Brad Fitzpatrick a7b3ae04b0 cmd/tailscale: use Google as DNS of last resort
Sometimes we try a dozen different ways to read the phone's DNS
settings and it still comes back empty. In that case, if we're already
on a Google-ified Android phone, just use Google's Public DNS servers
as the ultimate fallback, as we already do on ChromeOS to work around
ChromeOS Android VpnBuilder bugs.

Updates tailscale/tailscale#8006 etc etc

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2 years ago
RoboMagus 13ecd3e34d
Exit node in notification (#96)
* Show exitnode in persistent notification when connected.
* updateNotification when exitnode changes

Fixes https://github.com/tailscale/tailscale/issues/4642

Signed-off-by: RoboMagus <68224306+RoboMagus@users.noreply.github.com>
2 years ago
Denton Gentry 0931e9b3ee android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry aa32919ac3 go.mod: update from OSS
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 2118ca5b38 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 1a4a088466 go.mod: update from OSS
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Brett Jenkins eb9599540c
Add intents (#87)
IPNReceiver: Add intents to connect and disconnect VPN

Added a new class IPNReceiver to listen to intents silently and connect and disconnect the VPN. This uses workers to avoid doing too much in the IPNReceiver which is to be avoided according to documentation.

Also includes a fix for vpn occasionally not starting. Think this was due to a race condition, but now only sets autoConnect to false when we know a connection is connecting or connected.

Fixes https://github.com/tailscale/tailscale/issues/3547
Updates https://github.com/tailscale/tailscale/issues/2481

Signed-off-by: Brett Jenkins <brett@brettjenkins.co.uk>
2 years ago
Mihai Parparita 24ba39121f README.md: update macOS instructions
The JRE path has changed in more recent versions of Android Studio.
Also make it more explicit how to get the `adb` tool in the search path.
2 years ago
Denton Gentry c077c1b38a android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry a5346dcc26 go.mod: update from OSS.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Mihai Parparita e7efa7d1c2 Makefile: add tailscale_go to the set of build tags
We build with the tailscale Go toolchain, so we should pass in the
build tag that indicates that it's safe to use functionality contained
within it.

Updates tailscale/corp#9230
2 years ago
Mihai Parparita 8e4a740d8e cmd/tailscale: enabled logtail flushing for the Android client
The c2n endpoint was not working since we did not configure the flush
function (same issue that was previously fixed in the iOS client in
tailscale/corp#9939).

Updates tailscale/corp#8564
2 years ago
Mihai Parparita 68ecc44a49 cmd/tailscale: upload client metrics
More generally, make the logtail.Config similar to the one used by
clients for other platforms.

Updates tailscale/corp#9230
2 years ago
Jordan Whited 44d083aaf1
go.mod: Update from OSS and bump wireguard-go (#90)
Signed-off-by: Jordan Whited <jordan@tailscale.com>
2 years ago
Gero Gerke d316acaa3d
Add DNS/routing prefs like on desktop (#86)
ui: Add DNS/routing prefs like on desktop

Fixes https://github.com/tailscale/tailscale/issues/2155

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Jordan Whited 2470284b31
go.mod: Update from OSS and bump wireguard-go. (#88)
Signed-off-by: Jordan Whited <jordan@tailscale.com>
2 years ago
Denton Gentry 71f203a493 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry df47a60927 go.mod: Update from OSS.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 8f512dd7a9 go.mod: beginning of 1.39 development.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry d0c45c1de1 Revert "IPNReceiver: Add intents to connect and disconnect VPN (#84)"
Reverting according to discussion in
https://github.com/tailscale/tailscale/issues/3547#issuecomment-1465035410

This reverts commit 51a53e5472.
2 years ago
Denton Gentry 6499fb845e android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 814cd3c43a go.mod: update from OSS.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 6bbc9032bf android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry d53da4ac65 go.mod: update OSS
Skip over the build number used for 1.36.2.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Brett Jenkins 51a53e5472
IPNReceiver: Add intents to connect and disconnect VPN (#84)
* IPNReceiver: Add intents to connect and disconnect VPN

Added a new class IPNReceiver to listen to intents silently and connect and disconnect the VPN
Also removed unneeded comment

Fixes: https://github.com/tailscale/tailscale/issues/3547

Signed-off-by: Brett Jenkins <brett@brettjenkins.co.uk>
2 years ago
dependabot[bot] 7245e72dcf build(deps): bump golang.org/x/image
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.0.0-20220722155232-062f8c9fd539 to 0.5.0.
- [Release notes](https://github.com/golang/image/releases)
- [Commits](https://github.com/golang/image/commits/v0.5.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2 years ago
Denton Gentry e7ceb58224 IPNService: add Chromecast to the apps allowed to bypass the VPN.
Needed for LAN discovery of Chromecast devices.
Fixes https://github.com/tailscale/tailscale/issues/3636

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 2073704cad android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 813b770cdf go.mod: update Gio, finish Go 1.20 support
Upstream Gio fixed https://todo.sr.ht/~eliasnaur/gio/473, which
we run into in https://github.com/tailscale/tailscale/issues/7255

https://github.com/tailscale/tailscale has moved to Go 1.20
- update go.mod to go 1.20
- go mod tidy -compat=1.20
- update go4.org/unsafe/assume-no-moving-gc

Skip over the build numbers used for 1.36.x

Fixes https://github.com/tailscale/tailscale/issues/7255

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
David Anderson 52a0509a5d go.mod: update to tailscale HEAD
And update build logic to account for the API change in
tailscale.com/version.

And apply the API change introduced by
tailscale/tailscale@04b57a371e

Signed-off-by: David Anderson <dave@natulte.net>
2 years ago
Spencer Comfort 2a5ced8159
Dockerfile: go 1.19.5 (#77)
Updates go from 1.17.5 to 1.19.5 in the dockerfile
2 years ago
Denton Gentry 68b6b92eaf go.mod: update for 1.37.x
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry f139c0221e android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry f643488f7a go.mod: update OSS and Gio UI.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry a23dbaf58e android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 9562c27766 android/build.gradle: skip versions from branches.
We did several builds from release branches.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 6f1567bac8 go.mod: update from OSS.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 908c634a6a
IPNService: add Sonos S1 to the apps allowed to bypass the VPN. (#76)
Fixes https://github.com/tailscale/tailscale/issues/2548

Signed-off-by: Denton Gentry <dgentry@tailscale.com>

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 152110204c android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 365b0ce6b0
ui: Fix background color of exit node status. (#75)
Prior to https://github.com/tailscale/tailscale-android/pull/73/,
the exit node status pane was set to a background color using:
    paint.Fill(gtx.Ops, bg)

paint.Fill() is documented to fill the entire clipped area.
It assumes that one has already applied a clip area... but
no clip area had been set in this code path.

As far as I can tell, that this worked prior to pull #73
was a bug, something had a side-effect of setting a clipping
rectangle.

We updated to the head of the Gio repo, which apparently fixed
that bug. After pull #73, the paint.Fill() painted the entire
app window.

Fix this using a stacked layout. A color filled widget is the
lower layer, and will expand to the size of the widget sitting
atop of it.

Fixes https://github.com/tailscale/tailscale/issues/6873

Signed-off-by: Denton Gentry <dgentry@tailscale.com>

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry a0f2c883b4 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 30e46fb854
go.mod: update from OSS. (#74)
Signed-off-by: Denton Gentry <dgentry@tailscale.com>

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 62cc5fe074
cmd/tailscale: update to latest gioui.org. (#73)
There were several API changes since we last updated. We
followed examples from the upstream Gio repository:

1. Replace unit.Px with unit.Dp.
   Followed example in:
   3d37491342

2. op.Offset now takes int coordinates instead of float.
   Followed example in:
   a63e0cb44a

3. clip.RRect now takes int coordinates instead of float.
   Followed example in:
   48a8540a68

4. Replace system.CommandEvent with key.Event.
   Followed example in:
   6c76fa6dec

Updates https://github.com/tailscale/tailscale/issues/6824

Signed-off-by: Denton Gentry <dgentry@tailscale.com>

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 6f6d319048 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry fd874ed58e go.mod: update OSS.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
James Tucker 73e3a13322
cmd/tailscale: implement new batch APIs (#71)
Signed-off-by: Jordan Whited <jordan@tailscale.com>
2 years ago
Denton Gentry 0244fd108d
cmd/tailscale: fix alternate ControlURL handling. (#72)
With the Fast User Switching support in Tailscale 1.34,
it is no longer necessary (nor sufficient) to exit
and restart the app, as the settings are saved upon
first connection.

Therefore:
- do not restart the app after changing the control URL,
  just go back to the authentication screen.
- call `ipn/ipnlocal/local.go:Start()` to reinitialize
  the backend using the new auth URL.

Fixes https://github.com/tailscale/tailscale/issues/6671

Signed-off-by: Denton Gentry <dgentry@tailscale.com>

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 7f11150cb6 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry c5e20b297c go.mod: update from OSS for 1.35.x
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 3c081e5d10 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 80b896e71c go.mod: update from OSS.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 498e73e392 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 1181155b7d go.mod: update from OSS.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 9ce897ed8f
IPNService: add Sonos to the apps allowed to bypass the VPN. (#69)
Updates https://github.com/tailscale/tailscale/issues/2548

Signed-off-by: Denton Gentry <dgentry@tailscale.com>

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry bb147bf07c android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 26e72f15ef go.mod: update from OSS.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry ac5e24a63d
build.gradle: update to SDK31 (#68)
Required for apps to update in the Play Store after November 1.

This requires:
- manifest must specify if Intents are exported.
- PendingIntent must declare FLAG_IMMUTABLE or MUTABLE

Signed-off-by: Denton Gentry <dgentry@tailscale.com>

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry b6c2536147 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 80dfbd8a0c go.mod: bump OSS.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry d46d247535
cmd/tailscale: set logcfg.FlushDelay (#67)
Signed-off-by: Denton Gentry <dgentry@tailscale.com>

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry f7c662ca4a android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 185cc3dd8f skip mistaken build number.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 98b7c9d7e5 backend: logcfg.DrainLogs is now the default
We don't need to implement a 2 minute timer.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 1bf8c0b270 go.mod: update from OSS.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry d050f2742a go.mod: Update for 1.33
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry d93c6aa7f3 go.sum: add crypto entry
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry ce550a4225 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 0d859fb46c go.mod: update from OSS
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry f27f4567f0 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 5ed3921ad6 go.mod: update from OSS
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 10419aea2d android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 03970952d5 go.mod: update from OSS.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Andrew Dunham 576b46f8f7
cmd/tailscale: show an error when we hit the multiple users Android bug (#65)
Updates tailscale/tailscale#2180

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>

Signed-off-by: Andrew Dunham <andrew@du.nham.ca>
2 years ago
Will Norris 901c43c3b9 android: add about dialog with license link
Add "about" menu item in both the logged out and logged in menus.  About
dialog shows Tailscale version and link to URL with open source
licenses.

Updates tailscale/corp#5780

Signed-off-by: Will Norris <will@tailscale.com>
2 years ago
Denton Gentry 4869bb4666 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 033f7d87b4 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 5d209e6122 go.mod: update from OSS
Also increment past the build number used for 1.30.1.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry bab2ac0058 go.mod: update for 1.31 dev builds.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry c0f9eed38e android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry d0812b9476 go.mod: bumposs
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 634431055b android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry f9a23978a3 go.mod: update from OSS.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 4102b6a40a android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 4738308088 go.mod: make bumposs
Also ran go mod tidy.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Will Norris 9dee09156d .github/actions: signoff go-licenses commits
Also ignore tailscale.com package (and add directly to template) and
remove branch-suffix. This aligns android with other go-license
workflows.

Signed-off-by: Will Norris <will@tailscale.com>
2 years ago
Will Norris 249cab2bc6 .github/workflows: add go-licenses GitHub Action 2 years ago
Denton Gentry bbf31f568f
UI: remove "No internet connection" (#57)
This has never worked well. I think we'd need to periodically re-check
link state, and possibly hook up a generate_204 function, to make this
really work.

Fixes https://github.com/tailscale/tailscale/issues/5422

Signed-off-by: Denton Gentry <dgentry@tailscale.com>

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 548fbe21ea
Merge pull request #59 from tailscale/gin-gonic
go.mod: update gin-gonic.
2 years ago
Denton Gentry 05cba1ed18 go.mod: update gin-gonic.
Dependabot flagged a problem.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 67f94b771b
Merge pull request #58 from tailscale/ci
Add continuous integration builder
2 years ago
Denton Gentry e91d0e89d3 Add continuous integration builder
Building tailscale-debug.apk also runs unit tests.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry e49241f40b go.mod: update from OSS.
Also update to Go 1.19 in go.mod.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry f2e89244a9 build.gradle: increment build number.
Built on another branch.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry aafa3432dc android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 9e6ef85d26 go.mod: update from OSS.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Brad Fitzpatrick b1501eb5d8 Hide login screen advance settings behind few taps
By default, only show the version number in the login screen's menu.
But if you open and close it a few times, then show the alternate
control plane server option. It's always shown if you've ever edited
the value.

And rename it to just "Change server" and remove "Advanced".

Updates tailscale/tailscale-android#45
2 years ago
Trevor Bergeron d8aedf721a Add ability to choose a custom coordination server
Signed-off-by: Trevor Bergeron <mal@sec.gd>
2 years ago
Denton Gentry 4f17005954 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Brad Fitzpatrick 140149ef87 fix "invalid IP" regression from netaddr to netip migration
Fixes tailscale/tailscale#5243
2 years ago
Brad Fitzpatrick f77610bd62 reformat, rename googleDNSServers variable
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2 years ago
Denton Gentry f225992d28 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 2b9b952d27 go.mod: update OSS one more time before building unstable.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry bc9311c1e9
Merge pull request #52 from tailscale/oss
go.mod: update from OSS.
2 years ago
Denton Gentry dfd3c3d543
Merge pull request #53 from tailscale/gopro
Add GoPro to the apps allowed to skip the VPN.
2 years ago
Denton Gentry d1d72859b4 Add GoPro to the apps allowed to skip the VPN.
Fixes https://github.com/tailscale/tailscale/issues/2554

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 1a0253892b go.mod: update from OSS.
Includes netaddr -> netip changes.

main.go getInterfaces still uses netaddr as it needs to return
a net.IPNet (we'd need to add a new implementation in
7c671b0220/net/interfaces/interfaces.go (L110)
to handle a different type).

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 6eca3897f8 build.gradle: spaces -> tabs
Only changes whitespace.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 07ae8a6dd5
Merge pull request #51 from tailscale/dns-addr
DnsConfig: don't use signed bytes when printing.
2 years ago
Denton Gentry 283dd77bcc Add a unit test for DnsConfig.intToInetString
adds JUnit dependencies and basic gradle support to run unit tests,
and a test for DnsConfig.intToInetString().

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 63dba694af DnsConfig: don't use signed bytes when printing.
intToInetString(0x0101a8c0) returns "-64.-88.1.1" because Java
integers are always signed. There is not a %u format specifier.

Though the quads of an IP address literally are bytes, they can
be left as an int to pass to String.format. This allows room for
sign bits, so intToInetString(0x0101a8c0) returns "192.168.1.1"

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry db31496a24 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 42f688f129
Merge pull request #50 from tailscale/apps
Add inherently local apps to disallowed list.
2 years ago
Denton Gentry 4223a68a2d go.mod: update from OSS for 1.29 unstable builds
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 8f551d0320 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 21ea21f4f0 go.mod: update from OSS.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 2597b82c3f Add inherently local apps to disallowed list.
Google Stadia, Messages (RCS/Jibe), and Android Auto
don't benefit from being on the VPN, and don't work.
Either they need access to local hardware (Auto, Stadia)
or they're accessing an entirely different communications
channel (Messages). Don't send them through the VPN.

Fixes https://github.com/tailscale/tailscale/issues/2322
Fixes https://github.com/tailscale/tailscale/issues/3460
Fixes https://github.com/tailscale/tailscale/issues/3828

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 1bdfaf88d9
Merge pull request #49 from tailscale/exit-node
Move run exit node to main menu
2 years ago
Denton Gentry b606b0b668 UI: move exit node function into the main menu
Android running as an exit node has been implemented behind
a magic "debug" search string for three releases. I think we've
gotten as much early usage as we're going to get. Move it into
the main menu as a regular feature.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 11a027269c go.mod: update from OSS.
Increment build number past the point used on other branches.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 8ddaf1ec5c android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 4fa037b636 go.mod: update from OSS.
Especially want to get https://github.com/tailscale/tailscale/pull/4965
into an Open Testing release.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 221300266f android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 0c11377ca1 go.mod: update OSS for unstable build.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
2 years ago
Denton Gentry 9de8d8e525 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 1845f17317 go.mod: update OSS to main@HEAD.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 7697c9d300 go.mod: update OSS to main@HEAD.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 536c17a3a2 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry e66e57fbb0 go.mod: update OSS.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry e73f55f5db go.mod: Start of 1.25.x development.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 40481f5ec6 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry bd5ef3fd68 go.mod: update OSS
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Elias Naur 090676bb74 go.*: bump Gio version
Fixes tailscale/tailscale#4278

Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Brad Fitzpatrick 751bda721c README.md: add some notes for developing on macOS 3 years ago
Brad Fitzpatrick cff9e2a772 cmd/tailscale: fix netstack init, call SetLocalBackend
The netstack code on Android was never told about the LocalBackend,
so the peerapi interception wasn't working.

Fixes tailscale/tailscale#4449
Fixes tailscale/tailscale#4293

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
3 years ago
Brad Fitzpatrick 8550365e52 README.md: add some Fire Stick dev docs
I always forget these and need to go search old bugs for them.
3 years ago
Denton Gentry d1bff07fbd android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry b8af14c009 go.mod: update OSS
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry e652d853d6 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 3f8df48d23 go.mod: update OSS.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 58e85726a4 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 4ccafba8f7 go.mod: update from OSS.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 51fc2e7030 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry cc70ae7aa6 Update OSS.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Elias Naur 0ec9167cd2 flake.*: add Nix support for establishing a development environment
Nix is a package system similar to Go modules for creating predictable
builds and environments. Nix builds are reproducible and a ligthweight
alternative to Docker.

This change makes the repository a Nix flake that includes a development
environment. Use it with Nix 2.4 and later with flakes enabled:

$ alias nix='nix --extra-experimental-features "nix-command flakes"'
$ nix develop

Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Elias Naur b803576542 android,Dockerfile: bump NDK, Gradle
Nix support is easier to do with recent NDK and Gradle dependencies.
Bump them here, and add Nix support in a follow-up.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Denton Gentry d8ccc2387f android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry b4f8e7f90a Go 1.18 and update OSS.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Ross Zurowski c7afe66e9a android: update Android TV banner dimensions 3 years ago
Ross Zurowski 052ba2755f android: update Android TV launcher icon 3 years ago
Brad Fitzpatrick 9101d9adc4 android: try to add a Android TV Leanback launcher icon
Maybe it works on Android TV, but this doesn't work on a Fire Stick.

Updates tailscale/tailscale#4179

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
3 years ago
Elias Naur 83bfea18bb cmd/tailscale,com/tailscale/ipn: implement QR sign-in for TV devices
This is a cleand up version of #27.

Fixes tailscale/tailscale#1611

Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Elias Naur e316f3b1c2 cmd/tailscale: improve focus navigation
This change prevents focusing hidden widgets or widgets under modal dialogs.

For tailscale/tailscale#1611

Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Elias Naur 7c268dfc4f Revert "Revert "cmd/tailscale,go.*: update Gio version""
This reverts commit 213009e9af, and
updates Gio to a version that includes directional focus support.

For tailscale/tailscale#1611

Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Aman Karmani d9c64011f5 android: make apk android tv compatible 3 years ago
Denton Gentry b0f1428443
Merge pull request #41 from tailscale/digits
java: format strings containing integers in ROOT locale.
3 years ago
Denton Gentry fd42b4b352 java: format strings containing integers in ROOT locale.
We use strings to pass structured data from the JVM to Go.
In a locale using Indian-Arabic numerals: ۰ ۱ ۲ ۳ ۴ ۵ ۶ ۷ ۸ ۹
the Java code will format decimal strings using Indian-Arabic
glyphs.

Go doesn't get a locale set automatically by the Android
runtime, so it always parses strings in a default en-US
`unable to parse "lo ١ ٦٥٥٣٦ true false true false false |": expected integer`

Make the Java code format using the ROOT locale. These strings
are purely internal to pass between the two runtimes, they are
not shown to the user.

Fixes https://github.com/tailscale/tailscale/issues/4156

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry b125fbf179 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 7203980ecc Disable vulkan.
Trying to resolve crashes on certain OpenGL hardware.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 8c94f7975c go.mod: increment from OSS@main
Also go mod tidy.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry a8f017ddf6 go.mod: update from OSS@main
Also increment Android build number past 1.22.0.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 0e5c2ec1ec android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 4a1c0cb2ee go.mod: update to tailscale.com@main.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Brad Fitzpatrick 20ea9fd17d go.mod, go.sum: run go mod tidy -compat=1.17
Drops unused netstack and old versions of tailscale.com.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
3 years ago
Denton Gentry 69f2fe67dc android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 5868fdb7b0 go.mod: update OSS from HEAD.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 47f646d0ae go.mod: update dependencies. 3 years ago
Denton Gentry 7989a1ae2a android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 467ddfc605 go.mod; update OSS from HEAD.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 61490b1713 go.mod: go mod tidy -compat=1.17
Also increment build number past the one used for 1.20.2
on release-branch/1.20.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry aa5123b8b4 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 213009e9af Revert "cmd/tailscale,go.*: update Gio version"
Updates https://github.com/tailscale/tailscale/issues/3754

This reverts commit 36b09f6b06.
3 years ago
Denton Gentry 17e6d5f653 Update for unstable release.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry df73d8a419 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 6b83c6ae21 go.mod: update OSS for next unstable build.
Also increment the build number past what was used
on the release-branch/1.20

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 3709101d12 Merge branch 'main' of https://github.com/tailscale/tailscale-android into main 3 years ago
Brad Fitzpatrick e3f7123238 Add "Allow LAN access" checkbox in Exit Node menu
Updates tailscale/tailscale#2155
3 years ago
Denton Gentry 270b8efe97 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 039124db79 go.mod: Update from OSS.
Increment version number, the 1.18.2 release was build 82.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Brad Fitzpatrick 1be9000a6a Makefile: add 'bumposs' target, run go mod tidy
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
3 years ago
Brad Fitzpatrick 9d801a42d7 Add secret Run Exit Node option when "debug" is searched for.
And bump tailscale dep, to bring in new ipn.Prefs API and wire up
ExitDNS for Android.

This change has no visible behavior change to anybody unless they
search for "debug" and then hit the "..." menu.

Updates tailscale/tailscale#1738

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
3 years ago
Brad Fitzpatrick ed29f4b3d2 cmd/tailscale: add "Bug report" item to menu
Signed-off-by: Brad Fitzpatrick <brad@danga.com>
3 years ago
Brad Fitzpatrick 3b6b9acf2b Makefile: clear GOROOT, add rundebug target
Nowadays basically nobody should set GOROOT. I still do for various
reasons, but that broke this build which then ran our custom toolchain
with a mismatched GOROOT. Clear GOROOT so the default GOROOT is used
(which is the path that matches the Go toolchain in use)

Also, add a "rundebug" target to control adb (phone or emulator)
3 years ago
Brad Fitzpatrick df09d74486 Makefile: make toolchain bootstrap work on darwin and arm64
It only worked on linux/amd64 before, but we support
{linux,darwin}/{amd64,arm64} for development elsewhere.

Fixes tailscale/tailscale#3669

Signed-off-by: Brad Fitzpatrick <brad@danga.com>
3 years ago
Brad Fitzpatrick 19822d0b94 go.mod: use Go toolchain rev from oss
And don't sha1-sum the Go toolchain cache.
Just see if its go binary works.

Updates tailscale/corp#3385
Updates tailscale/tailscale#3596
3 years ago
Denton Gentry 33329425b8
Merge pull request #28 from tailscale/tool_checksum
Makefile: make toolchain sum not use absolute path.
3 years ago
Denton Gentry 2d49c9ae30 Makefile: make toolchain sum not use absolute path.
This is intended as a quick fix of the immediate problem:
don't compute the checksum using absolute paths (i.e.
/home/dgentry), use relative paths.

In the future we may rationalize the versioning of the
toolchain to tie the Android toolchain to the one used
for building Linux/macOS/Windows/etc.

Fixes https://github.com/tailscale/tailscale/issues/3596

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Brad Fitzpatrick 050f4a41d4 Makefile: add fdroid-like APK target 3 years ago
Brad Fitzpatrick af2c71c6e2 Dockerfile: remove old, unnnecessary, conflicting fetching of Go
The Makefile's toolchain target fetches the right Go version
nowadays. We can make the Dockerfile do that and cache it later, but
for now this at least prevents the Go version mismatch bugs.
3 years ago
Brad Fitzpatrick f8176b47a9 Makefile: remove long-obsolete -tags=xversion
That build tag hasn't been used since tailscale/tailscale's 5088af68cf
(June 2nd, 2021, for 1.10.0)
3 years ago
Denton Gentry bbd7b61d44 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry a3d2dc95db go.mod update OSS.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 7cb3c9a427
Merge pull request #26 from tailscale/linkproperties
ui: more robust isConnected check.
3 years ago
Denton Gentry 79bb2f33d0 ui: more robust isConnected check.
1. Follow
   https://developer.android.com/training/monitoring-device-state/connectivity-status-type
   to determine whether to report ourself as having connectivity or not.

   Tested by turning the Wifi & LTE off and on, seems to work well in
   the contrived test case.

2. Call superclass for onLost() and onLinkPropertiesChanged() handlers.
   Current Android versions have no code in the superclass of these two
   callbacks, but future proofiness.

3. Log when the UI report of LostInternet changes, so we can find it.

Fixes https://github.com/tailscale/tailscale/issues/3542

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 0265dcfd1b android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry b2665ab2ff Update tailscale OSS.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 665488ff4a
Merge pull request #23 from tailscale/dns
Android: retrieve current DNS servers.
3 years ago
Denton Gentry 184250167b
Merge branch 'main' into dns 3 years ago
Denton Gentry f2a104fc5f android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry ca696b116c Update opensource repository 3 years ago
Denton Gentry 5c9cec0064 retrieve current DNS servers.
Add getDnsConfigAsString() to retrieve the current DNS
configuration from the Android platform. This implements
several mechanisms to retrieve DNS information, suitable
for different Android versions:

Android 7 and later use ConnectivityManager getAllNetworks(),
then iterate over each network to retrieve DNS servers and
search domains using the LinkProperties.

Android 6 and earlier can only retrieve the currently active
interface using ConnectivityManager getActiveNetwork(), but have
two additional fallback options which leverage the system
properties available in older Android releases.

--------

Also changed how LinkChange notification works, switching from
the older BroadcastReceiver of a ConnectivityManager Intent to
the newer ConnectivityManager.registerNetworkCallback. We need
this because the onAvailable event is too early, we get notified
that LTE is up before its DNS servers have been set. We need
to wait for the onLinkPropertiesChanged event instead, which is
only available with registerNetworkCallback.

Fixes https://github.com/tailscale/tailscale/issues/2116
Updates https://github.com/tailscale/tailscale/issues/988

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 0c00fa3374
Merge pull request #25 from tailscale/a11y
cmd/tailscale,go.*: update Gio version
3 years ago
Elias Naur 36b09f6b06 cmd/tailscale,go.*: update Gio version
Provides support for Android TalkBack

Fixes tailscale/tailscale#1004

Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Denton Gentry b6fbbbbd39
Merge pull request #24 from tailscale/android-logs
backend: use logpolicy.NewLogtailTransport
3 years ago
Denton Gentry 729bf9a356 backend: use logpolicy.NewLogtailTransport
Allows use of bootstrap DNS and of a built-in ISRG X1 root
certificate.

Fixes https://github.com/tailscale/tailscale/issues/3046
3 years ago
Denton Gentry 5c93a7a829 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry da175ba221 go.mod: update OSS
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry a045ba5ab1 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry db53a314eb go.mod: update OSS
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry c98d4dd89c android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 0ecb2a2587 Increment build number.
1.16.2 on release-branch/1.16 is build 74.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 780e7515da go.mod: update to tailscale.com@main.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry fe76bef85b android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 61f90a1975 Increment build number.
Build 72 was 1.16.1, built on release-branch/1.16

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry f9bbd73413 go.mod: update OSS from main for an unstable build.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry a4a3ae6eff android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 82ea8df1dc go.mod: update from HEAD.
Preparing for a 1.17.x unstable build.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Elias Naur a3152ae505 go.*,cmd/tailscale: upgrade Gio
Add proper margins to toast messages while here.

Fixes tailscale/tailscale#3059

Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Denton Gentry 0a13b89ce0 go.mod: update from HEAD.
Also increment build number to skip over the 1.16.0 release build.
3 years ago
Denton Gentry f0dcec6c27
Merge pull request #21 from tailscale/getinterfaces
cmd/tailscale: implement getInterfaces + SDK 30
3 years ago
Denton Gentry 02a6ae0e0d cmd/tailscale: implement getInterfaces + SDK 30
SDK 30 prohibits syscall.NetlinkRIB(syscall.RTM_GETADDR, ...)
which Go's net.Interfaces uses. Implement an Android
specific version of net.Interfaces to use instead.

Passing primitive types across JNI is relatively straightforward,
passing a single object of a complex class is annoying but still
possible, but passing lists and other more complex data structures is
way harder. As such, this commit added a Java routine to render the
interface information to a string and pass that across JNI as a
primitive type for Go code to parse.

Fixes https://github.com/tailscale/tailscale/issues/2293
3 years ago
Denton Gentry 41aa0c1d02 go.mod: update to current HEAD.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Brad Fitzpatrick d0b4a09e59 fix name of NewUserspaceEngine in error/comment
It was renamed some time ago.
3 years ago
Brad Fitzpatrick a5bed46c9c cmd/tailscale: use hostinfo setters for OSVersion, DeviceModel
Stop abusing Prefs, which bit us in the iOS client. We're going to
remove the ipn.Prefs mechanism.

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
3 years ago
Denton Gentry 6518535039 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 9b52c6b357 go.mod: update to current main.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 54d511a9b6 Increment build number.
build 68 is 1.14.6 on release-branch/1.14.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry b8c3f9da9a
Merge pull request #20 from tailscale/chromeos
cmd/tailscale: report ChromeOS.
3 years ago
Denton Gentry 59aecdb2e5 cmd/tailscale: report ChromeOS.
Fixes https://github.com/tailscale/tailscale/issues/2971

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry ef96ee30fd android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 0637d599af go.mod: Update to tailscale HEAD.
Notably, pull in https://github.com/tailscale/tailscale/pull/2951

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry e2128ef6d6 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 75ef65dd50 go.mod: Update to tailscale HEAD.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Elias Naur 84b484a954 android,cmd/tailscale: implement taildrop receive for Android < 10
Fixes tailscale/tailscale#2720
Fixes tailscale/tailscale#2296

Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Elias Naur f37cf72d81 android/ipn: ignore shared files we have no access to
I'm not able to reproduce the crash described in #2720; sharing files
from an SD-card through taildrop works for me (ChromeOS 93.0) without
issues. However, this change makes sure that we don't crash should we
lack permission for some reason.

Updates tailscale/tailscale#2720

Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Elias Naur db77216ead go.*: bump Gio version
Fixes graphics issues on various devices (Nexus 7, LG K20, Samsung J2).

Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Denton Gentry c02078e41e Makefile: update toolchain version to use.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Elias Naur 078356613f android: fix persistent notification intent target
Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Elias Naur 54eab1a5d3 cmd/tailscale: delete unused FileTargetsEvent; gofmt
Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Denton Gentry d3edb004e3 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 7aa8ae9a47 go.mod: update for 1.15.x unstable build.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry f33b98b313 Skip version used for 1.14.0 take 2.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 742da3ae36 Skip version used for 1.14.0.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry eb24dedd81 go.mod: move to unstable version 1.15.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 5c524d2768 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry ae2df12032 Update from Tailscale OSS.
Skip release 61.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Elias Naur 6635d89292 go.*: bump Gio version; go mod tidy
Fixes two minor OpenGL ES 2.0 issues.

Updates tailscale/tailscale#1008

Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Denton Gentry 9283506c04 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 30324736c9 Update OSS from HEAD.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry d81f8a03b6 Switch to Go 1.17.
Matches https://github.com/tailscale/tailscale at HEAD.
3 years ago
Denton Gentry 76df742662 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry a68462ec65 Update for unstable build.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 3575ef712a android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 82b6b8dbd3 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 0f46117f9c Update to latest OSS
Preparing new Open Testing (unstable) release.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry d98827df03
Merge pull request #17 from tailscale/issue-1008
Add fallback CPU renderer, sRGB emulation for Android Go
3 years ago
Denton Gentry f25f7ecc80 tailscale-version.sh: adjust version format.
Use the same version number format as other platforms.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 67c2869ec6 tailscale-version.sh: always use bash
We specify `set -o pipefail`, which is not implemented in dash
or a number of other shells. The F-Droid builder is defaulting
to a shell which does not:
```
./version/tailscale-version.sh: 10: set: Illegal option -o pipefail
```

Updates https://github.com/tailscale/tailscale/issues/2536

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Elias Naur ebcc878fcb go.*,cmd/tailscale: upgrade Gio
The upgraded version adds a CPU fallback renderer and sRGB emulation,
to support very low-spec Android Go devices.

Fixes tailscale/tailscale#1008

Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Denton Gentry 7f086ccaa6 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry f59e53e41d Update to Gio @main
For low end phone support:
https://lists.sr.ht/~eliasnaur/gio/%3CCD3XWVXUTCG0.23LAQED4PF674%40themachine%3E

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry ade708af7f Update for 1.13.x unstable builds. 3 years ago
Denton Gentry 9f701283bc
Merge pull request #16 from tailscale/toolchain
Makefile: download Tailscale's Go toolchain
3 years ago
Denton Gentry 078eee6b39 Dockerfile: use Go 1.16.5
Going to use the Tailscale fork of the Go toolchain instead.
3 years ago
Denton Gentry bb0494637f Makefile: download Tailscale's Go toolchain
Tailscale maintains a patched Go toolchain, pulling in
fixes early. Download the toolchain and use it to build.

Fixes https://github.com/tailscale/tailscale/issues/2450
in a better way.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry ea1bb12e9b android: bump version code
Fix F-Droid build.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 7b2e61e80c Remove check for Tailscale toolchain.
Broke the F-Droid build.
Update Dockerfile to use Go 1.17rc1 to verify that as a fix.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 00e45e3795 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 7ebedfd62a Require Tailscale go toolchain in $PATH.
Setting GOBIN was not sufficient, something deeper in
the build process is invoking the go tool without $GOBIN.
Instead, require it be first in $PATH.

This is necessary to fix
https://github.com/tailscale/tailscale/issues/2450
and (hopefully)
https://github.com/tailscale/tailscale/issues/2478
which are a SECCOMP crash in accept() which we have
patched in the Tailscale toolchain by pulling in
an early patch from go 1.17.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 0df2377630 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 7a00ad639b Update to latest OSS & use prod toolchain.
Unstable release 1.11.150.

Enforce use of Tailscale production toolchain, mainly because
it pulls in a fix for https://github.com/tailscale/tailscale/issues/2450

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 3cb36c4599 android: bump version code
Unstable build 1.11.109.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 4d32c6da4f android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 2c1f35d560
Merge pull request #15 from tailscale/unstable
Update for 1.11 unstable release
3 years ago
Denton Gentry 1c78887bf5 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 85ed50317d Update for 1.11 unstable release.
Use OSS version in tailscale-version.sh

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 90909797a0 android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 74a18b3359 go.mod: update to 1.10.2
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 2a6cd09d7c android: bump version code
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry adfcedb097 Update to 1.10.1 release.
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Elias Naur 14cdec17f1 android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Elias Naur 242c936b2c android: lower target SDK to 29
Target 30 results in an error on load, most likely because netlink
is no longer accessible for apps.

runBackend: NewUserspaceEngineAdvanced: route ip+net: netlinkrib: permission denied

Fixes tailscale/tailscale#2290

Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Elias Naur 639aebac6a android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Elias Naur 10ded1bad2 cmd/tailscale,java: implement file sharing
Fixes tailscale/tailscale#1809

Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Elias Naur 331bc1e30a jni: introduce Get*ArrayElements
Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Elias Naur e007a9c153 android: bump target/compile SDK version to 30
Gio uses SDK 30 API now.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Denton Gentry 7dedc91a80 ui: show IPv6 address if there's no IPv4 address
There are several situations where a peer may only have IPv6:
+ it was authorized using an ephemeral node key
  https://tailscale.com/kb/1111/ephemeral-nodes/
+ the Tailnet has IPv4 disabled
  https://twitter.com/bradfitz/status/1398380415124082690

So:
+ if a peer has an IPv4 address, display that.
+ if a peer does not have an IPv4 address, display IPv6.
+ If a peer does not have either IPv4 or IPv6 then leave
  the address blank. Not sure how this could happen.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Denton Gentry 594ed6b7bc android: bump version code
On 6/24 I built a v43 and started to release it to production,
then decided to do an Internal release first. The Play Store
would not allow v43 to be used again so I built v44, released
it to Internal, and after testing promoted it to Production and
it went into Google's review process.

Crucially, I did not push the tags before going to bed.

On 6/25, not knowing any of this because I hadn't pushed the tags,
Elias built v43 and released it. The Play Store had apparently cleaned
up the state from my abandoned v43 by that point.

This commit increments the build number to v44 so we don't re-use it.
I'm not building an actual release, just incrementing the build
number and pointing to the same git commit. I'm hoping F-Droid will
therefore build exactly the same thing as it did for v43.

I promise to push the tags before going to bed next time.

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
3 years ago
Elias Naur e8f2409cb3 android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Elias Naur 47b732aaab Makefile,version: update versioning scheme to match main repository
Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Elias Naur 131bf27995 cmd/tailscale: use go:embed directives for image files
Fixes tailscale/tailscale#2243

Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Elias Naur b57f06455d go.*: bump Gio version
Enables us to replace GioActivity with our own.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Elias Naur d67229dc38 jni: merge jni.c into jni.go
As a bonus, the C functions can be static.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
3 years ago
Brad Fitzpatrick 2f7b27412a gitignore: also ignore tailscale-debug.apk 3 years ago
Brad Fitzpatrick b97cc703d8 Fix routing loop prevention, MagicDNS forwarding over Tailscale.
Fixes tailscale/tailscale#2102
Updates tailscale/tailscale#1809

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
3 years ago
Brad Fitzpatrick ac8ec020b8 Update to Tailscale 1.10.
Updates tailscale/tailscale#2102
Updates tailscale/tailscale#1809
3 years ago
Brad Fitzpatrick d66204ecfd Dockerfile: bump Go to 1.16, set ANDROID_SDK_ROOT for dockershell 3 years ago
Elias Naur 9e9d69fd95 android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Denton Gentry 0489079771
Merge pull request #10 from tailscale/b1956
backend: Send DNS config through CallbackRouter.
4 years ago
Denton Gentry 90351e7392 backend: Send DNS config through CallbackRouter.
Using NewNoopManager avoided the errors from trying to overwrite
/etc/resolv.conf, but still didn't fully work. Route DNS config
through the CallbackRouter.

Fixes https://github.com/tailscale/tailscale/issues/1956

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
4 years ago
Elias Naur 20ddae3208 android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur ff16a75a65 go.*: bump tailscale.com to 1.8.6
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 9ba4a01a4e cmd/tailscale: remove tstun.Wrapper
NewUserspaceEngine wraps our TUN device already.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Denton Gentry d90aa446c0
Merge pull request #9 from tailscale/b1956
backend: use dns.NewNoopManager.
4 years ago
Denton Gentry ec8133a972 backend: use dns.NewNoopManager.
Android updates its DNS config in updateTUN() when in response
to several different channels from the backend.

There is not an Android-specific NewOSConfigurator, we end
up pulling in the Linux NewOSConfigurator:
https://github.com/tailscale/tailscale/blob/main/net/dns/manager_linux.go

The Linux DNS manager expects to be able to write to /etc/resolv.conf,
which does not work on Android and causes errors in updating DNS config.

Instead, allocate dns.NewNoopManager to disable the DNS manager, and
rely on the updateTUN() code to handle DNS.

Fixes https://github.com/tailscale/tailscale/issues/1956

Signed-off-by: Denton Gentry <dgentry@tailscale.com>
4 years ago
Elias Naur 3d2abf0b3b android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 8ea1d4ced7 android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 06e461d703 go.*,cmd/tailscale: upgrade to tailscale.com v1.8.3
Updates tailscale/tailscale#1695

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 401ed389ef android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 05212e770b cmd/tailscale: don't configure logtail for low memory
logtail in low memory configuration truncates log lines to ~254 bytes.

Fixes tailscale/tailscale#1625

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 6d9d6786af Makefile: make sed invocations portable to macOS
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur db13aa4e92 android/build.gradle: upgrade androidx.security to 1.1.0-alpha03
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 633d81287a cmd/tailscale,com/tailscale/ipn: delete unused constant, reformat
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 206f2bb4e7 android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur e2d731dbba ensure exit node status is updated when changing it
Fixes tailscale/tailscale#1545

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 085d823920 android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 05ddfd5d90 go.*: upgrade to tailscale v1.6.0
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 3b1a5e7a71 cmd/tailscale: implement Tailscale 1.6 default route setting
Fixes tailscale/tailscale#1401

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 71e0f2bd94 cmd/tailscale,go.*: bump gio version
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 56362cc61a cmd/tailscale,go.*: upgrade to latest tailscale
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur b151de039b cmd/tailscale: simplify non-blocking coalescing channels
Three variables are used everywhere a single non-blocking producer sends
values where only the latest is relevant:

    var (
	    mu sync.Mutex
	    latest T
	    notify = make(chan struct{}, 1)
    )

By draining the notification channel before sending through it, we
can simplify to just one channel:

    latest = make(chan T, 1)

Thanks to Chris Waldon for showing me this neat trick.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur dfe7b6c0a2 android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur d3e0b42093 go.*: upgrade to tailscale v1.4.5
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur ebdbe7c315 android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 3e758d0fe2 go.*: bump tailscale.com to v1.4.4
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 54917ae2f5 android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 33cf7c0aa1 jni: replace True and False with more convenient Bool function
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 07b2373e6b com/tailscale/ipn,cmd/tailscale: handle quick tile clicks while signed out
Specifically, start the main activity to prompt the user to sign in or
be notified of a pending machine auth.

Fixes tailscale/tailscale#1225

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 71a9bd537c com/tailscale/ipn,AndroidManifest.xml: make quick tile service passive
Active mode is more efficient, but otherwise equivalent to passive mode.
However, some Android versions don't implement active mode reliably. See
also

https://stackoverflow.com/questions/58035971/tileservice-requestlisteningstate-not-working-on-android-q-couldnt-find-tile-f
https://issuetracker.google.com/issues?q=requestListeningState

I can reproduce issue 1225 on an Android 10 emulator, but no longer with
this change applied.

For tailscale/tailscale#1225

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur f19c0c057e android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Sonia Appasamy c4f626c5a7 cmd/tailscale: use node.DisplayName for machine names
Update to tailscale 1.4.0 while here.

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
4 years ago
Elias Naur ba38a9bb59 jni,cmd/tailscale: replace jni.EnvFor with explicit conversion
The EnvFor converted an uintptr to a pointer value, which is not
guaranteed to work in general. This change removes EnvFor and pushes the
potentially unsafe conversion to users of the jni package.

Fixes tailscale/tailscale#1195

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 61d9733b24 jni,cmd/tailscale: replace jni.JVMFor with direct cast
The JVMFor function converted an uintptr to a pointer, which is not
guaranteed to work in general. This change removes JVMFor, forcing the
unsafe conversion to the user of the jni packge.

Updates tailscale/tailscale#1195

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur d3dc208237 jni: move package documentation to the package declaration
Updates tailscale/tailscale#1195

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 9525b1c46c android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 96e2661764 version: determine tailscale.com version by cloning it and running version.sh
Updates tailscale/tailscale#1158

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 1c93c0f2c7 cmd/tailscale: don't surface IPv6 addresses in UI
Fixes tailscale/tailscale#1158

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
David Anderson b1395cfefb cmd/tailscale: update to network engine that supports IPv6.
Part of tailscale/tailscale#1158.

Signed-off-by: David Anderson <danderson@tailscale.com>
4 years ago
Elias Naur c8114b4474 android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur c26c3b0a35 metadata/en-US/images/phoneScreenshots: add screenshots for F-Droid
Fixes tailscale/tailscale#1086

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 28e5c33b3b android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur b981aa576c cmd/tailscale,go.*: update Gio version
Fixes tailscale/tailscale#471

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 2ed6c7df9a android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
David Anderson 8daee9c431 com/tailscale/ipn: exclude the app from its own VPN.
This means that the Tailscale app's traffic will never use
the VPN that it sets up, which avoids routing loops in
scenarios like publishing a default route over Tailscale.

Signed-off-by: David Anderson <danderson@tailscale.com>
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
David Anderson e87e87367a README.md: document how to get the app working in android emulator.
Signed-off-by: David Anderson <dave@natulte.net>
4 years ago
Elias Naur 2c9fddab4f cmd/tailscale: warn when debug signed and Google Sign-In fails
Fixes tailscale/tailscale#1036

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 1c61cc0702 jni: tolerate nil byte arrays in GetByteArrayElements
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur de6c243bae android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 9db851a113 cmd/tailscale: notify user when VPN access is denied
When tailscale starts, any other active VPN service is automatically closed by the system.
However, if the other VPN service is configured to be always-on, we will be denied access
to set up a VPN. The user may not realize this case, so this change adds a notification
when we're denied access.

The failure mode is identical to the user denying access tthrough the system dialog
shown first time Tailscale starts, so the notification also mentions that case.

Fixes tailscale/tailscale#1017

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 0363b565ee
Merge pull request #4 from Poussinou/patch-1
Update README.md
4 years ago
Poussinou 9c39b7fced
Update README.md 4 years ago
Elias Naur b97970dd8f android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 33a953fb21 cmd/tailscale: only refresh VPN tunnel if configuration changed
This used to work, but a later ChromeOS workaround closed and cleared the last
configuration before comparing it with the new.

Fixes tailscale/tailscale#966

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 129abdb13f cmd/tailscale: close Tailscale when user cancels system VPN dialog
Fixes tailscale/tailscale#904

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 21037e6d67 android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur f2c035a8bf cmd/tailscale: note in Hostinfo.OSVersion if Google Play is unavailable
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur cedc696c87 go.*,cmd/tailscale: upgrade to latest gio version
Includes the GOARM=7 fix to avoid softfloat on 32-bit android/arm.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur d95d693a73 Dockerfile: bump Go version to 1.15.5
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 0964bc5a6e android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 97a826d118 Makefile: extract version information from build.gradle
The tag_release target already writes the version into build.gradle.
Ensure the exact same version information is used for the Go build.

As a bonus, the F-Droid builder no longer needs a modern git installed
for determining the version string.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 6252433c48 metadata: add short and full descriptions for F-Droid
The F-Droid app store reads metadata from the source repository.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 7f6ccc9f88 Makefile: add tag_release target for bumping version and tagging
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 99d00e803c Makefile: separate target for generating ipn.aar
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 9c38bebfa9 android: create build flavor for omitting non-free Google dependency
The F-Droid app store don't support non-free dependencies. Create two build
flavors, "fdroid" for building without Google Sign-In, and "play" for including
it.

Modify Makefile to target the play flavor.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 6d9acbb479 cmd/tailscale,java: refactor Google Sign-In into separate class
In preparation for the F-Droid release, refactor the non-free Google dependency
into a separate Java class and make the Go client tolerate missing support.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 412fe8ad68 android/gradle/wrapper: add cryptographic checksum
Suggested by F-Droid bot, https://gitlab.com/fdroid/rfp/-/issues/1546#note_443476386.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 39dfd84951 android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 2b38d32130 go.*: upgrade to Tailscale 1.2.2
Also revert the "0.0.0" hack that made mkversion.sh complain.

Fixes tailscale/tailscale#883

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 3eab35ca80 android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 5a581c1a9d version,Makefile: implement new Tailscale versioning scheme
The strategy is to ask `go list` for the tailscale.com module version, and then
use that as a tag in a `git ls-remote` query.

If the module version is a pseudo-version such as
"v1.1.1-0.20201030135043-eab6e9ea4e45", use the abbreviated commit directly
(git ls-remote only list remote refs, not commits).

Fixes tailscale/tailscale#883

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 6aaaa84dcf go.*: bump Gio version to fix Fairphone 2 UI glitches
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur cddf7217f3 go.*: bump tailscale version
Include a fix for an Android crash on startup:

eab6e9ea4e

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 25168130a7 cmd/tailscale,go.*: update to tailscale 1.2
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur fd4646a900 android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur ab29b995b5 go.*: bump gogio version
Exposes the app to OpenGL ES 2.0 devices such as the Fairphone 2.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 6eeb9d8ac4 AndroidManifest.xml: disable auto-backup of (encrypted) app data
Android 6.0 and later automatically backs up app data and allow the user
to restore it when setting up a new device. Unfortunately, the app data
is encrypted with a device specific master key, rendering the data
unreadable on the second device.

Apply the allowBackup=false hammer since we only store device-specific
(logs) and sensitive (private keys, authentication tokens) data for now.

Fixes tailscale/tailscale#732

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 233515e86a cmd/tailscale: move the App.appDir field to the only method that uses it
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 7de19cd9b8 cmd/tailscale: remove unused field stateStore.dataDir
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur dfbfd2a3ed android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 97b727d8a8 README.md: document Google Sign-In requirements
Fixes tailscale/tailscale#608

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 679f97afb3 cmd/tailscale: don't show toggle when not authenticated
Also, change the login screen tile to say "Tailscale", not the
redundant "Needs authentication".

Updates tailscale/tailscale#608

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur c1863a42ae android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur d221e0db42 java/com/tailscale/ipn: run attachPeer on main thread
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur b6d6f57261 android: bump version code 4 years ago
Elias Naur 1b402aebb0 cmd/tailscale,java/com/tailscale/ipn: always register the Peer Fragment
Before this change, the Peer would be registered across Activity restarts
but not after Activity destruction (for example, when the user pressed the
back button).

Use the newer Gio ViewEvent API for tracking the Activity lifecycle and
the most recent Activity reference.

Move Java calls that need an Activity from Peer to App, leaving Peer solely
as a method for receiving onActivityResult.

Fixes tailscale/tailscale#670

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 3089ad8347 cmd/tailsca,java/comt/tailscale/ipn: don't require an Activity for Google sign-out
The GoogleSignIn.getClient has a version that only needs a Context, not an Activity.

Updates tailscale/tailscale#670

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur a0a33e92c4 android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 39cb01da42 java/com/tailscale/ipn: upgrade Android security to support Android 5.1
As luck would have it, there's a new version of the androidx.security
library available that support Android 5+. Use that, and adjust to the
incompatible API changes.

Fixes tailscale/tailscale#577

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 3ced33d812 java/com/tailscale/ipn: make App Android 5.1 compatible
Fragment.commitNow doesn't exist on Android 5.1. Calling commit and then all
flushing pending transactions is just as good, because we're not using any
other fragments.

Updates tailscale/tailscale#577

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur f25b5bbcba cmd/tailscale: make intro screen scrollable
Include the version code bump as well. Oops.

Updates tailscale/tailscale#488

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 1003774193 cmd/tailscale,go.*: fix network hangs on Huawei devices
Bump the tailscale.com module version to get the Android fallback
for determining the default network device,

25b021388b

Updates tailscale/tailscale#471

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 758e5691da cmd/tailscale: mask route addresses to please VpnService.Builder.addRoute
Update inet.af/netaddr for IPPrefix.Masked.

Fixes tailscale/tailscale#645

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 454c59a4e5 cmd/tailscale: add detail to VpnService.Builder cosntruction errors
Updates tailscale/tailscale#645

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 943bded910 cmd/tailscale: don't duplicate log output
logtail.Log by default writes log output to stderr, but stderr is taken over by
filch's ReplaceStderr, resulting in duplicate logs sent to Tailscale.
ReplaceStderr is useful for capturing stack dumps from panics.

Configure logtail to route logs to the Android logger, which stops the
duplicate logging and replaces an existing MultiWriter setup for the same
purpose.

Reduce the scope of the logtail logger while here.

Fixes tailscale/tailscale#646

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 334dff897c cmd/tailscale: pause app on sign-in screen when internet is gone
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 05fc3ef433 cmd/tailscale: close overflow menu when the Android back button is pressed
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur a2b15127dd go.*: bump gogio tool version
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur c706699862 android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur fe465654a2 jni: fold gojni.h into jni.go
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur a7dfea267c cmd/tailscale: fallback back to Google DNS on ChromeOS
Contrary to the VpnService.Builder documentation, ChromeOS doesn't
automatically fall back to the underlying network nameservers when
none are provided.

Updates tailscale/tailscale#431

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 7211e6db1b cmd/tailscale: tee log output to both Tailscale and the Android log
Updates tailscale/tailscale#471

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 9e45538997 cmd/tailscale,java/com/tailscale/ipn: provide OSVersion and DeviceModel for the backend
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 93afdf1e5d cmd/tailscale: bump tailscale version
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 91d4d47fd8 cmd/tailscale: stop loader indicator when Google Sign-in is cancelled
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 15632cb15b cmd/tailscale: sign-out any Google users when logging out from Tailscale
Fixes tailscale/tailscale#585

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 053820acda cmd/tailscale: ensure the web sign-in button always chooses the browser
It was set to reauthenticate with the last used sign-in method by mistake.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 5a018c7209 cmd/tailscale: reset loader indicator after Google Sign-in
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 8353a32ed9 go.*: bump gio to avoid a deadlock at startup
Updates tailscale/tailscale#471

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 1a937b5c5f java/com/tailscale/ipn: retain peer Fragment across Activity restarts
There is no reason to recreate it for transient restarts.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 1775eaf309 go.*: fix ChromeOS flickering byu upgrading Gio
Updates tailscale/tailscale#431

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 6265d84c36 cmd/tailscale: don't use TileService if not supported
Bump version code for release.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur cbde34f13b android: bump version code
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 19ed532519 cmd/tailscale: don't set up VPN for invalid configurations
Fixes tailscale/tailscale#507

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur 2119f8aa9d cmd/tailscale: avoid backend deadlocks from SetPrefs and LinkChange
Updates tailscale/tailscale#471 (perhaps fixes it)

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur bae9b8394a android: add quick setting tile support
Fixes tailscale/tailscale#516

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur df1d8b338b cmd/tailscale: implement Google ID sign-in
Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago
Elias Naur ad92c8d81f cmd/tailscale: remove redundant locking
There are no more concurrent accesses to the prefs variable.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
4 years ago

@ -0,0 +1,20 @@
{{/*
This template is used to generate the license notices published at
https://github.com/tailscale/tailscale/blob/main/licenses/android.md.
Publishing is managed by the go-licenses GitHub Action. Non-Go dependencies
should be manually updated at the bottom of this file as needed.
*/}}# Tailscale for Android dependencies
The following open source dependencies are used to build the [Tailscale Android
Client][]. See also the dependencies in the [Tailscale CLI][].
[Tailscale Android Client]: https://github.com/tailscale/tailscale-android
## Go Packages
{{ range . }}
- [{{.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))

@ -0,0 +1,37 @@
name: Android CI
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"
# Clean should essentially be a no-op, but make sure that it works.
- name: Clean
run: make clean
- name: Build APKs
run: make tailscale-debug.apk
- name: Run tests
run: make test

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

@ -0,0 +1,19 @@
on:
push:
branches:
- "main"
- "release-branch/*"
pull_request:
# all PRs on all branches
merge_group:
branches:
- "main"
jobs:
license_headers:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v4
- name: check license headers
run: ./scripts/check_license_headers.sh .

34
.gitignore vendored

@ -6,3 +6,37 @@ build
# The destination for the Go Android archive.
android/libs
android_legacy/libs
# Ignore ABI
android/src/main/jniLibs/*
# Android Studio files
android_legacy/.idea
android_legacy/local.properties
android/.idea
android/local.properties
.idea
# Output files from the Makefile:
*.apk
*.aab
# Signing key
tailscale.jks
# android sdk dir
./android-sdk
# Java profiling output
*.hprof
#IDE
.vscode
.idea
libtailscale.aar
libtailscale-sources.jar
.DS_Store
tailscale.version

@ -1,54 +0,0 @@
# This is a Dockerfile for creating a build environment for
# tailscale-android.
FROM openjdk:8-jdk
# To enable running android tools such as aapt
RUN apt-get update && apt-get -y upgrade
RUN apt-get install -y lib32z1 lib32stdc++6
# For Go:
RUN apt-get -y --no-install-recommends install curl gcc
RUN apt-get -y --no-install-recommends install ca-certificates libc6-dev git
RUN apt-get -y install make
RUN mkdir -p BUILD
ENV HOME /build
# Get android sdk, ndk, and rest of the stuff needed to build the android app.
WORKDIR $HOME
RUN mkdir android-sdk
ENV ANDROID_HOME $HOME/android-sdk
WORKDIR $ANDROID_HOME
RUN curl -O https://dl.google.com/android/repository/sdk-tools-linux-3859397.zip
RUN echo '444e22ce8ca0f67353bda4b85175ed3731cae3ffa695ca18119cbacef1c1bea0 sdk-tools-linux-3859397.zip' | sha256sum -c
RUN unzip sdk-tools-linux-3859397.zip
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager --update
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'platforms;android-29'
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'extras;android;m2repository'
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'ndk;20.0.5594570'
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'platform-tools'
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'build-tools;28.0.3'
# Get Go stable release
WORKDIR $HOME
ARG GO_VERSION=1.14.3
RUN curl -O https://storage.googleapis.com/golang/go${GO_VERSION}.linux-amd64.tar.gz
RUN echo "1c39eac4ae95781b066c144c58e45d6859652247f7515f0d2cba7be7d57d2226 go${GO_VERSION}.linux-amd64.tar.gz" | sha256sum -c
RUN tar -xzf go${GO_VERSION}.linux-amd64.tar.gz && mv go goroot
ENV GOROOT $HOME/goroot
ENV PATH $PATH:$GOROOT/bin:$HOME/bin:$ANDROID_HOME/platform-tools
RUN mkdir -p $HOME/tailscale-android
WORKDIR $HOME/tailscale-android
ADD go.mod .
ADD go.sum .
RUN go mod download
# Preload Gradle
COPY android/gradlew android/gradlew
COPY android/gradle android/gradle
RUN ./android/gradlew
CMD /bin/bash

@ -2,41 +2,315 @@
# 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/libs/ipn.aar
KEYSTORE=tailscale.jks
KEYSTORE_ALIAS=tailscale
GIT_DESCRIBE=$(shell git describe --tags --long)
VERSION_LONG=$(shell ./mkversion.sh long $(GIT_DESCRIBE))
VERSION_SHORT=$(shell ./mkversion.sh short $(GIT_DESCRIBE))
RELEASE_TV_AAB=tailscale-tv-release.aab
LIBTAILSCALE=android/libs/libtailscale.aar
# Extract the version code from build.gradle.
ifeq ($(shell uname),Linux)
ANDROID_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip"
ANDROID_TOOLS_SUM="bd1aa17c7ef10066949c88dc6c9c8d536be27f992a1f3b5a584f9bd2ba5646a0 commandlinetools-linux-9477386_latest.zip"
else
ANDROID_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-mac-9477386_latest.zip"
ANDROID_TOOLS_SUM="2072ffce4f54cdc0e6d2074d2f381e7e579b7d63e915c220b96a7db95b2900ee commandlinetools-mac-9477386_latest.zip"
endif
ANDROID_SDK_PACKAGES='platforms;android-31' 'extras;android;m2repository' 'ndk;23.1.7779620' 'platform-tools' 'build-tools;33.0.2'
# Attempt to find an ANDROID_SDK_ROOT / ANDROID_HOME based either from
# preexisting environment or common locations.
export ANDROID_SDK_ROOT ?= $(shell find $$ANDROID_SDK_ROOT $$ANDROID_HOME $$HOME/Library/Android/sdk $$HOME/Android/Sdk $$HOME/AppData/Local/Android/Sdk /usr/lib/android-sdk -maxdepth 1 -type d 2>/dev/null | head -n 1)
# If ANDROID_SDK_ROOT is still unset, set it to a default location by platform.
ifeq ($(ANDROID_SDK_ROOT),)
ifeq ($(shell uname),Linux)
export ANDROID_SDK_ROOT=$(HOME)/Android/Sdk
else ifeq ($(shell uname),Darwin)
export ANDROID_SDK_ROOT=$(HOME)/Library/Android/sdk
else ifneq ($(WINDIR),))
export ANDROID_SDK_ROOT=$(HOME)/AppData/Local/Android/sdk
else
export ANDROID_SDK_ROOT=$(PWD)/android-sdk
endif
endif
export ANDROID_HOME ?= $(ANDROID_SDK_ROOT)
# Attempt to find Android Studio for Linux configuration, which does not have a
# predetermined location.
ANDROID_STUDIO_ROOT ?= $(shell find ~/android-studio /usr/local/android-studio /opt/android-studio /Applications/Android\ Studio.app $(PROGRAMFILES)/Android/Android\ Studio -type d -maxdepth 1 2>/dev/null | head -n 1)
# Set JAVA_HOME to the Android Studio bundled JDK.
export JAVA_HOME ?= $(shell find "$(ANDROID_STUDIO_ROOT)/jbr" "$(ANDROID_STUDIO_ROOT)/jre" "$(ANDROID_STUDIO_ROOT)/Contents/jbr/Contents/Home" "$(ANDROID_STUDIO_ROOT)/Contents/jre/Contents/Home" -maxdepth 1 -type d 2>/dev/null | head -n 1)
# 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 ?=
export TOOLCHAINDIR
GOBIN ?= $(PWD)/android/build/go/bin
export GOBIN
export PATH := $(PWD)/tool:$(GOBIN):$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$(PATH)
export GOROOT := # Unset
#
# Android Builds:
#
all: $(APK)
.PHONY: apk
apk: $(DEBUG_APK) ## Build the debug APK
aar:
.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: 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: 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) tailscale.version
$(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 $@
tailscale.version: go.mod go.sum $(wildcard .git/HEAD)
$(shell ./tool/go run tailscale.com/cmd/mkversion > tailscale.version)
.PHONY: version
version: tailscale.version ## print the current version information
cat tailscale.version
#
# Go Builds:
#
android/libs:
mkdir -p android/libs
go run gioui.org/cmd/gogio -ldflags "-X tailscale.com/version.LONG=$(VERSION_LONG) -X tailscale.com/version.SHORT=$(VERSION_SHORT)" -tags xversion -buildmode archive -target android -appid $(APPID) -o $(AAR) github.com/tailscale/tailscale-android/cmd/tailscale
$(DEBUG_APK): aar
(cd android && VERSION=$(VERSION_LONG) ./gradlew assembleDebug)
mv android/build/outputs/apk/debug/android-debug.apk $@
$(RELEASE_AAB): aar
(cd android && VERSION=$(VERSION_LONG) ./gradlew bundleRelease)
mv ./android/build/outputs/bundle/release/android-release.aab $@
$(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 tailscale.version
$(GOBIN)/gomobile bind -target android -androidapi 26 \
-tags "$$(./build-tags.sh)" \
-ldflags "-w $$(./version-ldflags.sh)" \
-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)
@echo ANDROID_HOME=$(ANDROID_HOME)
@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: tailscale.version ## Tag the current commit with the current version
source tailscale.version && git tag -a "$${VERSION_LONG}" -m "OSS and Version updated to $${VERSION_LONG}"
.PHONY: bumposs ## Bump to the latest oss and update the versions.
bumposs: update-oss tailscale.version
source tailscale.version && git commit -sm "android: bump OSS" -m "OSS and Version updated to $${VERSION_LONG}" go.toolchain.rev android/build.gradle go.mod go.sum
source tailscale.version && git tag -a "$${VERSION_LONG}" -m "OSS and Version updated to $${VERSION_LONG}"
.PHONY: bump_version_code
bump_version_code: ## Bump the version code in build.gradle
sed -i'.bak' "s/versionCode .*/versionCode $$(expr $$(awk '/versionCode ([0-9]+)/{print $$2}' android/build.gradle) + 1)/" android/build.gradle && rm android/build.gradle.bak
.PHONY: update-oss
update-oss: ## Update the tailscale.com go module
GOPROXY=direct ./tool/go get tailscale.com@main
./tool/go mod tidy -compat=1.23
./tool/go run tailscale.com/cmd/printdep --go > go.toolchain.rev.new
mv go.toolchain.rev.new go.toolchain.rev
# Get the commandline tools package, this provides (among other things) the sdkmanager binary.
$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager:
mkdir -p $(ANDROID_HOME)/tmp
mkdir -p $(ANDROID_HOME)/cmdline-tools
(cd $(ANDROID_HOME)/tmp && \
curl --silent -O -L $(ANDROID_TOOLS_URL) && \
echo $(ANDROID_TOOLS_SUM) | sha256sum -c && \
unzip $(shell basename $(ANDROID_TOOLS_URL)))
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
$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager $(ANDROID_SDK_PACKAGES)
# 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\
\tANDROID_HOME=$(ANDROID_HOME)\n\
\tANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT)\n\n\
See README.md for instructions on how to install the prerequisites.\n"; exit 1)
.PHONY: test
test: gradle-dependencies ## Run the Android tests
(cd android && ./gradlew test)
.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.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)
release: $(RELEASE_AAB)
jarsigner -sigalg SHA256withRSA -digestalg SHA-256 -keystore $(KEYSTORE) $(RELEASE_AAB) $(KEYSTORE_ALIAS)
.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
install: $(DEBUG_APK)
adb install -r $(DEBUG_APK)
.PHONY: docker-remove-shell-image
docker-remove-shell-image: ## Removes all docker shell image
docker rmi --force tailscale-android-shell-amd64
dockershell:
docker build -t tailscale-android .
docker run -v $(CURDIR):/build/tailscale-android -it --rm tailscale-android
.PHONY: clean
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
-rm tailscale.version
clean:
rm -rf android/build $(RELEASE_AAB) $(DEBUG_APK) $(AAR)
.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 aar release dockershell
.DEFAULT_GOAL := help

@ -10,32 +10,109 @@ This repository contains the open source Tailscale Android client.
## Using
Available on [Play Store](https://play.google.com/store/apps/details?id=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)
## Building
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.
[Go](https://golang.org), the [Android
SDK](https://developer.android.com/studio/releases/platform-tools),
the [Android NDK](https://developer.android.com/ndk) are required.
#### 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
There are several options for setting up a build environment. The Android Studio
path is the most useful path for longer term development.
In all cases you will need:
- Go runtime
- Android SDK
- Android SDK components (`make androidsdk` will install them)
### Android Studio
1. Install a Go runtime (https://go.dev/dl/).
2. Install Android Studio (https://developer.android.com/studio).
3. Start Android Studio, from the Welcome screen select "More Actions" and "SDK Manager".
4. In the SDK manager, select the "SDK Tools" tab and install the "Android SDK Command-line Tools (latest)".
3. Run `make androidsdk` to install the necessary SDK components.
If you would prefer to avoid Android Studio, you can also install an Android
SDK. The makefile detects common paths, so `sudo apt install android-sdk` is
sufficient on Debian / Ubuntu systems. To use an Android SDK installed in a
non-standard location, set the `ANDROID_SDK_ROOT` environment variable to the
path to the SDK.
If you installed Android Studio the tools may not be in your path. To get the
correct tool path, run `make androidpath` and export the provided path in your
shell.
#### Code Formatting
The ktmft plugin on the default setting should be used to autoformat all Java, Kotlin
and XML files in Android Studio. Enable "Format on Save".
### Docker
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 docker-shell
```
$ make tailscale-debug.apk
$ adb install -r tailscale-debug.apk
```
The `dockershell` target builds a container with the necessary
dependencies and runs a shell inside it.
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
be set up with:
```sh
alias nix='nix --extra-experimental-features "nix-command flakes"'
nix develop
```
$ make dockershell
# make tailscale-debug.apk
## Building
```sh
make apk
make install
```
## Building a release
Use `make tag_release` to bump the Android version code, update the version
name, and tag the current commit.
We only guarantee to support the latest Go release and any Go beta or
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.
## Developing on a Fire Stick TV
On the Fire Stick:
* Settings > My Fire TV > Developer Options > ADB Debugging > ON
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.MainActivity
adb shell pm uninstall com.tailscale.ipn
```
## Bugs
Please file any issues about this code or the hosted service on
@ -54,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,43 +1,195 @@
buildscript {
ext.kotlin_version = "1.9.22"
ext.compose_version = "1.5.10"
ext.accompanist_version = "0.34.0"
repositories {
google()
jcenter()
mavenCentral()
maven {
url = uri("https://plugins.gradle.org/m2/")
}
}
dependencies {
classpath 'com.android.tools.build:gradle:3.6.0'
classpath 'com.android.tools.build:gradle:8.6.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")
}
}
allprojects {
repositories {
google()
jcenter()
flatDir {
dirs 'libs'
}
repositories {
google()
mavenCentral()
flatDir {
dirs 'libs'
}
}
apply plugin: 'kotlin-android'
apply plugin: 'com.android.application'
apply plugin: 'org.jetbrains.kotlin.plugin.serialization'
apply plugin: 'com.ncorti.ktfmt.gradle'
android {
compileSdkVersion 29
ndkVersion "23.1.7779620"
compileSdkVersion 34
defaultConfig {
minSdkVersion 23
targetSdkVersion 29
versionCode 12
versionName System.getenv("VERSION")
}
compileOptions {
sourceCompatibility 1.8
targetCompatibility 1.8
}
minSdkVersion 26
targetSdkVersion 34
versionCode 242
versionName getVersionProperty("VERSION_LONG")
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// This setting, which defaults to 'true', will cause Tailscale to fall
// back to the Google DNS servers if it cannot determine what the
// operating system's DNS configuration is.
//
// Set it to false either here or in your local.properties file to
// disable this behaviour.
buildConfigField "boolean", "USE_GOOGLE_DNS_FALLBACK", getLocalProperty("tailscale.useGoogleDnsFallback", "true")
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
lintOptions {
warningsAsErrors true
}
kotlinOptions {
jvmTarget = "17"
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
buildConfig true
compose true
}
composeOptions {
kotlinCompilerExtensionVersion = "$compose_version"
}
flavorDimensions "version"
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", "")+"\""
}
debug {
manifestPlaceholders.leanbackRequired = false
}
release {
manifestPlaceholders.leanbackRequired = false
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile(
'proguard-android-optimize.txt'),
'proguard-rules.pro'
}
release_tv {
initWith release
manifestPlaceholders.leanbackRequired = true
}
}
testBuildType "applicationTest"
}
dependencies {
implementation 'com.google.android.gms:play-services-auth:18.0.0'
implementation "androidx.core:core:1.2.0"
implementation "androidx.browser:browser:1.2.0"
implementation "androidx.security:security-crypto:1.0.0-rc01"
implementation ':ipn@aar'
// Android dependencies.
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.1"
// Kotlin dependencies.
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
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:2024.09.03')
implementation composeBom
implementation 'androidx.compose.material3:material3:1.3.0'
implementation 'androidx.compose.material:material-icons-core:1.7.3'
implementation "androidx.compose.ui:ui:1.7.3"
implementation "androidx.compose.ui:ui-tooling:1.7.3"
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6'
implementation 'androidx.activity:activity-compose:1.9.2'
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version"
implementation "androidx.core:core-splashscreen:1.1.0-rc01"
implementation "androidx.compose.animation:animation:1.7.4"
// Navigation dependencies.
def nav_version = "2.8.2"
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'
// Integration Tests
androidTestImplementation composeBom
androidTestImplementation 'androidx.test:runner:1.6.2'
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'
// 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.12.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
}
}
def getVersionProperty(key) {
// tailscale.version is created / updated by the makefile, it is in a loosely
// Makfile/envfile format, which is also loosely a properties file format.
// make tailscale.version
def versionProps = new Properties()
versionProps.load(project.file('../tailscale.version').newDataInputStream())
return versionProps.getProperty(key).replaceAll('^\"|\"$', '')
}

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

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

2
android/gradlew vendored

@ -44,7 +44,7 @@ APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
DEFAULT_JVM_OPTS='"-Xmx80m" "-Xms80m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"

@ -33,7 +33,7 @@ set APP_HOME=%DIRNAME%
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
set DEFAULT_JVM_OPTS="-Xmx80m" "-Xms80m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome

@ -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,163 @@
// 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 java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
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
@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, "Click through Get Started screen")
device.find(By.text("Get Started"))
device.find(By.text("Get Started")).click()
Log.d(TAG, "Wait for VPN permission prompt and accept")
device.find(By.text("Connection request"))
device.find(By.text("OK")).click()
asNecessary(
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("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, "Authorizing Tailscale")
device.find(By.text("Authorize tailscale")).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,31 +1,126 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.tailscale.ipn">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- Disable input emulation on ChromeOS -->
<uses-feature android:name="android.hardware.type.pc" android:required="false"/>
<application android:label="Tailscale" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round"
android:name=".App">
<activity android:name="org.gioui.GioActivity"
android:label="Tailscale"
android:theme="@style/Theme.GioApp"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service android:name=".IPNService"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService"/>
</intent-filter>
</service>
</application>
</manifest>
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.WRITE_EXTERNAL_STORAGE"
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
android:name="android.hardware.type.pc"
android:required="false" />
<!-- Signal support for Android TV -->
<uses-feature
android:name="android.software.leanback"
android:required="${leanbackRequired}" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<application
android:name=".App"
android:allowBackup="false"
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
android:name="MainActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
android:exported="true"
android:label="@string/app_name"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
</activity>
<activity
android:name="ShareActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
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" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="image/*" />
<data android:mimeType="message/*" />
<data android:mimeType="multipart/*" />
<data android:mimeType="text/*" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="image/*" />
<data android:mimeType="message/*" />
<data android:mimeType="multipart/*" />
<data android:mimeType="text/*" />
<data android:mimeType="video/*" />
</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" />
</intent-filter>
</service>
<service
android:name=".QuickToggleService"
android:exported="true"
android:icon="@drawable/ic_tile"
android:label="@string/tile_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<meta-data
android:name="android.content.APP_RESTRICTIONS"
android:resource="@xml/app_restrictions" />
</application>
</manifest>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@ -1,132 +0,0 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn;
import android.app.Application;
import android.app.Activity;
import android.app.Fragment;
import android.app.FragmentTransaction;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.content.BroadcastReceiver;
import android.provider.Settings;
import android.net.ConnectivityManager;
import android.view.View;
import android.os.Build;
import java.io.IOException;
import java.io.File;
import java.io.FileOutputStream;
import java.security.GeneralSecurityException;
import androidx.security.crypto.EncryptedSharedPreferences;
import androidx.security.crypto.MasterKeys;
import org.gioui.Gio;
public class App extends Application {
@Override public void onCreate() {
super.onCreate();
// Load and initialize the Go library.
Gio.init(this);
registerNetworkCallback();
}
private void registerNetworkCallback() {
BroadcastReceiver connectivityChanged = new BroadcastReceiver() {
@Override public void onReceive(Context ctx, Intent intent) {
boolean noconn = intent.getBooleanExtra(ConnectivityManager.EXTRA_NO_CONNECTIVITY, false);
onConnectivityChanged(!noconn);
}
};
registerReceiver(connectivityChanged, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION));
}
public void startVPN() {
Intent intent = new Intent(this, IPNService.class);
intent.setAction(IPNService.ACTION_CONNECT);
startService(intent);
}
public void stopVPN() {
Intent intent = new Intent(this, IPNService.class);
intent.setAction(IPNService.ACTION_DISCONNECT);
startService(intent);
}
// encryptToPref a byte array of data using the Jetpack Security
// library and writes it to a global encrypted preference store.
public void encryptToPref(String prefKey, String plaintext) throws IOException, GeneralSecurityException {
getEncryptedPrefs().edit().putString(prefKey, plaintext).commit();
}
// decryptFromPref decrypts a encrypted preference using the Jetpack Security
// library and returns the plaintext.
public String decryptFromPref(String prefKey) throws IOException, GeneralSecurityException {
return getEncryptedPrefs().getString(prefKey, null);
}
private SharedPreferences getEncryptedPrefs() throws IOException, GeneralSecurityException {
String masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC);
return EncryptedSharedPreferences.create(
"secret_shared_prefs",
masterKeyAlias,
this,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
);
}
String getHostname() {
String userConfiguredDeviceName = getUserConfiguredDeviceName();
if (!isEmpty(userConfiguredDeviceName)) return userConfiguredDeviceName;
return getModelName();
}
private String getModelName() {
String manu = Build.MANUFACTURER;
String model = Build.MODEL;
// Strip manufacturer from model.
int idx = model.toLowerCase().indexOf(manu.toLowerCase());
if (idx != -1) {
model = model.substring(idx + manu.length());
model = model.trim();
}
return manu + " " + model;
}
// get user defined nickname from Settings
// returns null if not available
private String getUserConfiguredDeviceName() {
String nameFromSystemBluetooth = Settings.System.getString(getContentResolver(), "bluetooth_name");
String nameFromSecureBluetooth = Settings.Secure.getString(getContentResolver(), "bluetooth_name");
String nameFromSystemDevice = Settings.Secure.getString(getContentResolver(), "device_name");
if (!isEmpty(nameFromSystemBluetooth)) return nameFromSystemBluetooth;
if (!isEmpty(nameFromSecureBluetooth)) return nameFromSecureBluetooth;
if (!isEmpty(nameFromSystemDevice)) return nameFromSystemDevice;
return null;
}
private static boolean isEmpty(String str) {
return str == null || str.length() == 0;
}
// Tracklifecycle adds a Peer fragment for tracking the Activity
// lifecycle.
static void trackLifecycle(View view) {
Activity act = (Activity)view.getContext();
FragmentTransaction ft = act.getFragmentManager().beginTransaction();
ft.add(new Peer(), "Peer");
ft.commitNow();
}
private static native void onConnectivityChanged(boolean connected);
}

@ -0,0 +1,587 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.Manifest
import android.app.Application
import android.app.Notification
import android.app.NotificationChannel
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.os.Build
import android.os.Environment
import android.util.Log
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.flow.combine
import kotlinx.coroutines.flow.first
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.NetworkInterface
import java.security.GeneralSecurityException
import java.util.Locale
class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
companion object {
private const val FILE_CHANNEL_ID = "tailscale-files"
private const val TAG = "App"
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 get(): App {
appInstance.initOnce()
return appInstance
}
}
val dns = DnsConfig()
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 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)
}
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
// to the given folder. We will preferentially use <shared>/Downloads and fallback to
// 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
NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
initViewModels()
applicationScope.launch {
Notifier.state.collect { state ->
combine(Notifier.state, MDMSettings.forceEnabled.flow) { state, forceEnabled ->
Pair(state, forceEnabled)
}
.collect { (state, hideDisconnectAction) ->
val ableToStartVPN = state > Ipn.State.NeedsMachineAuth
// If VPN is stopped, show a disconnected notification. If it is running as a
// foreground
// service, IPNService will show a connected notification.
if (state == Ipn.State.Stopped) {
notifyStatus(vpnRunning = false, hideDisconnectAction = hideDisconnectAction.value)
}
val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running
updateConnStatus(ableToStartVPN)
QuickToggleService.setVPNRunning(vpnRunning)
// Update notification status when VPN is running
if (vpnRunning) {
notifyStatus(vpnRunning = true, hideDisconnectAction = hideDisconnectAction.value)
}
}
}
}
applicationScope.launch {
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
}
}
private fun initViewModels() {
vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java)
}
fun setWantRunning(wantRunning: Boolean, onSuccess: (() -> Unit)? = null) {
val callback: (Result<Ipn.Prefs>) -> Unit = { result ->
result.fold(
onSuccess = { onSuccess?.invoke() },
onFailure = { error ->
TSLog.d("TAG", "Set want running: failed to update preferences: ${error.message}")
})
}
Client(applicationScope)
.editPrefs(Ipn.MaskedPrefs().apply { WantRunning = wantRunning }, callback)
}
// 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)
override fun encryptToPref(prefKey: String?, plaintext: String?) {
getEncryptedPrefs().edit().putString(prefKey, plaintext).commit()
}
// decryptFromPref decrypts a encrypted preference using the Jetpack Security
// library and returns the plaintext.
@Throws(IOException::class, GeneralSecurityException::class)
override fun decryptFromPref(prefKey: String?): String? {
return getEncryptedPrefs().getString(prefKey, null)
}
@Throws(IOException::class, GeneralSecurityException::class)
fun getEncryptedPrefs(): SharedPreferences {
val key = MasterKey.Builder(this).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
return EncryptedSharedPreferences.create(
this,
"secret_shared_prefs",
key,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
}
/*
* 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 {
val manu = Build.MANUFACTURER
var model = Build.MODEL
// Strip manufacturer from model.
val idx = model.lowercase(Locale.getDefault()).indexOf(manu.lowercase(Locale.getDefault()))
if (idx != -1) {
model = model.substring(idx + manu.length).trim()
}
return "$manu $model"
}
override fun getOSVersion(): String = Build.VERSION.RELEASE
override fun isChromeOS(): Boolean {
return packageManager.hasSystemFeature("android.hardware.type.pc")
}
override fun getInterfacesAsString(): String {
val interfaces: ArrayList<NetworkInterface> =
java.util.Collections.list(NetworkInterface.getNetworkInterfaces())
val sb = StringBuilder()
for (nif in interfaces) {
try {
sb.append(
String.format(
Locale.ROOT,
"%s %d %d %b %b %b %b %b |",
nif.name,
nif.index,
nif.mtu,
nif.isUp,
nif.supportsMulticast(),
nif.isLoopback,
nif.isPointToPoint,
nif.supportsMulticast()))
for (ia in nif.interfaceAddresses) {
val parts = ia.toString().split("/", limit = 0)
if (parts.size > 1) {
sb.append(String.format(Locale.ROOT, "%s/%d ", parts[1], ia.networkPrefixLength))
}
}
} catch (e: Exception) {
continue
}
sb.append("\n")
}
return sb.toString()
}
private fun prepareDownloadsFolder(): File {
var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
try {
if (!downloads.exists()) {
downloads.mkdirs()
}
} catch (e: Exception) {
TSLog.e(TAG, "Failed to create downloads folder: $e")
downloads = File(this.filesDir, "Taildrop")
try {
if (!downloads.exists()) {
downloads.mkdirs()
}
} catch (e: Exception) {
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 }
// FLAG_UPDATE_CURRENT ensures that if the intent is already pending, the existing intent will
// be updated rather than creating multiple redundant instances.
val pendingIntent =
PendingIntent.getService(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or
PendingIntent.FLAG_IMMUTABLE // FLAG_IMMUTABLE for Android 12+
)
try {
pendingIntent.send()
} catch (foregroundServiceStartException: IllegalStateException) {
TSLog.e(
TAG,
"startVPN hit ForegroundServiceStartNotAllowedException: $foregroundServiceStartException")
} catch (securityException: SecurityException) {
TSLog.e(TAG, "startVPN hit SecurityException: $securityException")
} catch (e: Exception) {
TSLog.e(TAG, "startVPN hit exception: $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")
}
}
fun restartVPN() {
// Register a receiver to listen for the completion of stopVPN
TSLog.d("KARI", "hi")
val stopReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
// Ensure stop intent is complete
if (intent?.action == IPNService.ACTION_STOP_VPN) {
// Unregister receiver after receiving the broadcast
context?.unregisterReceiver(this)
// Now start the VPN
startVPN()
}
}
}
// Register the receiver before stopping VPN
val intentFilter = IntentFilter(IPNService.ACTION_STOP_VPN)
this.registerReceiver(stopReceiver, intentFilter)
stopVPN()
}
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, hideDisconnectAction: Boolean) {
notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction))
}
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, hideDisconnectAction: 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)
val builder =
NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
.setSmallIcon(icon)
.setContentTitle(getString(R.string.app_name))
.setContentText(message)
.setAutoCancel(!vpnRunning)
.setOnlyAlertOnce(!vpnRunning)
.setOngoing(vpnRunning)
.setSilent(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
if (!vpnRunning || !hideDisconnectAction) {
builder.addAction(
NotificationCompat.Action.Builder(0, actionLabel, pendingButtonIntent).build())
}
return builder.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)"
}
}
}

@ -0,0 +1,54 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
// Tailscale DNS Config retrieval
//
// Tailscale's DNS support can either override the local DNS servers with a set of servers
// configured in the admin panel, or supplement the local DNS servers with additional
// servers for specific domains like example.com.beta.tailscale.net. In the non-override mode,
// we need to retrieve the current set of DNS servers from the platform. These will typically
// be the DNS servers received from DHCP.
//
// Importantly, after the Tailscale VPN comes up it will set a DNS server of 100.100.100.100
// but we still want to retrieve the underlying DNS servers received from DHCP. If we roam
// from Wi-Fi to LTE, we want the DNS servers received from LTE.
public class DnsConfig {
private String dnsConfigs;
// getDnsConfigAsString returns the current DNS configuration as a multiline string:
// line[0] DNS server addresses separated by spaces
// line[1] search domains separated by spaces
//
// For example:
// 8.8.8.8 8.8.4.4
// example.com
//
// an empty string means the current DNS configuration could not be retrieved.
String getDnsConfigAsString() {
String dnsConfig = getDnsConfigs();
if (dnsConfig != null) {
return getDnsConfigs().trim();
}
return "";
}
private String getDnsConfigs() {
synchronized (this) {
return this.dnsConfigs;
}
}
boolean updateDNSFromNetwork(String dnsConfigs) {
synchronized (this) {
if (!dnsConfigs.equals(this.dnsConfigs)) {
this.dnsConfigs = dnsConfigs;
return true;
} else {
return false;
}
}
}
}

@ -0,0 +1,45 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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);
// On the relevant action, start the relevant worker, which can stay active for longer than this receiver can.
if (Objects.equals(intent.getAction(), INTENT_CONNECT_VPN)) {
workManager.enqueue(new OneTimeWorkRequest.Builder(StartVPNWorker.class).build());
} 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());
}
}
}

@ -1,111 +0,0 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn;
import android.os.Build;
import android.app.PendingIntent;
import android.app.NotificationChannel;
import android.content.Intent;
import android.net.VpnService;
import android.system.OsConstants;
import org.gioui.GioActivity;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
public class IPNService extends VpnService {
public static final String ACTION_CONNECT = "com.tailscale.ipn.CONNECT";
public static final String ACTION_DISCONNECT = "com.tailscale.ipn.DISCONNECT";
private static final String STATUS_CHANNEL_ID = "tailscale-status";
private static final String STATUS_CHANNEL_NAME = "VPN Status";
private static final int STATUS_NOTIFICATION_ID = 1;
private static final String NOTIFY_CHANNEL_ID = "tailscale-notify";
private static final String NOTIFY_CHANNEL_NAME = "Notifications";
private static final int NOTIFY_NOTIFICATION_ID = 2;
@Override public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && ACTION_DISCONNECT.equals(intent.getAction())) {
close();
return START_NOT_STICKY;
}
connect();
return START_STICKY;
}
private void close() {
stopForeground(true);
disconnect();
}
@Override public void onDestroy() {
close();
super.onDestroy();
}
@Override public void onRevoke() {
close();
super.onRevoke();
}
private PendingIntent configIntent() {
return PendingIntent.getActivity(this, 0, new Intent(this, GioActivity.class), PendingIntent.FLAG_UPDATE_CURRENT);
}
protected VpnService.Builder newBuilder() {
VpnService.Builder b = new VpnService.Builder()
.setConfigureIntent(configIntent())
.allowFamily(OsConstants.AF_INET)
.allowFamily(OsConstants.AF_INET6);
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.
return b;
}
public void notify(String title, String message) {
createNotificationChannel(NOTIFY_CHANNEL_ID, NOTIFY_CHANNEL_NAME, NotificationManagerCompat.IMPORTANCE_DEFAULT);
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, NOTIFY_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(message)
.setContentIntent(configIntent())
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.notify(NOTIFY_NOTIFICATION_ID, builder.build());
}
public void updateStatusNotification(String title, String message) {
createNotificationChannel(STATUS_CHANNEL_ID, STATUS_CHANNEL_NAME, NotificationManagerCompat.IMPORTANCE_LOW);
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(message)
.setContentIntent(configIntent())
.setPriority(NotificationCompat.PRIORITY_LOW);
startForeground(STATUS_NOTIFICATION_ID, builder.build());
}
private void createNotificationChannel(String id, String name, int importance) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationChannel channel = new NotificationChannel(id, name, importance);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.createNotificationChannel(channel);
}
private native void connect();
private native void disconnect();
}

@ -0,0 +1,181 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageManager
import android.net.VpnService
import android.os.Build
import android.system.OsConstants
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
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
val scope = CoroutineScope(Dispatchers.IO)
override fun id(): String {
return randomID
}
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()
START_NOT_STICKY
}
ACTION_START_VPN -> {
scope.launch {
// Collect the first value of hideDisconnectAction asynchronously.
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
showForegroundNotification(hideDisconnectAction.value)
}
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.
scope.launch {
// Collect the first value of hideDisconnectAction asynchronously.
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
app.notifyStatus(true, hideDisconnectAction.value)
}
app.setWantRunning(true)
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()) {
scope.launch {
// Collect the first value of hideDisconnectAction asynchronously.
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
showForegroundNotification(hideDisconnectAction.value)
}
App.get()
Libtailscale.requestVPN(this)
START_STICKY
} else {
START_NOT_STICKY
}
}
}
override fun close() {
app.setWantRunning(false) {}
Notifier.setState(Ipn.State.Stopping)
disconnectVPN()
Libtailscale.serviceDisconnect(this)
}
override fun disconnectVPN() {
stopSelf()
}
override fun onDestroy() {
close()
updateVpnStatus(false)
super.onDestroy()
}
override fun onRevoke() {
close()
updateVpnStatus(false)
super.onRevoke()
}
private fun setVpnPrepared(isPrepared: Boolean) {
app.getAppScopedViewModel().setVpnPrepared(isPrepared)
}
private fun showForegroundNotification(hideDisconnectAction: Boolean) {
try {
startForeground(
UninitializedApp.STATUS_NOTIFICATION_ID,
UninitializedApp.get().buildStatusNotification(true, hideDisconnectAction))
} catch (e: Exception) {
TSLog.e(TAG, "Failed to start foreground service: $e")
}
}
private fun configIntent(): PendingIntent {
return PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
private fun disallowApp(b: Builder, name: String) {
try {
b.addDisallowedApplication(name)
} catch (e: PackageManager.NameNotFoundException) {
TSLog.d(TAG, "Failed to add disallowed application: $e")
}
}
override fun newBuilder(): VPNServiceBuilder {
val b: Builder =
Builder()
.setConfigureIntent(configIntent())
.allowFamily(OsConstants.AF_INET)
.allowFamily(OsConstants.AF_INET6)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
b.setMetered(false) // Inherit the metered status from the underlying networks.
}
b.setUnderlyingNetworks(null) // Use all available networks.
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)
}
}
return VPNServiceBuilder(b)
}
companion object {
const val ACTION_START_VPN = "com.tailscale.ipn.START_VPN"
const val ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN"
}
}

@ -0,0 +1,426 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Bundle
import android.provider.Settings
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 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.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 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 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 {
private const val TAG = "Main Activity"
private const val START_AT_ROOT = "startAtRoot"
}
private fun Context.isLandscapeCapable(): Boolean {
return (resources.configuration.screenLayout and SCREENLAYOUT_SIZE_MASK) >=
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()) {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
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 {
navController = rememberNavController()
Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox
Surface(modifier = Modifier.universalFit()) { // Letterbox for AndroidTV
NavHost(
navController = navController,
startDestination = "main",
enterTransition = {
slideInHorizontally(animationSpec = tween(150), initialOffsetX = { it })
},
exitTransition = {
slideOutHorizontally(animationSpec = tween(150), targetOffsetX = { -it })
},
popEnterTransition = {
slideInHorizontally(animationSpec = tween(150), initialOffsetX = { -it })
},
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") },
onNavigateToPeerDetails = {
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") },
onBackToSettings = backTo("settings"),
onNavigateBackHome = backTo("main"))
val exitNodePickerNav =
ExitNodePickerNav(
onNavigateBackHome = {
navController.popBackStack(route = "main", inclusive = false)
},
onNavigateBackToExitNodes = backTo("exitNodes"),
onNavigateToMullvad = { navController.navigate("mullvad") },
onNavigateToMullvadInfo = { navController.navigate("mullvad_info") },
onNavigateBackToMullvad = backTo("mullvad"),
onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") },
onNavigateToRunAsExitNode = { navController.navigate("runExitNode") })
val userSwitcherNav =
UserSwitcherNav(
backToSettings = backTo("settings"),
onNavigateHome = backTo("main"),
onNavigateCustomControl = {
navController.navigate("loginWithCustomControl")
},
onNavigateToAuthKey = { navController.navigate("loginWithAuthKey") })
composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) {
MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel)
}
composable("settings") { SettingsView(settingsNav) }
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 })) {
MullvadExitNodePicker(
it.arguments!!.getString("countryCode")!!, exitNodePickerNav)
}
composable("runExitNode") { RunExitNodeView(exitNodePickerNav) }
composable(
"peerDetails/{nodeId}",
arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) {
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(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"))
}
}
// Show the intro screen one time
if (!introScreenViewed()) {
navController.navigate("intro")
setIntroScreenViewed(true)
}
}
}
// 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 or
// pop up a QR code to scan
lifecycleScope.launch {
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.get().applicationScope.launch {
try {
Notifier.state.collect { state ->
if (state > Ipn.State.NeedsMachineAuth) {
val intent =
Intent(applicationContext, MainActivity::class.java).apply {
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)
// Cancel coroutine once we've logged in
this@launch.cancel()
}
}
} catch (e: Exception) {
TSLog.e(TAG, "Login: failed to start MainActivity: $e")
}
}
val url = urlString.toUri()
try {
val customTabsIntent = CustomTabsIntent.Builder().build()
customTabsIntent.launchUrl(this, url)
} catch (e: Exception) {
// Fallback to a regular browser if CustomTabsIntent fails
try {
val fallbackIntent = Intent(Intent.ACTION_VIEW, url)
startActivity(fallbackIntent)
} catch (e: Exception) {
TSLog.e(TAG, "Login: failed to open browser: $e")
}
}
}
override fun onResume() {
super.onResume()
val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
}
override fun onStart() {
super.onStart()
}
override fun onStop() {
super.onStop()
val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
}
private fun openApplicationSettings() {
val intent =
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
}
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
private fun introScreenViewed(): Boolean {
return getSharedPreferences("introScreen", Context.MODE_PRIVATE).getBoolean("seen", false)
}
private fun setIntroScreenViewed(seen: Boolean) {
getSharedPreferences("introScreen", Context.MODE_PRIVATE)
.edit()
.putBoolean("seen", seen)
.apply()
}
}
class VpnPermissionContract : ActivityResultContract<Intent, Boolean>() {
override fun createIntent(context: Context, input: Intent): Intent {
return input
}
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
return resultCode == Activity.RESULT_OK
}
}

@ -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,89 +0,0 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.app.Fragment;
import android.app.DialogFragment;
import android.content.Intent;
import android.net.Uri;
import android.net.VpnService;
import android.os.Bundle;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import androidx.browser.customtabs.CustomTabsIntent;
import com.google.android.gms.auth.api.signin.GoogleSignIn;
import com.google.android.gms.auth.api.signin.GoogleSignInAccount;
import com.google.android.gms.auth.api.signin.GoogleSignInClient;
import com.google.android.gms.auth.api.signin.GoogleSignInOptions;
public class Peer extends Fragment {
private final static int REQUEST_SIGNIN = 1001;
private final static int REQUEST_PREPARE_VPN = 1002;
@Override public void onActivityResult(int requestCode, int resultCode, Intent data) {
switch (requestCode) {
case REQUEST_SIGNIN:
if (resultCode == Activity.RESULT_OK) {
GoogleSignInAccount acc = GoogleSignIn.getLastSignedInAccount(getActivity());
onSignin(acc.getIdToken());
return;
}
case REQUEST_PREPARE_VPN:
if (resultCode == Activity.RESULT_OK) {
onVPNPrepared();
return;
}
}
super.onActivityResult(requestCode, resultCode, data);
}
@Override public void onCreate(Bundle b) {
super.onCreate(b);
fragmentCreated();
}
@Override public void onDestroy() {
fragmentDestroyed();
super.onDestroy();
}
public void googleSignIn(String serverOAuthID) {
GoogleSignInOptions gso = new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestIdToken(serverOAuthID)
.requestEmail()
.build();
GoogleSignInClient client = GoogleSignIn.getClient(getActivity(), gso);
Intent signInIntent = client.getSignInIntent();
startActivityForResult(signInIntent, REQUEST_SIGNIN);
}
public void prepareVPN() {
Intent intent = VpnService.prepare(getActivity());
if (intent == null) {
onVPNPrepared();
} else {
startActivityForResult(intent, REQUEST_PREPARE_VPN);
}
}
void showURL(String url) {
CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
int headerColor = 0xff496495;
builder.setToolbarColor(headerColor);
CustomTabsIntent intent = builder.build();
intent.launchUrl(getActivity(), Uri.parse(url));
}
private native void fragmentCreated();
private native void fragmentDestroyed();
private native void onSignin(String idToken);
private native void onVPNPrepared();
}

@ -0,0 +1,99 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
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();
// isRunning tracks whether the VPN is running.
private static boolean isRunning;
// currentTile tracks getQsTile while service is listening.
private static Tile currentTile;
public static void updateTile() {
var app = UninitializedApp.get();
Tile t;
boolean act;
synchronized (lock) {
t = currentTile;
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 setVPNRunning(boolean running) {
synchronized (lock) {
isRunning = running;
}
updateTile();
}
@Override
public void onStartListening() {
synchronized (lock) {
currentTile = getQsTile();
}
updateTile();
}
@Override
public void onStopListening() {
synchronized (lock) {
currentTile = null;
}
}
@SuppressWarnings("deprecation")
@Override
public void onClick() {
boolean r;
synchronized (lock) {
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() {
UninitializedApp app = UninitializedApp.get();
boolean needsToStop;
synchronized (lock) {
needsToStop = app.isAbleToStartVPN() && isRunning;
}
if (needsToStop) {
app.stopVPN();
} else {
app.startVPN();
}
}
}

@ -0,0 +1,129 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.tailscale.ipn.ui.model.Ipn
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.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.random.Random
// ShareActivity is the entry point for Taildrop share intents
class ShareActivity : ComponentActivity() {
private val TAG = ShareActivity::class.simpleName
private val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>> = MutableStateFlow(emptyList())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
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()
// Ensure our app instance is initialized
App.get()
lifecycleScope.launch { withContext(Dispatchers.IO) { loadFiles() } }
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
loadFiles()
}
// Loads the files from the intent.
fun loadFiles() {
if (intent == null) {
TSLog.e(TAG, "Share failure - No intent found")
return
}
val act = intent.action
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)
}
}
Intent.ACTION_SEND_MULTIPLE -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra<Uri?>(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION") intent.getParcelableArrayListExtra<Uri?>(Intent.EXTRA_STREAM)
}
}
else -> {
TSLog.e(TAG, "No extras found in intent - nothing to share")
null
}
}
val pendingFiles: List<Ipn.OutgoingFile> =
uris?.filterNotNull()?.mapNotNull {
contentResolver?.query(it, null, null, null, null)?.let { c ->
val nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeCol = c.getColumnIndex(OpenableColumns.SIZE)
c.moveToFirst()
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)
file.uri = it
file
}
} ?: emptyList()
if (pendingFiles.isEmpty()) {
TSLog.e(TAG, "Share failure - no files extracted from intent")
}
requestedTransfers.set(pendingFiles)
}
}

@ -0,0 +1,65 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
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) {
super(appContext, workerParams);
}
@NonNull
@Override
public Result doWork() {
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();
}
}
// 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, 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(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();
}
}

@ -0,0 +1,29 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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(
Context appContext,
WorkerParameters workerParams) {
super(appContext, workerParams);
}
@NonNull
@Override
public Result doWork() {
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"
}
}

@ -0,0 +1,48 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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) {
builder.addAddress(p0, p1)
}
override fun addDNSServer(p0: String) {
builder.addDnsServer(p0)
}
override fun addRoute(p0: String, p1: Int) {
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)
}
override fun establish(): ParcelFileDescriptor? {
return builder.establish()?.let { ParcelFileDescriptor(it) }
}
override fun setMTU(p0: Int) {
builder.setMtu(p0)
}
}
class ParcelFileDescriptor(private val fd: android.os.ParcelFileDescriptor) :
libtailscale.ParcelFileDescriptor {
override fun detach(): Int {
return fd.detachFd()
}
}

@ -0,0 +1,115 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.mdm
import android.content.RestrictionsManager
import com.tailscale.ipn.App
import kotlin.reflect.KVisibility
import kotlin.reflect.full.declaredMemberProperties
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
.filter {
it.visibility == KVisibility.PUBLIC &&
it.returnType.jvmErasure.isSubclassOf(MDMSetting::class)
}
.map { it.call(MDMSettings) as MDMSetting<*> }
}
val allSettingsByKey by lazy { allSettings.associateBy { it.key } }
fun update(app: App, restrictionsManager: RestrictionsManager?) {
val bundle = restrictionsManager?.applicationRestrictions
val preferences = lazy { app.getEncryptedPrefs() }
allSettings.forEach { it.setFrom(bundle, preferences) }
app.notifyPolicyChanged()
}
}

@ -0,0 +1,121 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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 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 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) }
}
}
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 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 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 getFromBundle(bundle: Bundle): List<String>? {
// Try to retrieve the value as a String[] first
val stringArray = bundle.getStringArray(key)
if (stringArray != null) {
return stringArray.toList()
}
// Optionally, handle other types if necessary
val stringArrayList = bundle.getStringArrayList(key)
if (stringArrayList != null) {
return stringArrayList
}
// If neither String[] nor ArrayList<String> is found, return null
return null
}
override fun getFromPrefs(prefs: SharedPreferences): List<String>? {
return prefs.getStringSet(key, HashSet<String>())?.toList()
}
}
class AlwaysNeverUserDecidesMDMSetting(key: String, localizedTitle: String) :
MDMSetting<AlwaysNeverUserDecides>(AlwaysNeverUserDecides.UserDecides, key, localizedTitle) {
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 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");
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");
override fun toString(): String {
return value
}
companion object {
fun fromString(value: String?): ShowHide {
return ShowHide.values().find { it.value == value } ?: Show
}
}
}

@ -0,0 +1,27 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui
object Links {
const val DEFAULT_CONTROL_URL = "https://controlplane.tailscale.com"
const val SERVER_URL = "https://login.tailscale.com"
const val ADMIN_URL = SERVER_URL + "/admin"
const val SIGNIN_URL = "https://tailscale.com/login"
const val PRIVACY_POLICY_URL = "https://tailscale.com/privacy-policy/"
const val TERMS_URL = "https://tailscale.com/terms"
const val DOCS_URL = "https://tailscale.com/kb/"
const val START_GUIDE_URL = "https://tailscale.com/kb/1017/install/"
const val LICENSES_URL = "https://tailscale.com/licenses/android"
const val DELETE_ACCOUNT_URL =
"https://login.tailscale.com/login?next_url=%2Fadmin%2Fsettings%2Fgeneral"
const val TAILNET_LOCK_KB_URL = "https://tailscale.com/kb/1226/tailnet-lock/"
const val KEY_EXPIRY_KB_URL = "https://tailscale.com/kb/1028/key-expiry/"
const val INSTALL_TAILSCALE_KB_URL = "https://tailscale.com/kb/installation/"
const val INSTALL_UNSTABLE_KB_URL = "https://tailscale.com/kb/1083/install-unstable"
const val MAGICDNS_KB_URL = "https://tailscale.com/kb/1081/magicdns"
const val TROUBLESHOOTING_KB_URL = "https://tailscale.com/kb/1023/troubleshooting"
const val SUPPORT_URL = "https://tailscale.com/contact/support#support-form"
const val TAILDROP_KB_URL = "https://tailscale.com/kb/1106/taildrop"
const val TAILFS_KB_URL = "https://tailscale.com/kb/1106/taildrop"
}

@ -0,0 +1,368 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.localapi
import android.content.Context
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
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.serializer
import libtailscale.FilePart
import java.nio.charset.Charset
import kotlin.reflect.KType
import kotlin.reflect.typeOf
private object Endpoint {
const val DEBUG = "debug"
const val DEBUG_LOG = "debug-log"
const val BUG_REPORT = "bugreport"
const val PREFS = "prefs"
const val FILE_TARGETS = "file-targets"
const val UPLOAD_METRICS = "upload-client-metrics"
const val START = "start"
const val LOGIN_INTERACTIVE = "login-interactive"
const val RESET_AUTH = "reset-auth"
const val LOGOUT = "logout"
const val PROFILES = "profiles/"
const val PROFILES_CURRENT = "profiles/current"
const val STATUS = "status"
const val TKA_STATUS = "tka/status"
const val TKA_SIGN = "tka/sign"
const val TKA_VERIFY_DEEP_LINK = "tka/verify-deeplink"
const val PING = "ping"
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
typealias TailnetLockStatusResponseHandler = (Result<IpnState.NetworkLockStatus>) -> Unit
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.
*/
class Client(private val scope: CoroutineScope) {
private val TAG = Client::class.simpleName
fun start(options: Ipn.Options, responseHandler: (Result<Unit>) -> Unit) {
val body = Json.encodeToString(options).toByteArray()
return post(Endpoint.START, body, responseHandler = responseHandler)
}
fun status(responseHandler: StatusResponseHandler) {
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)
}
fun prefs(responseHandler: PrefsHandler) {
get(Endpoint.PREFS, responseHandler = responseHandler)
}
fun editPrefs(prefs: Ipn.MaskedPrefs, responseHandler: (Result<Ipn.Prefs>) -> Unit) {
val body = Json.encodeToString(prefs).toByteArray()
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)
}
fun currentProfile(responseHandler: (Result<IpnLocal.LoginProfile>) -> Unit) {
return get(Endpoint.PROFILES_CURRENT, responseHandler = responseHandler)
}
fun addProfile(responseHandler: (Result<String>) -> Unit = {}) {
return put(Endpoint.PROFILES, responseHandler = responseHandler)
}
fun deleteProfile(
profile: IpnLocal.LoginProfile,
responseHandler: (Result<String>) -> Unit = {}
) {
return delete(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
}
fun switchProfile(
profile: IpnLocal.LoginProfile,
responseHandler: (Result<String>) -> Unit = {}
) {
return post(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
}
fun startLoginInteractive(responseHandler: (Result<Unit>) -> Unit) {
return post(Endpoint.LOGIN_INTERACTIVE, responseHandler = responseHandler)
}
fun logout(responseHandler: (Result<String>) -> Unit) {
return post(Endpoint.LOGOUT, responseHandler = responseHandler)
}
fun tailnetLockStatus(responseHandler: TailnetLockStatusResponseHandler) {
get(Endpoint.TKA_STATUS, responseHandler = responseHandler)
}
fun fileTargets(responseHandler: (Result<List<Ipn.FileTarget>>) -> Unit) {
get(Endpoint.FILE_TARGETS, responseHandler = responseHandler)
}
fun putTaildropFiles(
context: Context,
peerId: StableNodeID,
files: Collection<Ipn.OutgoingFile>,
responseHandler: (Result<String>) -> Unit
) {
val manifest = Json.encodeToString(files)
val manifestPart = FilePart()
manifestPart.body = InputStreamAdapter(manifest.byteInputStream(Charset.defaultCharset()))
manifestPart.filename = "manifest.json"
manifestPart.contentType = "application/json"
val parts = mutableListOf(manifestPart)
try {
parts.addAll(
files.map { file ->
val stream =
context.contentResolver.openInputStream(file.uri)
?: throw Exception("Error opening file stream")
val part = FilePart()
part.filename = file.Name
part.contentLength = file.DeclaredSize
part.body = InputStreamAdapter(stream)
part
})
} catch (e: Exception) {
parts.forEach { it.body.close() }
TSLog.e(TAG, "Error creating file upload body: $e")
responseHandler(Result.failure(e))
return
}
return postMultipart(
"${Endpoint.FILE_PUT}/${peerId}",
FileParts(parts),
responseHandler,
)
}
private inline fun <reified T> get(
path: String,
body: ByteArray? = null,
noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "GET",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler)
.execute()
}
private inline fun <reified T> put(
path: String,
body: ByteArray? = null,
noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "PUT",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler)
.execute()
}
private inline fun <reified T> post(
path: String,
body: ByteArray? = null,
timeoutMillis: Long = 30000,
noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "POST",
path = path,
body = body,
timeoutMillis = timeoutMillis,
responseType = typeOf<T>(),
responseHandler = responseHandler)
.execute()
}
private inline fun <reified T> postMultipart(
path: String,
parts: FileParts,
noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "POST",
path = path,
parts = parts,
timeoutMillis = 24 * 60 * 60 * 1000, // 24 hours
responseType = typeOf<T>(),
responseHandler = responseHandler)
.execute()
}
private inline fun <reified T> patch(
path: String,
body: ByteArray? = null,
noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "PATCH",
path = path,
body = body,
responseType = typeOf<T>(),
responseHandler = responseHandler)
.execute()
}
private inline fun <reified T> delete(
path: String,
noinline responseHandler: (Result<T>) -> Unit
) {
Request(
scope = scope,
method = "DELETE",
path = path,
responseType = typeOf<T>(),
responseHandler = responseHandler)
.execute()
}
}
class Request<T>(
private val scope: CoroutineScope,
private val method: String,
path: String,
private val body: ByteArray? = null,
private val parts: FileParts? = null,
private val timeoutMillis: Long = 30000,
private val responseType: KType,
private val responseHandler: (Result<T>) -> Unit
) {
private val fullPath = "/localapi/v0/$path"
companion object {
private const val TAG = "LocalAPIRequest"
private val jsonDecoder = Json { ignoreUnknownKeys = true }
private lateinit var app: libtailscale.Application
@JvmStatic
fun setApp(newApp: libtailscale.Application) {
app = newApp
}
}
@OptIn(ExperimentalSerializationApi::class)
fun execute() {
scope.launch(Dispatchers.IO) {
TSLog.d(TAG, "Executing request:${method}:${fullPath} on app $app")
try {
val resp =
if (parts != null) app.callLocalAPIMultipart(timeoutMillis, method, fullPath, parts)
else
app.callLocalAPI(
timeoutMillis,
method,
fullPath,
body?.let { InputStreamAdapter(it.inputStream()) })
// 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)
typeOf<Unit>() -> Result.success(Unit as T)
else ->
try {
Result.success(
jsonDecoder.decodeFromStream(
Json.serializersModule.serializer(responseType), respData.inputStream())
as T)
} catch (t: Throwable) {
// If we couldn't parse the response body, assume it's an error response
try {
val error =
jsonDecoder.decodeFromStream<Errors.GenericError>(respData.inputStream())
throw Exception(error.error)
} catch (t: Throwable) {
Result.failure(t)
}
}
}
if (resp.statusCode() >= 400) {
throw Exception(
"Request failed with status ${resp.statusCode()}: ${respData.toString(Charset.defaultCharset())}")
}
// The response handler will invoked internally by the request parser
scope.launch { responseHandler(response) }
} catch (e: Exception) {
TSLog.e(TAG, "Error executing request:${method}:${fullPath}: $e")
scope.launch { responseHandler(Result.failure(e)) }
}
}
}
}
class FileParts(private val parts: List<FilePart>) : libtailscale.FileParts {
override fun get(i: Int): FilePart {
return parts[i]
}
override fun len(): Int {
return parts.size
}
}

@ -0,0 +1,30 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.model
import kotlinx.serialization.Serializable
class Dns {
@Serializable data class HostEntry(val addr: Addr?, val hosts: List<String>?)
@Serializable
data class OSConfig(
val hosts: List<HostEntry>? = null,
val nameservers: List<Addr>? = null,
val searchDomains: List<String>? = null,
val matchDomains: List<String>? = null,
) {
val isEmpty: Boolean
get() =
(hosts.isNullOrEmpty()) &&
(nameservers.isNullOrEmpty()) &&
(searchDomains.isNullOrEmpty()) &&
(matchDomains.isNullOrEmpty())
}
}
class DnsType {
@Serializable
data class Resolver(var Addr: String? = null, var BootstrapResolution: List<Addr>? = null)
}

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

@ -0,0 +1,240 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.model
import android.net.Uri
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.util.UUID
class Ipn {
// Represents the overall state of the Tailscale engine.
enum class State(val value: Int) {
NoState(0),
InUseOtherUser(1),
NeedsLogin(2),
NeedsMachineAuth(3),
Stopped(4),
Starting(5),
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 {
return State.values().firstOrNull { it.value == value } ?: NoState
}
}
}
// A nofitication message recieved on the Notify bus. Fields will be populated based
// on which NotifyWatchOpts were set when the Notifier was created.
@Serializable
data class Notify(
val Version: String? = null,
val ErrMessage: String? = null,
val LoginFinished: Empty.Message? = null,
val FilesWaiting: Empty.Message? = null,
val OutgoingFiles: List<OutgoingFile>? = null,
val State: Int? = null,
var Prefs: Prefs? = null,
var NetMap: Netmap.NetworkMap? = null,
var Engine: EngineStatus? = null,
var BrowseToURL: String? = null,
var BackendLogId: String? = null,
var LocalTCPPort: Int? = null,
var IncomingFiles: List<PartialFile>? = null,
var ClientVersion: Tailcfg.ClientVersion? = null,
var TailFSShares: List<String>? = null,
var Health: Health.State? = null,
)
@Serializable
data class Prefs(
var ControlURL: String = "",
var RouteAll: Boolean = false,
var AllowsSingleHosts: Boolean = false,
var CorpDNS: Boolean = false,
var WantRunning: Boolean = false,
var LoggedOut: Boolean = false,
var ShieldsUp: Boolean = false,
var AdvertiseRoutes: List<String>? = null,
var AdvertiseTags: List<String>? = null,
var ExitNodeID: StableNodeID? = null,
var ExitNodeAllowLANAccess: Boolean = false,
var Config: Persist.Persist? = null,
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(
var ControlURLSet: Boolean? = null,
var RouteAllSet: Boolean? = null,
var CorpDNSSet: Boolean? = null,
var ExitNodeIDSet: Boolean? = null,
var ExitNodeAllowLANAccessSet: Boolean? = null,
var WantRunningSet: Boolean? = null,
var ShieldsUpSet: Boolean? = null,
var AdvertiseRoutesSet: Boolean? = null,
var ForceDaemonSet: Boolean? = null,
var HostnameSet: Boolean? = null,
var InternalExitNodePriorSet: Boolean? = null,
) {
var ControlURL: String? = null
set(value) {
field = value
ControlURLSet = true
}
var RouteAll: Boolean? = null
set(value) {
field = value
RouteAllSet = true
}
var CorpDNS: Boolean? = null
set(value) {
field = value
CorpDNSSet = true
}
var ExitNodeID: StableNodeID? = null
set(value) {
field = value
ExitNodeIDSet = true
}
var InternalExitNodePrior: String? = null
set(value) {
field = value
InternalExitNodePriorSet = true
}
var ExitNodeAllowLANAccess: Boolean? = null
set(value) {
field = value
ExitNodeAllowLANAccessSet = true
}
var WantRunning: Boolean? = null
set(value) {
field = value
WantRunningSet = true
}
var ShieldsUp: Boolean? = null
set(value) {
field = value
ShieldsUpSet = true
}
var AdvertiseRoutes: List<String>? = null
set(value) {
field = value
AdvertiseRoutesSet = true
}
var ForceDaemon: Boolean? = null
set(value) {
field = value
ForceDaemonSet = true
}
var Hostname: String? = null
set(value) {
field = value
HostnameSet = true
}
}
@Serializable
data class AutoUpdatePrefs(
var Check: Boolean? = null,
var Apply: Boolean? = null,
)
@Serializable
data class EngineStatus(
val RBytes: Long,
val WBytes: Long,
val NumLive: Int,
val LivePeers: Map<String, IpnState.PeerStatusLite>,
)
@Serializable
data class PartialFile(
val Name: String,
val Started: String,
val DeclaredSize: Long,
val Received: Long,
val PartialPath: String? = null,
var FinalPath: String? = null,
val Done: Boolean? = null,
)
@Serializable
data class OutgoingFile(
val ID: String = "",
val Name: String,
val PeerID: StableNodeID = "",
val Started: String = "",
val DeclaredSize: Long,
val Sent: Long = 0L,
val PartialPath: String? = null,
var FinalPath: String? = null,
val Finished: Boolean = false,
val Succeeded: Boolean = false,
) {
@Transient lateinit var uri: Uri // only used on client
fun prepare(peerId: StableNodeID): OutgoingFile {
val f = copy(ID = UUID.randomUUID().toString(), PeerID = peerId)
f.uri = uri
return f
}
}
@Serializable data class FileTarget(var Node: Tailcfg.Node, var PeerAPIURL: String)
@Serializable
data class Options(
var FrontendLogID: String? = null,
var UpdatePrefs: Prefs? = null,
var AuthKey: String? = null,
)
}
class Persist {
@Serializable
data class Persist(
var PrivateMachineKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
var PrivateNodeKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
var OldPrivateNodeKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
var Provider: String = "",
)
}

@ -0,0 +1,152 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.model
import kotlinx.serialization.Serializable
import java.net.URL
class IpnState {
@Serializable
data class PeerStatusLite(
val RxBytes: Long,
val TxBytes: Long,
val LastHandshake: String,
val NodeKey: String,
)
@Serializable
data class PeerStatus(
val ID: StableNodeID,
val HostName: String,
val DNSName: String,
val TailscaleIPs: List<Addr>? = null,
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,
val Active: Boolean,
val PeerAPIURL: List<String>? = null,
val Capabilities: List<String>? = null,
val SSH_HostKeys: List<String>? = null,
val ShareeNode: Boolean? = null,
val Expired: Boolean? = null,
val Location: Tailcfg.Location? = null,
) {
fun computedName(status: Status): String {
val name = DNSName
val suffix = status.CurrentTailnet?.MagicDNSSuffix
suffix ?: return name
if (!(name.endsWith("." + suffix + "."))) {
return name
}
return name.dropLast(suffix.count() + 2)
}
}
@Serializable
data class ExitNodeStatus(
val ID: StableNodeID,
val Online: Boolean,
val TailscaleIPs: List<Prefix>? = null,
)
@Serializable
data class TailnetStatus(
val Name: String,
val MagicDNSSuffix: String,
val MagicDNSEnabled: Boolean,
)
@Serializable
data class Status(
val Version: String,
val TUN: Boolean,
val BackendState: String,
val AuthURL: String,
val TailscaleIPs: List<Addr>? = null,
val Self: PeerStatus? = null,
val ExitNodeStatus: ExitNodeStatus? = null,
val Health: List<String>? = null,
val CurrentTailnet: TailnetStatus? = null,
val CertDomains: List<String>? = null,
val Peer: Map<String, PeerStatus>? = null,
val User: Map<String, Tailcfg.UserProfile>? = null,
val ClientVersion: Tailcfg.ClientVersion? = null,
)
@Serializable
data class NetworkLockStatus(
var Enabled: Boolean? = null,
var PublicKey: String? = null,
var NodeKey: String? = null,
var NodeKeySigned: Boolean? = null,
var FilteredPeers: List<TKAFilteredPeer>? = null,
var StateID: ULong? = null,
var TrustedKeys: List<TKAKey>? = null
) {
fun IsPublicKeyTrusted(): Boolean {
return TrustedKeys?.any { it.Key == PublicKey } == true
}
}
@Serializable
data class TKAFilteredPeer(
var Name: String,
var TailscaleIPs: List<Addr>,
var NodeKey: String,
)
@Serializable data class TKAKey(var Key: String)
@Serializable
data class PingResult(
var IP: Addr,
var Err: String,
var LatencySeconds: Double,
)
}
class IpnLocal {
@Serializable
data class LoginProfile(
var ID: String,
val Name: String,
val Key: String,
val UserProfile: Tailcfg.UserProfile,
val NetworkProfile: Tailcfg.NetworkProfile? = null,
val LocalUserID: String,
var ControlURL: String? = null,
) {
fun isEmpty(): Boolean {
return ID.isEmpty()
}
// Returns true if the profile uses a custom control server (not Tailscale SaaS).
private fun isUsingCustomControlServer(): Boolean {
return ControlURL != null && ControlURL != "https://controlplane.tailscale.com"
}
// Returns the hostname of the custom control server, if any was set.
//
// Returns null if the ControlURL provided by the backend is an invalid URL, and
// a hostname cannot be extracted.
fun customControlServerHostname(): String? {
if (!isUsingCustomControlServer()) return null
return try {
URL(ControlURL).host
} catch (e: Exception) {
null
}
}
}
}

@ -0,0 +1,55 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.model
import kotlinx.serialization.Serializable
class Netmap {
@Serializable
data class NetworkMap(
var SelfNode: Tailcfg.Node,
var NodeKey: KeyNodePublic,
var Peers: List<Tailcfg.Node>? = null,
var Expiry: Time,
var Domain: String,
var UserProfiles: Map<String, Tailcfg.UserProfile>,
var TKAEnabled: Boolean,
var DNS: Tailcfg.DNSConfig? = null
) {
// Keys are tailcfg.UserIDs thet get stringified
// Helpers
fun currentUserProfile(): Tailcfg.UserProfile? {
return userProfile(User())
}
fun User(): UserID {
return SelfNode.User
}
fun userProfile(id: Long): Tailcfg.UserProfile? {
return UserProfiles[id.toString()]
}
fun getPeer(id: StableNodeID): Tailcfg.Node? {
if (id == SelfNode.StableID) {
return SelfNode
}
return Peers?.find { it.StableID == id }
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is NetworkMap) return false
return SelfNode == other.SelfNode &&
NodeKey == other.NodeKey &&
Peers == other.Peers &&
Expiry == other.Expiry &&
User() == other.User() &&
Domain == other.Domain &&
UserProfiles == other.UserProfiles &&
TKAEnabled == other.TKAEnabled
}
}
}

@ -0,0 +1,90 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.model
import android.Manifest
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.core.app.NotificationManagerCompat
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.accompanist.permissions.shouldShowRationale
import com.tailscale.ipn.R
object Permissions {
/** Permissions to prompt for on MainView. */
@OptIn(ExperimentalPermissionsApi::class)
val prompt: List<Pair<Permission, PermissionState>>
@Composable
get() {
val permissionStates = rememberMultiplePermissionsState(permissions = all.map { it.name })
return all.zip(permissionStates.permissions).filter { (_, state) ->
!state.status.isGranted && !state.status.shouldShowRationale
}
}
/** All permissions with granted status. */
@OptIn(ExperimentalPermissionsApi::class)
val withGrantedStatus: List<Pair<Permission, Boolean>>
@Composable
get() {
val permissionStates = rememberMultiplePermissionsState(permissions = all.map { it.name })
val result = mutableListOf<Pair<Permission, Boolean>>()
result.addAll(
all.zip(permissionStates.permissions).map { (permission, state) ->
Pair(permission, state.status.isGranted)
})
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
// On Android versions prior to 13, we have to programmatically check if notifications are
// being allowed.
val notificationsEnabled =
NotificationManagerCompat.from(LocalContext.current).areNotificationsEnabled()
result.add(
Pair(
Permission(
"",
R.string.permission_post_notifications,
R.string.permission_post_notifications_needed),
notificationsEnabled))
}
return result
}
/**
* All permissions that Tailscale requires. MainView takes care of prompting for permissions, and
* PermissionsView provides a list of permissions with corresponding statuses and a link to the
* application settings.
*
* When new permissions are needed, just add them to this list and the necessary strings to
* strings.xml and the rest should take care of itself.
*/
private val all: List<Permission> by lazy {
val result = mutableListOf<Permission>()
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
result.add(
Permission(
Manifest.permission.WRITE_EXTERNAL_STORAGE,
R.string.permission_write_external_storage,
R.string.permission_write_external_storage_needed,
))
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
result.add(
Permission(
Manifest.permission.POST_NOTIFICATIONS,
R.string.permission_post_notifications,
R.string.permission_post_notifications_needed))
}
result
}
}
data class Permission(
val name: String,
val title: Int,
val description: Int,
)

@ -0,0 +1,205 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.model
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
import java.util.Date
class Tailcfg {
@Serializable
data class ClientVersion(
var RunningLatest: Boolean? = null,
var LatestVersion: String? = null,
var UrgentSecurityUpdate: Boolean? = null,
var Notify: Boolean? = null,
var NotifyURL: String? = null,
var NotifyText: String? = null
)
@Serializable
data class UserProfile(
val ID: Long,
val DisplayName: String,
val LoginName: String,
val ProfilePicURL: String? = null,
) {
fun isTaggedDevice(): Boolean {
return LoginName == "tagged-devices"
}
}
@Serializable
data class Hostinfo(
var IPNVersion: String? = null,
var FrontendLogID: String? = null,
var BackendLogID: String? = null,
var OS: String? = null,
var OSVersion: String? = null,
var Env: String? = null,
var Distro: String? = null,
var DistroVersion: String? = null,
var DistroCodeName: String? = null,
var Desktop: Boolean? = null,
var Package: String? = null,
var DeviceModel: String? = null,
var ShareeNode: Boolean? = null,
var Hostname: String? = null,
var ShieldsUp: Boolean? = null,
var NoLogsNoSupport: Boolean? = null,
var Machine: String? = null,
var RoutableIPs: List<Prefix>? = null,
var Services: List<Service>? = null,
var Location: Location? = null,
)
@Serializable
data class Node(
var ID: NodeID,
var StableID: StableNodeID,
var Name: String,
var User: UserID,
var Sharer: UserID? = null,
var Key: KeyNodePublic,
var KeyExpiry: String,
var Machine: MachineKey,
var Addresses: List<Prefix>? = null,
var AllowedIPs: List<Prefix>? = null,
var Endpoints: List<String>? = null,
var Hostinfo: Hostinfo,
var Created: Time,
var LastSeen: Time? = null,
var Online: Boolean? = null,
var Capabilities: List<String>? = null,
var CapMap: Map<String, JsonElement?>? = null,
var ComputedName: String?,
var ComputedNameWithHost: String?
) {
val isAdmin: Boolean
get() =
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
val isMullvadNode: Boolean
get() = Name.endsWith(".mullvad.ts.net.")
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
fun connectedStrRes(nm: Netmap.NetworkMap?) =
if (connectedOrSelfNode(nm)) R.string.connected else R.string.not_connected
@Composable
fun connectedColor(nm: Netmap.NetworkMap?) =
if (connectedOrSelfNode(nm)) MaterialTheme.colorScheme.on else MaterialTheme.colorScheme.off
val nameWithoutTrailingDot = Name.trimEnd('.')
val displayAddresses: List<DisplayAddress>
get() {
var addresses = mutableListOf<DisplayAddress>()
addresses.add(DisplayAddress(nameWithoutTrailingDot))
Addresses?.let { addresses.addAll(it.map { addr -> DisplayAddress(addr) }) }
return addresses
}
val info: List<PeerSettingInfo>
get() {
val result = mutableListOf<PeerSettingInfo>()
if (Hostinfo.OS?.isNotEmpty() == true) {
result.add(
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
}
@Composable
fun expiryLabel(): String {
if (KeyExpiry == GoZeroTimeString) {
return stringResource(R.string.deviceKeyNeverExpires)
}
val expDate = TimeUtil.dateFromGoString(KeyExpiry)
val template = if (expDate > Date()) R.string.deviceKeyExpires else R.string.deviceKeyExpired
return stringResource(template, TimeUtil.keyExpiryFromGoTime(KeyExpiry).getString())
}
}
@Serializable
data class Service(var Proto: String, var Port: Int, var Description: String? = null)
@Serializable
data class NetworkProfile(var MagicDNSName: String? = null, var DomainName: String? = null)
@Serializable
data class Location(
var Country: String? = null,
var CountryCode: String? = null,
var City: String? = null,
var CityCode: String? = null,
var Priority: Int? = null
)
@Serializable
data class DNSConfig(
var Resolvers: List<DnsType.Resolver>? = null,
var Routes: Map<String, List<DnsType.Resolver>?>? = null,
var FallbackResolvers: List<DnsType.Resolver>? = null,
var Domains: List<String>? = null,
var Nameservers: List<Addr>? = null
)
}

@ -0,0 +1,36 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.model
import kotlinx.serialization.Serializable
typealias Addr = String
typealias Prefix = String
typealias NodeID = Long
typealias KeyNodePublic = String
typealias MachineKey = String
typealias UserID = Long
typealias Time = String
typealias StableNodeID = String
typealias BugReportID = String
val GoZeroTimeString = "0001-01-01T00:00:00Z"
// Represents and empty message with a single 'property' field.
class Empty {
@Serializable data class Message(val property: String = "")
}
// Parsable errors returned by localApiService
class Errors {
@Serializable data class GenericError(val error: String)
}

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

@ -0,0 +1,116 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
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
// a single Notifier per instance of your application which lasts for the lifetime of
// the process.
//
// The primary entry point here is watchIPNBus which will start a watcher on the IPN bus
// and return you the session Id. When you are done with your watcher, you must call
// unwatchIPNBus with the sessionId.
object Notifier {
private val TAG = Notifier::class.simpleName
private val decoder = Json { ignoreUnknownKeys = true }
// General IPN Bus State
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)
val tailFSShares: StateFlow<Map<String, String>?> = MutableStateFlow(null)
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)
val incomingFiles: StateFlow<List<Ipn.PartialFile>?> = MutableStateFlow(null)
val filesWaiting: StateFlow<Empty.Message?> = MutableStateFlow(null)
private lateinit var app: libtailscale.Application
private var manager: libtailscale.NotificationManager? = null
@JvmStatic
fun setApp(newApp: libtailscale.Application) {
app = newApp
}
@OptIn(ExperimentalSerializationApi::class)
fun start(scope: CoroutineScope) {
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 or
NotifyWatchOpt.InitialHealthState.value
manager =
app.watchNotifications(mask.toLong()) { notification ->
val notify = decoder.decodeFromStream<Notify>(notification.inputStream())
notify.State?.let { state.set(Ipn.State.fromInt(it)) }
notify.NetMap?.let(netmap::set)
notify.Prefs?.let(prefs::set)
notify.Engine?.let(engineStatus::set)
notify.TailFSShares?.let(tailFSShares::set)
notify.BrowseToURL?.let(browseToURL::set)
notify.LoginFinished?.let { loginFinished.set(it.property) }
notify.Version?.let(version::set)
notify.OutgoingFiles?.let(outgoingFiles::set)
notify.FilesWaiting?.let(filesWaiting::set)
notify.IncomingFiles?.let(incomingFiles::set)
notify.Health?.let(health::set)
}
}
}
fun stop() {
TSLog.d(TAG, "Stopping Notifier")
manager?.let {
it.stop()
manager = null
}
}
// NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which
// what we want to see on the Notify bus
private enum class NotifyWatchOpt(val value: Int) {
EngineUpdates(1),
InitialState(2),
Prefs(4),
Netmap(8),
NoPrivateKey(16),
InitialTailFSShares(32),
InitialOutgoingFiles(64),
InitialHealthState(128),
}
fun setState(newState: Ipn.State) {
_state.value = newState
}
}

@ -0,0 +1,9 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.theme
import androidx.compose.ui.graphics.Color
// TODO: replace references to these with references to material theme
val ts_color_light_blue = Color(0xFF4B70CC)

@ -0,0 +1,467 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItemColors
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextFieldDefaults
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
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import com.google.accompanist.systemuicontroller.rememberSystemUiController
@Composable
fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
val colors =
if (useDarkTheme) {
DarkColors
} else {
LightColors
}
val typography =
Typography(
// titleMedium is styled to be slightly larger than bodyMedium for emphasis
titleMedium =
MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp, lineHeight = 26.sp),
// bodyMedium is styled to use same line height as titleMedium to ensure even vertical
// margins in list items.
bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp))
// TODO: Migrate to Activity.enableEdgeToEdge
@Suppress("deprecation") val systemUiController = rememberSystemUiController()
DisposableEffect(systemUiController, useDarkTheme) {
systemUiController.setStatusBarColor(color = colors.surfaceContainer)
systemUiController.setNavigationBarColor(color = Color.Black)
onDispose {}
}
MaterialTheme(colorScheme = colors, typography = typography, content = content)
}
private val LightColors =
lightColorScheme(
primary = Color(0xFF4B70CC), // blue-500
onPrimary = Color(0xFFFFFFFF), // white
primaryContainer = Color(0xFFF0F5FF), // blue-0
onPrimaryContainer = Color(0xFF3E5DB3), // blue-600
error = Color(0xFFB22C30), // red-500
onError = Color(0xFFFFFFFF), // white
errorContainer = Color(0xFFFEF6F3), // red-0
onErrorContainer = Color(0xFF930921), // red-600
surfaceDim = Color(0xFFF7F5F4), // gray-100
surface = Color(0xFFFFFFFF), // white,
background = Color(0xFFF7F5F4), // gray-100
surfaceBright = Color(0xFFFFFFFF), // white
surfaceContainerLowest = Color(0xFFFFFFFF), // white
surfaceContainerLow = Color(0xFFF7F5F4), // gray-100
surfaceContainer = Color(0xFFF7F5F4), // gray-100
surfaceContainerHigh = Color(0xFFF7F5F4), // gray-100
surfaceContainerHighest = Color(0xFFF7F5F4), // gray-100
surfaceVariant = Color(0xFFF7F5F4), // gray-100,
onSurface = Color(0xFF232222), // gray-800
onSurfaceVariant = Color(0xFF706E6D), // gray-500
outline = Color(0xFF706E6D), // gray-500
outlineVariant = Color(0xFFEDEBEA), // gray-200
inverseSurface = Color(0xFF232222), // gray-800
inverseOnSurface = Color(0xFFFFFFFF), // white
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
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFFBB5504) // yellow-400
} else {
Color(0xFFD97917) // yellow-300
}
val ColorScheme.onWarning: Color
get() = Color(0xFFFFFFFF) // white
val ColorScheme.warningContainer: Color
get() = Color(0xFFFFFAEE) // orange-0
val ColorScheme.onWarningContainer: Color
get() = Color(0xFF7E1E22) // orange-600
val ColorScheme.success: Color
get() = Color(0xFF0A825D) // green-400
val ColorScheme.onSuccess: Color
get() = Color(0xFFFFFFFF) // white
val ColorScheme.successContainer: Color
get() = Color(0xFFEFFEEC) // green-0
val ColorScheme.onSuccessContainer: Color
get() = Color(0xFF0E4B3B) // green-600
val ColorScheme.on: Color
get() = Color(0xFF1CA672) // green-300
val ColorScheme.off: Color
@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.
*/
val ColorScheme.listItem: ListItemColors
@Composable
get() {
val default = ListItemDefaults.colors()
return ListItemColors(
containerColor = default.containerColor,
headlineColor = default.headlineColor,
leadingIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
overlineColor = default.overlineColor,
supportingTextColor = default.supportingTextColor,
trailingIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
disabledHeadlineColor = default.disabledHeadlineColor,
disabledLeadingIconColor = default.disabledLeadingIconColor,
disabledTrailingIconColor = default.disabledTrailingIconColor)
}
/** Like listItem, but with the overline content using the onSurface color. */
val ColorScheme.titledListItem: ListItemColors
@Composable
get() {
val default = listItem
return ListItemColors(
containerColor = default.containerColor,
headlineColor = default.headlineColor,
leadingIconColor = default.leadingIconColor,
overlineColor = MaterialTheme.colorScheme.onSurface,
supportingTextColor = default.supportingTextColor,
trailingIconColor = default.trailingIconColor,
disabledHeadlineColor = default.disabledHeadlineColor,
disabledLeadingIconColor = default.disabledLeadingIconColor,
disabledTrailingIconColor = default.disabledTrailingIconColor)
}
/** Color scheme for disabled list items. */
val ColorScheme.disabledListItem: ListItemColors
@Composable
get() {
val default = ListItemDefaults.colors()
return ListItemColors(
containerColor = default.containerColor,
headlineColor = MaterialTheme.colorScheme.disabled,
leadingIconColor = default.leadingIconColor,
overlineColor = default.overlineColor,
supportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
trailingIconColor = default.trailingIconColor,
disabledHeadlineColor = default.disabledHeadlineColor,
disabledLeadingIconColor = default.disabledLeadingIconColor,
disabledTrailingIconColor = default.disabledTrailingIconColor)
}
/** Color scheme for list items that should be styled as a surface container. */
val ColorScheme.surfaceContainerListItem: ListItemColors
@Composable
get() {
val default = ListItemDefaults.colors()
return ListItemColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
headlineColor = MaterialTheme.colorScheme.onSurface,
leadingIconColor = MaterialTheme.colorScheme.onSurface,
overlineColor = MaterialTheme.colorScheme.onSurfaceVariant,
supportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
trailingIconColor = MaterialTheme.colorScheme.onSurface,
disabledHeadlineColor = default.disabledHeadlineColor,
disabledLeadingIconColor = default.disabledLeadingIconColor,
disabledTrailingIconColor = default.disabledTrailingIconColor)
}
/** Color scheme for list items that should be styled as a primary item. */
val ColorScheme.primaryListItem: ListItemColors
@Composable
get() {
val default = ListItemDefaults.colors()
return ListItemColors(
containerColor = MaterialTheme.colorScheme.primary,
headlineColor = MaterialTheme.colorScheme.onPrimary,
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
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 a warning item. */
val ColorScheme.warningListItem: ListItemColors
@Composable
get() {
val default = ListItemDefaults.colors()
return 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)
}
/** 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,
disabledLeadingIconColor = default.disabledLeadingIconColor,
disabledTrailingIconColor = default.disabledTrailingIconColor)
}
/** Main color scheme for top app bar, styles it as a surface container. */
@OptIn(ExperimentalMaterial3Api::class)
val ColorScheme.topAppBar: TopAppBarColors
@Composable
get() =
TopAppBarDefaults.topAppBarColors()
.copy(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
)
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(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
@OptIn(ExperimentalMaterial3Api::class)
val ColorScheme.searchBarColors: TextFieldColors
@Composable
get() {
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.surfaceContainer,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
disabledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent)
}
val TextStyle.short: TextStyle
get() = copy(lineHeight = 20.sp)
val Typography.minTextSize: TextUnit
get() = 10.sp

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

@ -0,0 +1,20 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import com.tailscale.ipn.BuildConfig
class AppVersion {
companion object {
// Returns the short version of the build version, which is what users typically expect.
// For instance, if the build version is "1.75.80-t8fdffb8da-g2daeee584df",
// this function returns "1.75.80".
fun Short(): String {
// Split the full version string by hyphen (-)
val parts = BuildConfig.VERSION_NAME.split("-")
// Return only the part before the first hyphen
return parts[0]
}
}
}

@ -0,0 +1,78 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
// AutoResizingText automatically resizes text up to the specified minFontSize in order to avoid
// overflowing. It is based on https://stackoverflow.com/a/66090448 licensed under CC BY-SA 4.0.
@Composable
fun AutoResizingText(
text: String,
minFontSize: TextUnit,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = 1,
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
style: TextStyle = LocalTextStyle.current
) {
var textStyle = remember { mutableStateOf(style) }
var textOverflow = remember { mutableStateOf(TextOverflow.Clip) }
var readyToDraw = remember { mutableStateOf(false) }
Text(
text = text,
modifier = modifier.drawWithContent { if (readyToDraw.value) drawContent() },
color = color,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
textAlign = textAlign,
lineHeight = lineHeight,
overflow = textOverflow.value,
maxLines = maxLines,
softWrap = false,
style = textStyle.value,
onTextLayout = { result ->
if (result.didOverflowWidth) {
var newSize = textStyle.value.fontSize * 0.9
if (newSize < minFontSize) {
newSize = minFontSize
textOverflow.value = overflow
}
textStyle.value = textStyle.value.copy(fontSize = newSize)
} else {
readyToDraw.value = true
}
onTextLayout?.let { it(result) }
})
}

@ -0,0 +1,61 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
@Composable
fun ClipboardValueView(value: String, title: String? = null, subtitle: String? = null) {
val isFocused = remember { mutableStateOf(false) }
val localClipboardManager = LocalClipboardManager.current
val interactionSource = remember { MutableInteractionSource() }
ListItem(
modifier = Modifier
.focusable(interactionSource = interactionSource)
.onFocusChanged { focusState -> isFocused.value = focusState.isFocused }
.clickable(
interactionSource = interactionSource,
indication = LocalIndication.current
) { localClipboardManager.setText(AnnotatedString(value)) }
.background(
if (isFocused.value) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
else Color.Transparent
),
overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } },
headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) },
supportingContent = subtitle?.let {
{ Text(it, modifier = Modifier.padding(top = 8.dp), style = MaterialTheme.typography.bodyMedium) }
},
trailingContent = {
Icon(
painterResource(R.drawable.clipboard),
contentDescription = stringResource(R.string.copy_to_clipboard),
modifier = Modifier.size(24.dp)
)
}
)
}

@ -0,0 +1,22 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.tailscale.ipn.R
// Convenience wrapper for passing formatted strings to Composables
class ComposableStringFormatter(
@StringRes val stringRes: Int = R.string.template,
private vararg val params: Any
) {
// Convenience constructor for passing a non-formatted string directly
constructor(string: String) : this(stringRes = R.string.template, string)
// Returns the fully formatted string
@Composable fun getString(): String = stringResource(id = stringRes, *params)
}

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

@ -0,0 +1,46 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
class DisplayAddress(ip: String) {
enum class addrType {
V4,
V6,
MagicDNS
}
val type: addrType =
when {
ip.isIPV6() -> addrType.V6
ip.isIPV4() -> addrType.V4
else -> addrType.MagicDNS
}
val typeString: String =
when (type) {
addrType.V4 -> "IPv4"
addrType.V6 -> "IPv6"
addrType.MagicDNS -> "MagicDNS"
}
val address: String =
when (type) {
addrType.MagicDNS -> ip
else -> ip.split("/").first()
}
}
fun String.isIPV6(): Boolean {
return this.contains(":")
}
fun String.isIPV4(): Boolean {
val parts = this.split("/").first().split(".")
if (parts.size != 4) return false
for (part in parts) {
val value = part.toIntOrNull() ?: return false
if (value !in 0..255) return false
}
return true
}

@ -0,0 +1,33 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
/**
* Code adapted from
* https://github.com/piashcse/Compose-museum/blob/master/app/src/main/java/com/piashcse/compose_museum/screens/CountryList.kt#L75
*/
// Copyright 2023 piashcse (Mehedi Hassan Piash)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/** Flag turns an ISO3166 country code into a flag emoji. */
fun String.flag(): String {
val caps = this.uppercase()
val flagOffset = 0x1F1E6
val asciiOffset = 0x41
val firstChar = Character.codePointAt(caps, 0) - asciiOffset + flagOffset
val secondChar = Character.codePointAt(caps, 1) - asciiOffset + flagOffset
return String(Character.toChars(firstChar)) + String(Character.toChars(secondChar))
}

@ -0,0 +1,21 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import java.io.InputStream
class InputStreamAdapter(private val inputStream: InputStream) : libtailscale.InputStream {
override fun read(): ByteArray? {
val b = ByteArray(4096)
val i = inputStream.read(b)
if (i == -1) {
return null
}
return b.sliceArray(0 ..< i)
}
override fun close() {
inputStream.close()
}
}

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

@ -0,0 +1,125 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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
import androidx.compose.ui.unit.dp
object Lists {
@Composable
fun SectionDivider(title: String? = null) {
Box(Modifier.size(0.dp, 16.dp))
title?.let { LargeTitle(title) }
}
@Composable
fun ItemDivider() {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
}
@Composable
fun LargeTitle(
title: String,
bottomPadding: Dp = 0.dp,
style: TextStyle = MaterialTheme.typography.titleMedium,
fontWeight: FontWeight? = null,
focusable: Boolean = false
) {
Box(
modifier =
Modifier.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.surface, shape = RectangleShape)) {
Text(
title,
modifier =
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>,
noinline key: ((item: T) -> Any)? = null,
forceLeading: Boolean = false,
crossinline contentType: (item: T) -> Any? = { _ -> null },
crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
) =
items(
count = items.size,
key = if (key != null) { index: Int -> key(items[index]) } else null,
contentType = { index -> contentType(items[index]) }) {
if (forceLeading && it == 0 || it > 0 && it < items.size) {
Lists.ItemDivider()
}
itemContent(items[it])
}
inline fun <T> LazyListScope.itemsWithDividers(
items: Array<T>,
noinline key: ((item: T) -> Any)? = null,
forceLeading: Boolean = false,
crossinline contentType: (item: T) -> Any? = { _ -> null },
crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
) = itemsWithDividers(items.toList(), key, forceLeading, contentType, itemContent)

@ -0,0 +1,65 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
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
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
object LoadingIndicator {
private val loading = MutableStateFlow(false)
fun start() {
loading.value = true
}
fun stop() {
loading.value = false
}
@Composable
fun Wrap(content: @Composable () -> Unit) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
content()
val isLoading by loading.collectAsState()
if (isLoading) {
Box(Modifier.clickable {}.matchParentSize().background(Color.Gray.copy(alpha = 0.0f)))
val showSpinner: State<Boolean> =
produceState(initialValue = false) {
delay(300)
value = true
}
if (showSpinner.value) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally) {
TailscaleLogoView(
true, usesOnBackgroundColors = false, Modifier.size(72.dp).alpha(0.4f))
}
}
}
}
}
}

@ -0,0 +1,129 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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
data class PeerSet(val user: Tailcfg.UserProfile?, val peers: List<Tailcfg.Node>)
class PeerCategorizer {
var peerSets: List<PeerSet> = emptyList()
var lastSearchResult: List<PeerSet> = emptyList()
var lastSearchTerm: String = ""
fun regenerateGroupedPeers(netmap: Netmap.NetworkMap) {
val peers: List<Tailcfg.Node> = netmap.Peers ?: return
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)) {
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
}
if (!grouped.containsKey(userId)) {
grouped[userId] = mutableListOf()
}
grouped[userId]?.add(peer)
}
peerSets =
grouped
.map { (userId, peers) ->
val profile = netmap.userProfile(userId)
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) {
""
} else {
it.user?.DisplayName?.lowercase() ?: "unknown user"
}
}
}
fun groupedAndFilteredPeers(searchTerm: String = ""): List<PeerSet> {
if (searchTerm.isEmpty()) {
return peerSets
}
if (searchTerm == this.lastSearchTerm) {
return lastSearchResult
}
// We can optimize out typing... If the search term starts with the last search term, we can
// just search the last result
val setsToSearch =
if (this.lastSearchTerm.isNotEmpty() && searchTerm.startsWith(this.lastSearchTerm))
lastSearchResult
else peerSets
this.lastSearchTerm = searchTerm
val matchingSets =
setsToSearch
.map { peerSet ->
val user = peerSet.user
val peers = peerSet.peers
val userMatches = user?.DisplayName?.contains(searchTerm, ignoreCase = true) ?: false
if (userMatches) {
return@map peerSet
}
val matchingPeers =
peers.filter {
it.displayName.contains(searchTerm, ignoreCase = true) ||
(it.Addresses ?: emptyList()).fastAny { addr -> addr.contains(searchTerm) }
}
if (matchingPeers.isNotEmpty()) {
PeerSet(user, matchingPeers)
} else {
null
}
}
.filterNotNull()
lastSearchResult = matchingSets
return matchingSets
}
}

@ -0,0 +1,12 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/** Provides a way to expose a MutableStateFlow as an immutable StateFlow. */
fun <T> StateFlow<T>.set(v: T) {
(this as MutableStateFlow<T>).value = v
}

@ -0,0 +1,124 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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)
val expTime = epochMillisFromGoTime(time)
val now = Instant.now().toEpochMilli()
var diff = (expTime - now) / 1000
// Rather than use plurals here, we'll just use the singular form for everything and
// double the minimum. "in 70 minutes" instead of "in 1 hour". 121 minutes becomes
// 2 hours, as does 179 minutes... Close enough for what this is used for.
// Key is already expired (x minutes ago)
if (diff < 0) {
diff = -diff
return when (diff) {
in 0..60 -> ComposableStringFormatter(R.string.under_a_minute)
in 61..7200 ->
ComposableStringFormatter(R.string.ago_x_minutes, diff / 60) // 1 minute to 1 hour
in 7201..172800 ->
ComposableStringFormatter(R.string.ago_x_hours, diff / 3600) // 2 hours to 24 hours
in 172801..5184000 ->
ComposableStringFormatter(R.string.ago_x_days, diff / 86400) // 2 Days to 60 days
in 5184001..124416000 ->
ComposableStringFormatter(R.string.ago_x_months, diff / 2592000) // ~2 months to 2 years
else ->
ComposableStringFormatter(
R.string.ago_x_years,
diff.toDouble() / 31536000.0) // 2 years to n years (in decimal)
}
}
// Key is not expired (in x minutes)
return when (diff) {
in 0..60 -> ComposableStringFormatter(R.string.under_a_minute)
in 61..7200 ->
ComposableStringFormatter(R.string.in_x_minutes, diff / 60) // 1 minute to 1 hour
in 7201..172800 ->
ComposableStringFormatter(R.string.in_x_hours, diff / 3600) // 2 hours to 24 hours
in 172801..5184000 ->
ComposableStringFormatter(R.string.in_x_days, diff / 86400) // 2 Days to 60 days
in 5184001..124416000 ->
ComposableStringFormatter(R.string.in_x_months, diff / 2592000) // ~2 months to 2 years
else ->
ComposableStringFormatter(
R.string.in_x_years, diff.toDouble() / 31536000.0) // 2 years to n years (in decimal)
}
}
fun epochMillisFromGoTime(goTime: String): Long {
val ta = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(goTime)
val i = Instant.from(ta)
return i.toEpochMilli()
}
fun dateFromGoString(goTime: String): Date {
val ta = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(goTime)
val i = Instant.from(ta)
return Date.from(i)
}
// 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 < 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())
}
}

@ -0,0 +1,104 @@
// 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.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.draw.clip
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
import com.tailscale.ipn.ui.util.AppVersion
@Composable
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)
.verticalScroll(rememberScrollState())) {
TailscaleLogoView(
usesOnBackgroundColors = true,
modifier =
Modifier.width(100.dp)
.height(100.dp)
.clip(RoundedCornerShape(50))
.background(MaterialTheme.colorScheme.logoBackground)
.padding(25.dp))
Column(
verticalArrangement =
Arrangement.spacedBy(space = 2.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally) {
Text(
stringResource(R.string.about_view_title),
fontWeight = FontWeight.SemiBold,
fontSize = MaterialTheme.typography.titleLarge.fontSize)
Text(
modifier =
Modifier.clickable {
// When users tap on the version number, the extended version string
// (including commit hashes) is copied to the clipboard.
// This may be useful for debugging purposes...
localClipboardManager.setText(AnnotatedString(BuildConfig.VERSION_NAME))
},
// ... but we always display the short version in the UI to avoid user
// confusion.
text = "${stringResource(R.string.version)} ${AppVersion.Short()}",
fontWeight = MaterialTheme.typography.bodyMedium.fontWeight,
fontSize = MaterialTheme.typography.bodyMedium.fontSize)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
OpenURLButton(stringResource(R.string.acknowledgements), Links.LICENSES_URL)
OpenURLButton(stringResource(R.string.privacy_policy), Links.PRIVACY_POLICY_URL)
OpenURLButton(stringResource(R.string.terms_of_service), Links.TERMS_URL)
}
Text(
stringResource(R.string.about_view_footnotes),
fontWeight = FontWeight.Normal,
fontSize = MaterialTheme.typography.labelMedium.fontSize,
textAlign = TextAlign.Center)
}
}
}
@Preview
@Composable
fun AboutPreview() {
AboutView({})
}

@ -0,0 +1,95 @@
// 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.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
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.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
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)
@Composable
fun Avatar(
profile: IpnLocal.LoginProfile?,
size: Int = 50,
action: (() -> Unit)? = null,
isFocusable: Boolean = false
) {
var isFocused = remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
// Outer Box for the larger focusable and clickable area
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(4.dp)
.size((size * 1.5f).dp) // Focusable area is larger than the avatar
.clip(CircleShape) // Ensure both the focus and click area are circular
.background(
if (isFocused.value) MaterialTheme.colorScheme.surface
else Color.Transparent,
)
.onFocusChanged { focusState ->
isFocused.value = focusState.isFocused
}
.focusable() // Make this outer Box focusable (after onFocusChanged)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = true), // Apply ripple effect inside circular bounds
onClick = {
action?.invoke()
focusManager.clearFocus() // Clear focus after clicking the avatar
}
)
) {
// Inner Box to hold the avatar content (Icon or AsyncImage)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(size.dp)
.clip(CircleShape)
) {
// Always display the default icon as a background layer
Icon(
imageVector = Icons.Default.Person,
contentDescription = stringResource(R.string.settings_title),
modifier =
Modifier.size((size * 0.8f).dp)
.clip(CircleShape) // Icon size slightly smaller than the Box
)
// Overlay the profile picture if available
profile?.UserProfile?.ProfilePicURL?.let { url ->
AsyncImage(
model = url,
modifier = Modifier.size(size.dp).clip(CircleShape),
contentDescription = null)
}
}
}
}

@ -0,0 +1,94 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
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.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
import androidx.compose.ui.text.AnnotatedString
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(backToSettings: BackNavigation, model: BugReportViewModel = viewModel()) {
val handler = LocalUriHandler.current
val bugReportID by model.bugReportID.collectAsState()
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))
Lists.InfoItem(stringResource(id = R.string.bug_report_id_desc))
}
}
}
@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(
style =
SpanStyle(
color = MaterialTheme.colorScheme.link,
textDecoration = TextDecoration.Underline)) {
append(stringResource(id = R.string.bug_report_instructions_linktext))
}
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)
}

@ -0,0 +1,41 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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.material3.Button
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.LocalUriHandler
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.theme.link
@Composable
fun PrimaryActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) {
Button(
onClick = onClick,
contentPadding = PaddingValues(vertical = 12.dp),
modifier = Modifier.fillMaxWidth(),
content = content)
}
@Composable
fun OpenURLButton(title: String, url: String) {
val handler = LocalUriHandler.current
TextButton(onClick = { handler.openUri(url) }) {
Text(
title,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.link,
textDecoration = TextDecoration.Underline,
)
}
}

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

@ -0,0 +1,116 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
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.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
data class ViewableRoute(val name: String, val resolvers: List<DnsType.Resolver>)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DNSSettingsView(
backToSettings: BackNavigation,
model: DNSSettingsViewModel = viewModel(factory = DNSSettingsViewModelFactory())
) {
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> =
model.dnsConfig.collectAsState().value?.Routes?.mapNotNull { entry ->
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 = backToSettings) }) { innerPadding ->
LoadingIndicator.Wrap {
LazyColumn(Modifier.padding(innerPadding)) {
item("state") {
ListItem(
leadingContent = {
Icon(
painter = painterResource(state.symbolDrawable),
contentDescription = null,
tint = state.tint(),
modifier = Modifier.size(36.dp))
},
headlineContent = {
Text(stringResource(state.title), style = MaterialTheme.typography.titleMedium)
},
supportingContent = { Text(stringResource(state.caption)) })
if (!dnsSettingsMDMDisposition.value.hiddenFromUser) {
Lists.ItemDivider()
Setting.Switch(
R.string.use_ts_dns,
isOn = useCorpDNS,
onToggle = {
LoadingIndicator.start()
model.toggleCorpDNS { LoadingIndicator.stop() }
})
}
}
if (resolvers.isNotEmpty()) {
item("resolversHeader") { Lists.SectionDivider(stringResource(R.string.resolvers)) }
itemsWithDividers(resolvers) { resolver -> ClipboardValueView(resolver.Addr.orEmpty()) }
}
if (domains.isNotEmpty()) {
item("domainsHeader") { Lists.SectionDivider(stringResource(R.string.search_domains)) }
itemsWithDividers(domains) { domain -> ClipboardValueView(domain) }
}
if (routes.isNotEmpty()) {
routes.forEach { route ->
item { Lists.SectionDivider("Route: ${route.name}") }
itemsWithDividers(route.resolvers) { resolver ->
ClipboardValueView(resolver.Addr.orEmpty())
}
}
}
}
}
}
}
@Preview
@Composable
fun DNSSettingsViewPreview() {
val vm = DNSSettingsViewModel()
vm.enablementState.set(DNSEnablementState.ENABLED)
DNSSettingsView(backToSettings = {}, vm)
}

@ -0,0 +1,82 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.annotation.StringRes
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 {
INVALID_CUSTOM_URL,
LOGOUT_FAILED,
SWITCH_USER_FAILED,
ADD_PROFILE_FAILED,
SHARE_DEVICE_NOT_CONNECTED,
SHARE_FAILED,
INVALID_AUTH_KEY;
val message: Int
get() {
return when (this) {
INVALID_CUSTOM_URL -> R.string.invalidCustomUrl
LOGOUT_FAILED -> R.string.logout_failed
SWITCH_USER_FAILED -> R.string.switch_user_failed
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
}
}
val title: Int
get() {
return when (this) {
INVALID_CUSTOM_URL -> R.string.invalidCustomURLTitle
LOGOUT_FAILED -> R.string.logout_failed_title
SWITCH_USER_FAILED -> R.string.switch_user_failed_title
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
}
}
val buttonText: Int = R.string.ok
}
@Composable
fun ErrorDialog(type: ErrorDialogType, action: () -> Unit = {}) {
ErrorDialog(
title = type.title, message = type.message, buttonText = type.buttonText, onDismiss = action)
}
@Composable
fun ErrorDialog(
@StringRes title: Int = R.string.error,
@StringRes message: Int,
@StringRes buttonText: Int = R.string.ok,
onDismiss: () -> Unit = {}
) {
AppTheme {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(id = title)) },
text = { Text(text = stringResource(id = message)) },
confirmButton = {
PrimaryActionButton(onClick = onDismiss) { Text(text = stringResource(id = buttonText)) }
})
}
}
@Preview
@Composable
fun ErrorDialogPreview() {
ErrorDialog(ErrorDialogType.LOGOUT_FAILED)
}

@ -0,0 +1,225 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Check
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.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
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.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(
nav: ExitNodePickerNav,
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) {
LoadingIndicator.Wrap {
Scaffold(topBar = { Header(R.string.choose_exit_node, onBack = nav.onNavigateBackHome) }) {
innerPadding ->
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 = MutableStateFlow(true),
selected = !anyActive,
))
}
if (showRunAsExitNode.value == ShowHide.Show) {
Lists.ItemDivider()
RunAsExitNodeItem(nav = nav, viewModel = model, anyActive)
}
}
item(key = "divider1") { Lists.SectionDivider() }
itemsWithDividers(tailnetExitNodes, key = { it.id!! }) { node -> ExitNodeItem(model, node) }
if (mullvadExitNodeCount > 0) {
item(key = "mullvad") {
Lists.SectionDivider()
MullvadItem(
nav, mullvadExitNodesByCountryCode.size, mullvadExitNodesByCountryCode.selected)
}
} else if (shouldShowMullvadInfo) {
item(key = "mullvad_info") {
Lists.SectionDivider()
MullvadInfoItem(nav)
}
}
if (!allowLanAccessMDMDisposition.value.hiddenFromUser) {
item(key = "allowLANAccess") {
Lists.SectionDivider()
Setting.Switch(R.string.allow_lan_access, isOn = allowLANAccess) {
LoadingIndicator.start()
model.toggleAllowLANAccess { LoadingIndicator.stop() }
}
}
}
}
}
}
}
@Composable
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 (online && !isRunningExitNode && forcedExitNodeId == null) {
modifier = modifier.clickable { viewModel.setExitNode(node) }
}
ListItem(
modifier = modifier,
colors =
if (online && !isRunningExitNode) MaterialTheme.colorScheme.listItem
else MaterialTheme.colorScheme.disabledListItem,
headlineContent = {
Text(node.city.ifEmpty { node.label }, style = MaterialTheme.typography.bodyMedium)
},
supportingContent = {
if (!online)
Text(stringResource(R.string.offline), style = MaterialTheme.typography.bodyMedium)
},
trailingContent = {
Row {
if (node.selected) {
Icon(Icons.Outlined.Check, null)
}
}
})
}
}
@Composable
fun MullvadItem(nav: ExitNodePickerNav, count: Int, selected: Boolean) {
Box {
ListItem(
modifier = Modifier.clickable { nav.onNavigateToMullvad() },
headlineContent = {
Text(
stringResource(R.string.mullvad_exit_nodes),
style = MaterialTheme.typography.bodyMedium)
},
supportingContent = {
Text(
"$count ${stringResource(R.string.countries)}",
style = MaterialTheme.typography.bodyMedium)
},
trailingContent = {
if (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,
anyActive: Boolean
) {
val isRunningExitNode = viewModel.isRunningExitNode.collectAsState().value
Box {
var modifier: Modifier = Modifier
if (!anyActive) {
modifier = modifier.clickable { nav.onNavigateToRunAsExitNode() }
}
ListItem(
modifier = modifier,
colors =
if (!anyActive) MaterialTheme.colorScheme.listItem
else MaterialTheme.colorScheme.disabledListItem,
headlineContent = {
Text(
stringResource(id = R.string.run_as_exit_node),
style = MaterialTheme.typography.bodyMedium)
},
supportingContent = {
if (isRunningExitNode) {
Text(stringResource(R.string.enabled))
} else {
Text(stringResource(R.string.disabled))
}
})
}
}

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

@ -0,0 +1,68 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
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
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.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) {
Column(
modifier = Modifier.fillMaxHeight().fillMaxWidth().verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center) {
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),
text = stringResource(R.string.welcome1),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center)
Button(onClick = onContinue) {
Text(
text = stringResource(id = R.string.getStarted),
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),
contentAlignment = Alignment.BottomCenter) {
Text(
text = stringResource(R.string.welcome2),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center)
}
}
}
@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) }
}

@ -0,0 +1,61 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
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.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSetting
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.IpnViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
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 ->
MDMSettingView(setting)
}
}
}
}
@Composable
fun MDMSettingView(setting: MDMSetting<*>) {
val value by setting.flow.collectAsState()
ListItem(
headlineContent = { Text(setting.localizedTitle, maxLines = 3) },
supportingContent = {
Text(
setting.key,
fontSize = MaterialTheme.typography.labelSmall.fontSize,
fontFamily = FontFamily.Monospace)
},
trailingContent = {
Text(
if (value.isSet) value.value.toString() else "[not set]",
fontFamily = FontFamily.Monospace,
maxLines = 1,
fontWeight = FontWeight.SemiBold)
})
}

@ -0,0 +1,813 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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
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.fillMaxSize
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.layout.statusBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.Lock
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.Scaffold
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
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.saveable.rememberSaveable
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.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
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 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
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.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 onNavigateToHealth: () -> Unit
)
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable
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) {
// 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 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 = {
if (!hideHeader) {
TintedSwitch(
onCheckedChange = {
if (!disableToggle.value) {
viewModel.toggleVpn()
}
},
enabled = !disableToggle.value,
checked = isOn)
}
},
headlineContent = {
user?.NetworkProfile?.DomainName?.let { domain ->
AutoResizingText(
text = domain,
style = MaterialTheme.typography.titleMedium.short,
minFontSize = MaterialTheme.typography.minTextSize,
overflow = TextOverflow.Ellipsis)
}
},
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.padding(8.dp), contentAlignment = Alignment.CenterEnd) {
when (user) {
null -> SettingsButton { navigation.onNavigateToSettings() }
else -> {
Avatar(profile = user, size = 36, { navigation.onNavigateToSettings() }, isFocusable=true)
}
}
}
})
when (state) {
Ipn.State.Running -> {
PromptPermissionsIfNecessary()
viewModel.showVPNPermissionLauncherIfUnauthorized()
if (showKeyExpiry) {
ExpiryNotification(netmap = netmap, action = { viewModel.login() })
}
if (showExitNodePicker.value == ShowHide.Show) {
ExitNodeStatus(
navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
}
PeerList(
viewModel = viewModel,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { viewModel.searchPeers(it) })
}
Ipn.State.NoState,
Ipn.State.Starting -> StartingView()
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)
}
}
}
}
}
@Composable
fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
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.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp)
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
.fillMaxWidth()) {
ListItem(
modifier = Modifier.clickable { navAction() },
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(
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,
)
},
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text =
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)
Icon(
imageVector = Icons.Outlined.ArrowDropDown,
contentDescription = null,
tint =
if (nodeState == NodeState.NONE)
MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
)
}
},
trailingContent = {
if (nodeState != NodeState.NONE) {
Button(
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)
})
}
}
})
}
}
}
@Composable
fun SettingsButton(action: () -> Unit) {
IconButton(modifier = Modifier.size(24.dp), onClick = { action() }) {
Icon(
Icons.Outlined.Settings,
contentDescription = "Open settings",
tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
fun StartingView() {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
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,
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(
modifier = Modifier.padding(8.dp).fillMaxWidth(0.7f).fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
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,
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.disabled)
Text(
text = stringResource(id = R.string.not_connected),
fontSize = MaterialTheme.typography.titleMedium.fontSize,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
fontFamily = MaterialTheme.typography.titleMedium.fontFamily)
val tailnetName = user.NetworkProfile?.DomainName ?: ""
Text(
buildAnnotatedString {
append(stringResource(id = R.string.connect_to_tailnet_prefix))
pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
append(tailnetName)
pop()
append(stringResource(id = R.string.connect_to_tailnet_suffix))
},
fontSize = MaterialTheme.typography.titleMedium.fontSize,
fontWeight = FontWeight.Normal,
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 {
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.login_to_join_your_tailnet),
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Center)
Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = loginAction) {
Text(
text = stringResource(id = R.string.log_in),
fontSize = MaterialTheme.typography.titleMedium.fontSize)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun PeerList(
viewModel: MainViewModel,
onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
onSearch: (String) -> Unit
) {
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()
Column(modifier = Modifier.fillMaxSize()) {
if (enableSearch) {
SearchWithDynamicSuggestions(viewModel, onSearch)
Spacer(modifier = Modifier.height(if (showNoResults) 0.dp else 8.dp))
}
// Peers display
LazyColumn(
modifier =
Modifier.fillMaxWidth()
.weight(1f) // LazyColumn gets the remaining vertical space
.onFocusChanged { isListFocussed = it.isFocused }
.background(color = MaterialTheme.colorScheme.surface)) {
// Handle case when no results are found
if (showNoResults) {
item {
Spacer(
Modifier.height(16.dp)
.fillMaxSize()
.focusable(false)
.background(color = MaterialTheme.colorScheme.surface))
Lists.LargeTitle(
stringResource(id = R.string.no_results),
bottomPadding = 8.dp,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Light)
}
}
// Iterate over peer sets to display them
var first = true
peerList.forEach { peerSet ->
if (!first) {
item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() }
}
first = false
if (isAndroidTV()) {
item { NodesSectionHeader(peerSet = peerSet) }
} else {
stickyHeader { NodesSectionHeader(peerSet = peerSet) }
}
itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer ->
ListItem(
modifier =
Modifier.combinedClickable(
onClick = { onNavigateToPeerDetails(peer) },
onLongClick = { viewModel.expandedMenuPeer.set(peer) }),
colors = MaterialTheme.colorScheme.listItem,
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier =
Modifier.padding(top = 2.dp)
.size(10.dp)
.background(
color = peer.connectedColor(netmap.value),
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)) {
DropdownMenuItem(
leadingIcon = {
Icon(
painter = painterResource(R.drawable.timer),
contentDescription = null)
},
text = { Text(text = stringResource(R.string.ping)) },
onClick = {
viewModel.hidePeerDropdownMenu()
viewModel.startPing(peer)
})
}
}
}
}
},
supportingContent = {
Text(
text = peer.Addresses?.first()?.split("/")?.first() ?: "",
style =
MaterialTheme.typography.bodyMedium.copy(
lineHeight = MaterialTheme.typography.titleMedium.lineHeight))
})
}
}
}
}
}
@Composable
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(
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(
modifier = Modifier.clickable { action() },
colors = MaterialTheme.colorScheme.warningListItem,
headlineContent = {
Text(
netmap.SelfNode.expiryLabel(),
style = MaterialTheme.typography.titleMedium,
)
},
supportingContent = {
Text(
stringResource(id = R.string.keyExpiryExplainer),
style = MaterialTheme.typography.bodyMedium)
})
}
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun PromptPermissionsIfNecessary() {
Permissions.prompt.forEach { (permission, state) ->
ErrorDialog(
title = permission.title,
message = permission.description,
buttonText = R.string._continue) {
state.launchPermissionRequest()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchWithDynamicSuggestions(viewModel: MainViewModel, onSearch: (String) -> Unit) {
val searchTerm by viewModel.searchTerm.collectAsState()
val filteredPeers by viewModel.peers.collectAsState()
var expanded by rememberSaveable { mutableStateOf(false) }
val netmap by viewModel.netmap.collectAsState()
val keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
Column(
modifier =
Modifier.fillMaxWidth().focusRequester(focusRequester).clickable {
focusRequester.requestFocus()
keyboardController?.show()
}) {
SearchBar(
modifier = Modifier.fillMaxWidth().align(Alignment.CenterHorizontally),
inputField = {
SearchBarDefaults.InputField(
query = searchTerm,
onQueryChange = { query ->
viewModel.updateSearchTerm(query)
onSearch(query)
expanded = query.isNotEmpty()
},
onSearch = { query ->
viewModel.updateSearchTerm(query)
onSearch(query)
expanded = false
},
expanded = expanded,
onExpandedChange = { expanded = it },
placeholder = { Text("Search") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = {
if (expanded) {
IconButton(
onClick = {
viewModel.updateSearchTerm("")
onSearch("")
expanded = false
focusManager.clearFocus()
keyboardController?.hide()
}) {
Icon(Icons.Default.Clear, contentDescription = "Clear search")
}
}
})
},
expanded = expanded,
onExpandedChange = { expanded = it },
content = {
// Search results or suggestions
Column(Modifier.verticalScroll(rememberScrollState()).fillMaxSize()) {
filteredPeers.forEach { peerSet ->
val userName = peerSet.user?.DisplayName ?: "Unknown User"
peerSet.peers.forEach { peer ->
val deviceName = peer.displayName ?: "Unknown Device"
val ipAddress = peer.Addresses?.firstOrNull()?.split("/")?.first() ?: "No IP"
ListItem(
headlineContent = { Text(userName) },
supportingContent = {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
val onlineColor = peer.connectedColor(netmap)
Box(
modifier =
Modifier.size(10.dp)
.background(onlineColor, shape = RoundedCornerShape(50)))
Spacer(modifier = Modifier.size(8.dp))
Text(deviceName)
}
Text(ipAddress)
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier =
Modifier.clickable {
viewModel.updateSearchTerm(userName)
onSearch(userName)
expanded = false
focusManager.clearFocus()
keyboardController?.hide()
}
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp))
}
}
}
})
}
}
@Preview
@Composable
fun MainViewPreview() {
val vpnViewModel = VpnViewModel(App.get())
val vm = MainViewModel(vpnViewModel)
MainView(
{},
MainViewNavigation(
onNavigateToSettings = {},
onNavigateToPeerDetails = {},
onNavigateToExitNodes = {},
onNavigateToHealth = {}),
vm)
}

@ -0,0 +1,58 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
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
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(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().verticalScroll(rememberScrollState())) {
val managedByOrganization =
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)) }
managedByCaption?.let {
if (it.isNotEmpty()) {
Text(it)
}
}
managedByURL?.let { OpenURLButton(stringResource(R.string.open_support), it) }
}
}
}
@Preview
@Composable
fun ManagedByViewPreview() {
val vm = IpnViewModel()
ManagedByView(backToSettings = {}, vm)
}

@ -0,0 +1,67 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
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
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MullvadExitNodePicker(
countryCode: String,
nav: ExitNodePickerNav,
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) {
val mullvadExitNodes by model.mullvadExitNodesByCountryCode.collectAsState()
val bestAvailableByCountry by model.mullvadBestAvailableByCountry.collectAsState()
mullvadExitNodes[countryCode]?.toList()?.let { nodes ->
val any = nodes.first()
LoadingIndicator.Wrap {
Scaffold(
topBar = {
Header(
title = { Text("${countryCode.flag()} ${any.country}") },
onBack = nav.onNavigateBackToMullvad)
}) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
if (nodes.size > 1) {
val bestAvailableNode = bestAvailableByCountry[countryCode]!!
item {
ExitNodeItem(
model,
ExitNodePickerViewModel.ExitNode(
id = bestAvailableNode.id,
label = stringResource(R.string.best_available),
online = bestAvailableNode.online,
selected = false,
))
Lists.SectionDivider()
}
}
itemsWithDividers(nodes) { node -> ExitNodeItem(model, 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.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material3.ExperimentalMaterial3Api
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.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.util.itemsWithDividers
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MullvadExitNodePickerList(
nav: ExitNodePickerNav,
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) {
LoadingIndicator.Wrap {
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.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
// 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.
Box {
ListItem(
modifier =
Modifier.clickable {
if (nodes.size > 1) {
nav.onNavigateToMullvadCountry(countryCode)
} else {
model.setExitNode(first)
}
},
leadingContent = {
Text(
countryCode.flag(),
style = MaterialTheme.typography.titleLarge,
)
},
headlineContent = {
Text(first.country, style = MaterialTheme.typography.bodyMedium)
},
supportingContent = {
Text(
if (nodes.size == 1) first.city
else "${nodes.size} ${stringResource(R.string.cities_available)}",
style = MaterialTheme.typography.bodyMedium)
},
trailingContent = {
if (nodes.size > 1 && nodes.selected || first.selected) {
if (nodes.selected) {
Icon(
Icons.Outlined.Check,
contentDescription = stringResource(R.string.selected))
}
}
})
}
}
}
}
}
}

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

@ -0,0 +1,154 @@
// 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.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.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.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
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(
backToHome: BackNavigation,
nodeId: String,
pingViewModel: PingViewModel,
model: PeerDetailsViewModel =
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(
topBar = {
Header(
title = {
Column {
Text(
text = node.displayName,
style = MaterialTheme.typography.titleMedium.short,
color = MaterialTheme.colorScheme.onSurface)
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier =
Modifier.size(8.dp)
.background(
color = node.connectedColor(netmap),
shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = stringResource(id = node.connectedStrRes(netmap)),
style = MaterialTheme.typography.bodyMedium.short,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
},
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") {
Lists.MutedHeader(stringResource(R.string.tailscale_addresses))
}
itemsWithDividers(node.displayAddresses, key = { it.address }) {
AddressRow(address = it.address, type = it.typeString)
}
item(key = "infoDivider") { Lists.SectionDivider() }
itemsWithDividers(node.info, key = { "info_${it.titleRes}" }) {
ValueRow(title = stringResource(id = it.titleRes), value = it.value.getString())
}
}
if (isPinging) {
ModalBottomSheet(onDismissRequest = { model.onPingDismissal() }) {
PingView(model = model.pingViewModel)
}
}
}
}
}
}
@Composable
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,
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)
}
})
}
@Composable
fun ValueRow(title: String, value: String) {
ListItem(
colors = MaterialTheme.colorScheme.listItem,
headlineContent = { Text(text = title) },
supportingContent = { Text(text = value) })
}

@ -0,0 +1,66 @@
// 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.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.theme.off
import com.tailscale.ipn.ui.theme.on
@Composable
fun PeerView(
peer: Tailcfg.Node,
selfPeer: String? = null,
stateVal: Ipn.State? = null,
subtitle: () -> String = { peer.primaryIPv4Address ?: peer.primaryIPv6Address ?: "" },
onClick: (Tailcfg.Node) -> Unit = {},
trailingContent: @Composable () -> Unit = {}
) {
val disabled = !(peer.Online ?: false)
val textColor = if (disabled) MaterialTheme.colorScheme.onSurfaceVariant else Color.Unspecified
ListItem(
modifier = Modifier.clickable { onClick(peer) },
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
// By definition, SelfPeer is online since we will not show the peer list
// unless you're connected.
val isSelfAndRunning = (peer.StableID == selfPeer && stateVal == Ipn.State.Running)
val color: Color =
if ((peer.Online == true) || isSelfAndRunning) {
MaterialTheme.colorScheme.on
} else {
MaterialTheme.colorScheme.off
}
Box(
modifier =
Modifier.size(8.dp)
.background(color = color, shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = peer.displayName,
style = MaterialTheme.typography.titleMedium,
color = textColor)
}
},
supportingContent = {
Text(text = subtitle(), style = MaterialTheme.typography.bodyMedium, color = textColor)
},
trailingContent = trailingContent)
}

@ -0,0 +1,55 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
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.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Permissions
import com.tailscale.ipn.ui.theme.success
import com.tailscale.ipn.ui.util.itemsWithDividers
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun PermissionsView(backToSettings: BackNavigation, openApplicationSettings: () -> Unit) {
val permissions = Permissions.withGrantedStatus
Scaffold(topBar = { Header(titleRes = R.string.permissions, onBack = backToSettings) }) {
innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
itemsWithDividers(permissions) { (permission, granted) ->
ListItem(
modifier = Modifier.clickable { openApplicationSettings() },
leadingContent = {
Icon(
if (granted) painterResource(R.drawable.check_circle)
else painterResource(R.drawable.xmark_circle),
tint =
if (granted) MaterialTheme.colorScheme.success
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
contentDescription =
stringResource(if (granted) R.string.ok else R.string.warning))
},
headlineContent = {
Text(stringResource(permission.title), style = MaterialTheme.typography.titleMedium)
},
supportingContent = { Text(stringResource(permission.description)) },
)
}
}
}
}

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

@ -0,0 +1,119 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
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.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
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.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
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.IpnViewModel
@Composable
fun RunExitNodeView(nav: ExitNodePickerNav, model: IpnViewModel = viewModel()) {
val isRunningExitNode by model.isRunningExitNode.collectAsState()
Scaffold(
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()
.verticalScroll(rememberScrollState())) {
RunExitNodeGraphic()
if (isRunningExitNode) {
Text(
stringResource(R.string.running_as_exit_node),
fontFamily = MaterialTheme.typography.titleLarge.fontFamily,
fontSize = MaterialTheme.typography.titleLarge.fontSize,
fontWeight = FontWeight.SemiBold)
Text(stringResource(R.string.run_exit_node_explainer_running))
} else {
Text(
stringResource(R.string.run_this_device_as_an_exit_node),
fontFamily = MaterialTheme.typography.titleLarge.fontFamily,
fontSize = MaterialTheme.typography.titleLarge.fontSize,
fontWeight = FontWeight.SemiBold)
Text(stringResource(R.string.run_exit_node_explainer))
}
Text(stringResource(R.string.run_exit_node_caution))
Button(onClick = { model.setRunningExitNode(!isRunningExitNode) }) {
if (isRunningExitNode) {
Text(stringResource(R.string.stop_running_as_exit_node))
} else {
Text(stringResource(R.string.start_running_as_exit_node))
}
}
Spacer(modifier = Modifier.size(24.dp))
}
}
}
}
@Composable
fun RunExitNodeGraphic() {
@Composable
fun ArrowForward() {
Icon(
Icons.AutoMirrored.Outlined.ArrowForward,
"Arrow Forward",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 18.dp)) {
Icon(
painter = painterResource(id = R.drawable.computer),
"Computer icon",
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(36.dp))
ArrowForward()
Icon(
painter = painterResource(id = R.drawable.android),
"Android icon",
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(36.dp))
ArrowForward()
Icon(
painter = painterResource(id = R.drawable.globe),
"Globe icon",
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(36.dp))
}
}

@ -0,0 +1,214 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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.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
import androidx.compose.ui.res.stringResource
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
import com.tailscale.ipn.ui.util.AndroidTVUtil
import com.tailscale.ipn.ui.util.AppVersion
@Composable
fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel(), vpnViewModel: VpnViewModel = viewModel()) {
val handler = LocalUriHandler.current
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.onNavigateBackHome)
}) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) {
if (isVPNPrepared) {
UserView(
profile = user,
actionState = UserActionState.NAV,
onClick = settingsNav.onNavigateToUserSwitcher)
}
if (isAdmin && !isAndroidTV()) {
Lists.ItemDivider()
AdminTextView { handler.openUri(Links.ADMIN_URL) }
}
Lists.SectionDivider()
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,
subtitle =
tailnetLockEnabled?.let {
stringResource(if (it) R.string.enabled else R.string.disabled)
},
onClick = settingsNav.onNavigateToTailnetLock)
}
if (!AndroidTVUtil.isAndroidTV()){
Lists.ItemDivider()
Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions)
}
managedByOrganization.value?.let {
Lists.ItemDivider()
Setting.Text(
title = stringResource(R.string.managed_by_orgName, it),
onClick = settingsNav.onNavigateToManagedBy)
}
Lists.SectionDivider()
Setting.Text(R.string.bug_report, onClick = settingsNav.onNavigateToBugReport)
Lists.ItemDivider()
Setting.Text(
R.string.about_tailscale,
subtitle = "${stringResource(id = R.string.version)} ${AppVersion.Short()}",
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)
}
}
}
}
object Setting {
@Composable
fun Text(
titleRes: Int = 0,
title: String? = null,
subtitle: String? = null,
destructive: Boolean = false,
enabled: Boolean = true,
onClick: (() -> Unit)? = null
) {
var modifier: Modifier = Modifier
if (enabled) {
onClick?.let { modifier = modifier.clickable(onClick = it) }
}
ListItem(
modifier = modifier,
colors = MaterialTheme.colorScheme.listItem,
headlineContent = {
Text(
title ?: stringResource(titleRes),
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
fun Switch(
titleRes: Int = 0,
title: String? = null,
isOn: Boolean,
enabled: Boolean = true,
onToggle: (Boolean) -> Unit = {}
) {
ListItem(
colors = MaterialTheme.colorScheme.listItem,
headlineContent = {
Text(
title ?: stringResource(titleRes),
style = MaterialTheme.typography.bodyMedium,
)
},
trailingContent = {
TintedSwitch(checked = isOn, onCheckedChange = onToggle, enabled = enabled)
})
}
}
@Composable
fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
val adminStr = buildAnnotatedString {
append(stringResource(id = R.string.settings_admin_prefix))
pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL)
withStyle(
style =
SpanStyle(
color = MaterialTheme.colorScheme.link,
textDecoration = TextDecoration.Underline)) {
append(stringResource(id = R.string.settings_admin_link))
}
}
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)
}

@ -0,0 +1,104 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.ripple
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
typealias BackNavigation = () -> Unit
// Header view for all secondary screens
// @see TopAppBar actions for additional actions (usually a row of icons)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Header(
@StringRes titleRes: Int = 0,
title: (@Composable () -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {},
onBack: (() -> Unit)? = null
) {
val f = FocusRequester()
if (isAndroidTV()) {
LaunchedEffect(Unit) { f.requestFocus() }
}
TopAppBar(
title = {
title?.let { title() }
?: Text(
stringResource(titleRes),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface)
},
colors = MaterialTheme.colorScheme.topAppBar,
actions = actions,
navigationIcon = { onBack?.let { BackArrow(action = it, focusRequester = f) } },
)
}
@Composable
fun BackArrow(action: () -> Unit, focusRequester: FocusRequester) {
Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Go back to the previous screen",
modifier =
Modifier.focusRequester(focusRequester)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false),
onClick = { action() }))
}
}
@Composable
fun CheckedIndicator() {
Icon(Icons.Default.CheckCircle, null, tint = ts_color_light_blue)
}
@Composable
fun SimpleActivityIndicator(size: Int = 32) {
CircularProgressIndicator(
modifier = Modifier.width(size.dp),
)
}
@Composable
fun ActivityIndicator(progress: Double, size: Int = 32) {
LinearProgressIndicator(
progress = { progress.toFloat() },
modifier = Modifier.width(size.dp),
color = ts_color_light_blue,
trackColor = MaterialTheme.colorScheme.secondary,
)
}

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

@ -0,0 +1,199 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import android.text.format.Formatter
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.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
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 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
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
@Composable
fun TaildropView(
requestedTransfers: StateFlow<List<Ipn.OutgoingFile>>,
applicationScope: CoroutineScope,
viewModel: TaildropViewModel =
viewModel(factory = TaildropViewModelFactory(requestedTransfers, applicationScope))
) {
Scaffold(
contentWindowInsets = WindowInsets.Companion.statusBars,
topBar = { Header(R.string.share) }) { paddingInsets ->
val showDialog = viewModel.showDialog.collectAsState().value
// Show the error overlay
showDialog?.let { ErrorDialog(type = it, action = { viewModel.showDialog.set(null) }) }
Column(modifier = Modifier.padding(paddingInsets)) {
FileShareHeader(
fileTransfers = requestedTransfers.collectAsState().value,
totalSize = viewModel.totalSize)
when (viewModel.state.collectAsState().value) {
Ipn.State.Running -> {
val peers by viewModel.myPeers.collectAsState()
val context = LocalContext.current
FileSharePeerList(
peers = peers,
stateViewGenerator = { peerId ->
viewModel.TrailingContentForPeer(peerId = peerId)
},
onShare = { viewModel.share(context, it) })
}
else -> {
FileShareConnectView { viewModel.startVPN() }
}
}
}
}
}
@Composable
fun FileSharePeerList(
peers: List<Tailcfg.Node>,
stateViewGenerator: @Composable (String) -> Unit,
onShare: (Tailcfg.Node) -> Unit
) {
SectionDivider(stringResource(R.string.my_devices))
when (peers.isEmpty()) {
true -> {
Column(
modifier = Modifier.padding(horizontal = 8.dp).fillMaxHeight(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
Text(
stringResource(R.string.no_devices_to_share_with),
style = MaterialTheme.typography.titleMedium)
}
}
false -> {
LazyColumn {
peers.forEach { peer ->
item {
PeerView(
peer = peer,
onClick = { onShare(peer) },
subtitle = { peer.Hostinfo.OS ?: "" },
trailingContent = { stateViewGenerator(peer.StableID) })
}
}
}
}
}
}
@Composable
fun FileShareConnectView(onToggle: () -> Unit) {
Column(
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),
style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = onToggle) {
Text(
text = stringResource(id = R.string.connect),
fontSize = MaterialTheme.typography.titleMedium.fontSize)
}
}
}
@Composable
fun FileShareHeader(fileTransfers: List<Ipn.OutgoingFile>, totalSize: Long) {
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconForTransfer(fileTransfers)
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
when (fileTransfers.isEmpty()) {
true ->
Text(
stringResource(R.string.no_files_to_share),
style = MaterialTheme.typography.titleMedium)
false -> {
when (fileTransfers.size) {
1 -> Text(fileTransfers[0].Name, style = MaterialTheme.typography.titleMedium)
else ->
Text(
stringResource(R.string.file_count, fileTransfers.size),
style = MaterialTheme.typography.titleMedium)
}
}
}
val size = Formatter.formatFileSize(LocalContext.current, totalSize.toLong())
Text(
size,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.secondary)
}
}
}
}
@Composable
fun IconForTransfer(transfers: List<Ipn.OutgoingFile>) {
// (jonathan) TODO: Thumbnails?
when (transfers.size) {
0 ->
Icon(
painter = painterResource(R.drawable.warning),
contentDescription = "no files",
modifier = Modifier.size(32.dp))
1 -> {
// Show a thumbnail for single image shares.
val context = LocalContext.current
context.contentResolver.getType(transfers[0].uri)?.let {
if (it.startsWith("image/")) {
AsyncImage(
model = transfers[0].uri,
contentDescription = "one file",
modifier = Modifier.size(40.dp))
return
}
Icon(
painter = painterResource(R.drawable.single_file),
contentDescription = "files",
modifier = Modifier.size(40.dp))
}
}
else ->
Icon(
painter = painterResource(R.drawable.single_file),
contentDescription = "files",
modifier = Modifier.size(40.dp))
}
}

@ -0,0 +1,140 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.text.ClickableText
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.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
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.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(
backToSettings: BackNavigation,
model: TailnetLockSetupViewModel = viewModel(factory = TailnetLockSetupViewModelFactory())
) {
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 = backToSettings) }) { innerPadding ->
LoadingIndicator.Wrap {
LazyColumn(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
item { ExplainerView() }
items(statusItems) { statusItem ->
val interactionSource = remember { MutableInteractionSource() }
ListItem(
modifier =
Modifier.focusable(
interactionSource = interactionSource)
.clickable(
interactionSource = interactionSource,
indication = LocalIndication.current
) {},
leadingContent = {
Icon(
painter = painterResource(id = statusItem.icon),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant)
},
headlineContent = { Text(stringResource(statusItem.title)) })
}
item {
// Node key section
Lists.SectionDivider()
ClipboardValueView(
value = nodeKey,
title = stringResource(R.string.node_key),
subtitle = stringResource(R.string.node_key_explainer))
// Tailnet lock key section
Lists.SectionDivider()
ClipboardValueView(
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
Lists.MultilineDescription {
ClickableText(
explainerText(),
onClick = { handler.openUri(Links.TAILNET_LOCK_KB_URL) },
style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
fun explainerText(): AnnotatedString {
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)
withStyle(
style =
SpanStyle(
color = MaterialTheme.colorScheme.link,
textDecoration = TextDecoration.Underline)) {
append(stringResource(id = R.string.learn_more))
}
pop()
}
}
@Composable
@Preview
fun TailnetLockSetupViewPreview() {
val vm = TailnetLockSetupViewModel()
vm.nodeKey.set("8BADF00D-EA7-1337-DEAD-BEEF")
vm.tailnetLockKey.set("C0FFEE-CAFE-50DA")
TailnetLockSetupView(backToSettings = {}, vm)
}

@ -0,0 +1,154 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
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
import kotlin.concurrent.timer
// DotsMatrix represents the state of the progress indicator.
typealias DotsMatrix = List<List<Boolean>>
// The initial DotsMatrix that represents the Tailscale logo (T-shaped).
val logoDotsMatrix: DotsMatrix =
listOf(
listOf(false, false, false),
listOf(true, true, true),
listOf(false, true, false),
)
@Composable
fun TailscaleLogoView(
animated: Boolean = false,
usesOnBackgroundColors: Boolean = false,
modifier: Modifier
) {
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
fun advanceToNextMatrix() {
currentDotsMatrixIndex = (currentDotsMatrixIndex + 1) % gameOfLife.size
val newMatrix =
if (animated) {
gameOfLife[currentDotsMatrixIndex]
} else {
logoDotsMatrix
}
currentDotsMatrix.set(newMatrix)
}
if (animated) {
timer(period = 300L) { advanceToNextMatrix() }
}
@Composable
fun EnabledDot(modifier: Modifier) {
Canvas(modifier = modifier, onDraw = { drawCircle(primaryColor) })
}
@Composable
fun DisabledDot(modifier: Modifier) {
Canvas(modifier = modifier, onDraw = { drawCircle(secondaryColor) })
}
BoxWithConstraints(modifier) {
val currentMatrix = currentDotsMatrix.collectAsState().value
Column(verticalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
for (y in 0..2) {
Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
for (x in 0..2) {
if (currentMatrix[y][x]) {
EnabledDot(Modifier.size(this@BoxWithConstraints.maxWidth.div(4)))
} else {
DisabledDot(Modifier.size(this@BoxWithConstraints.maxWidth.div(4)))
}
}
}
}
}
}
}
val gameOfLife: List<DotsMatrix> =
listOf(
listOf(
listOf(false, true, true),
listOf(true, false, true),
listOf(false, false, true),
),
listOf(
listOf(false, true, true),
listOf(false, false, true),
listOf(false, true, false),
),
listOf(
listOf(false, true, true),
listOf(false, false, false),
listOf(false, false, true),
),
listOf(
listOf(false, false, true),
listOf(false, true, false),
listOf(false, false, false),
),
listOf(
listOf(false, true, false),
listOf(false, false, false),
listOf(false, false, false),
),
listOf(
listOf(false, false, false),
listOf(false, false, true),
listOf(false, false, false),
),
listOf(
listOf(false, false, false),
listOf(false, false, false),
listOf(false, false, false),
),
listOf(
listOf(false, false, true),
listOf(false, false, false),
listOf(false, false, false),
),
listOf(
listOf(false, false, false),
listOf(false, false, false),
listOf(true, false, false),
),
listOf(listOf(false, false, false), listOf(false, false, false), listOf(true, true, false)),
listOf(listOf(false, false, false), listOf(true, false, false), listOf(true, true, false)),
listOf(listOf(false, false, false), listOf(true, true, false), listOf(false, true, false)),
listOf(listOf(false, false, false), listOf(true, true, false), listOf(false, true, true)),
listOf(listOf(false, false, false), listOf(true, true, true), listOf(false, false, true)),
listOf(listOf(false, true, false), listOf(true, true, true), listOf(true, false, true)))

@ -0,0 +1,12 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
@Composable
fun TintedSwitch(checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?, enabled: Boolean = true) {
Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
}

@ -0,0 +1,193 @@
// 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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
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.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.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.ui.util.Lists
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: UserSwitcherNav, viewModel: UserSwitcherViewModel = viewModel()) {
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.backToSettings,
actions = {
Row {
FusMenu(
viewModel = viewModel,
onAuthKeyClick = nav.onNavigateToAuthKey,
onCustomClick = nav.onNavigateCustomControl)
IconButton(onClick = { viewModel.showHeaderMenu.set(!showHeaderMenu) }) {
Icon(Icons.Default.MoreVert, "menu")
}
}
})
}) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
val showErrorDialog by viewModel.errorDialog.collectAsState()
// Show the error overlay if need be
showErrorDialog?.let {
ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) })
}
// When switch is invoked, this stores the ID of the user we're trying to switch to
// so we can decorate it with a spinner. The actual logged in user will not change
// until
// we get our first netmap update back with the new userId for SelfNode.
// (jonathan) TODO: This user switch is not immediate. We may need to represent the
// "switching users" state globally (if ipnState is insufficient)
val nextUserId = remember { mutableStateOf<String?>(null) }
LazyColumn {
itemsWithDividers(users ?: emptyList()) { user ->
if (user.ID == currentUser?.ID) {
UserView(profile = user, actionState = UserActionState.CURRENT)
} else {
val state =
if (user.ID == nextUserId.value) UserActionState.SWITCHING
else UserActionState.NONE
UserView(
profile = user,
actionState = state,
onClick = {
nextUserId.value = user.ID
viewModel.switchProfile(user) {
if (it.isFailure) {
viewModel.errorDialog.set(ErrorDialogType.LOGOUT_FAILED)
nextUserId.value = null
} else {
nav.onNavigateHome()
}
}
})
}
}
item {
Lists.SectionDivider()
Setting.Text(R.string.add_account) {
viewModel.addProfile {
if (it.isFailure) {
viewModel.errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED)
}
}
}
Lists.ItemDivider()
Setting.Text(R.string.reauthenticate) { viewModel.login() }
if (currentUser != null) {
Lists.ItemDivider()
Setting.Text(
R.string.log_out,
destructive = true,
onClick = {
viewModel.logout {
it.onSuccess { nav.onNavigateHome() }
.onFailure {
viewModel.errorDialog.set(ErrorDialogType.LOGOUT_FAILED)
}
}
})
}
}
}
}
}
}
@Composable
fun FusMenu(
onCustomClick: () -> Unit,
onAuthKeyClick: () -> Unit,
viewModel: UserSwitcherViewModel
) {
val expanded by viewModel.showHeaderMenu.collectAsState()
DropdownMenu(
expanded = expanded,
onDismissRequest = { viewModel.showHeaderMenu.set(false) },
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)) {
MenuItem(
onClick = {
onCustomClick()
viewModel.showHeaderMenu.set(false)
},
text = stringResource(id = R.string.custom_control_menu))
MenuItem(
onClick = {
onAuthKeyClick()
viewModel.showHeaderMenu.set(false)
},
text = stringResource(id = R.string.auth_key_menu))
}
}
@Composable
fun MenuItem(text: String, onClick: () -> Unit) {
DropdownMenuItem(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 0.dp),
onClick = onClick,
text = { Text(text = text) })
}
@Composable
@Preview
fun UserSwitcherViewPreview() {
val vm = UserSwitcherViewModel()
val nav =
UserSwitcherNav(
backToSettings = {},
onNavigateHome = {},
onNavigateCustomControl = {},
onNavigateToAuthKey = {})
UserSwitcherView(nav, vm)
}

@ -0,0 +1,102 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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
import com.tailscale.ipn.ui.theme.short
import com.tailscale.ipn.ui.util.AutoResizingText
// Used to decorate UserViews.
// NONE indicates no decoration
// CURRENT indicates the user is the current user and will be "checked"
// SWITCHING indicates the user is being switched to and will be "loading"
// NAV will show a chevron
enum class UserActionState {
CURRENT,
SWITCHING,
NAV,
NONE
}
@Composable
fun UserView(
profile: IpnLocal.LoginProfile?,
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,
colors = colors,
leadingContent = { Avatar(profile = profile, size = 36) },
headlineContent = {
AutoResizingText(
text = profile.UserProfile.LoginName,
style = MaterialTheme.typography.titleMedium.short,
minFontSize = MaterialTheme.typography.minTextSize,
overflow = TextOverflow.Ellipsis)
},
supportingContent = {
Column {
AutoResizingText(
text = profile.NetworkProfile?.DomainName ?: "",
style = MaterialTheme.typography.bodyMedium.short,
minFontSize = MaterialTheme.typography.minTextSize,
overflow = TextOverflow.Ellipsis)
profile.customControlServerHostname()?.let {
AutoResizingText(
text = it,
style = MaterialTheme.typography.bodyMedium.short,
minFontSize = MaterialTheme.typography.minTextSize,
overflow = TextOverflow.Ellipsis)
}
}
},
trailingContent = {
when (actionState) {
UserActionState.CURRENT -> CheckedIndicator()
UserActionState.SWITCHING -> SimpleActivityIndicator(size = 26)
UserActionState.NAV ->
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight, null, Modifier.offset(x = 6.dp))
UserActionState.NONE -> Unit
}
})
}
?: run {
ListItem(
modifier = modifier,
colors = colors,
headlineContent = {
Text(
text = stringResource(id = R.string.accounts),
style = MaterialTheme.typography.titleMedium)
})
}
}
}

@ -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.ui.localapi.Client
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class BugReportViewModel : ViewModel() {
val bugReportID: StateFlow<String> = MutableStateFlow("")
init {
Client(viewModelScope).bugReportId { result ->
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() }
}
}
}
}
}

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

Loading…
Cancel
Save