Compare commits

...

124 Commits

Author SHA1 Message Date
Andrea Gottardo 840a31d74e
android: bump version to 1.69.75 (230) (#434)
android: bump version code

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

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

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

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
3 weeks ago
Jonathan Nobels 0e82e54ffb
android: bump version code (#418)
Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
3 weeks ago
Jonathan Nobels 64fca2a712
android: bump OSS (#417)
Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
3 weeks 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>
3 weeks 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>
3 weeks ago
kari-ts d7a87e868c
bump oss and version code (#415)
* bump version

* go.mod: update for 223

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

* android: bump version code

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

---------

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

Updates tailscale/corp#19122

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

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

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

Updates #cleanup

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

Updates tailscale/tailscale#12148

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

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

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

Signed-off-by: kari-ts <kari@tailscale.com>
1 month 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.
1 month ago
kari-ts 32e29c4efd
android: hide Accounts if VPN not prepared (#402)
Updates tailscale/tailscale#12148

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

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

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

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

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

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

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

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

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

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

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

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

2
.gitignore vendored

@ -24,6 +24,7 @@ tailscale-release.aab
tailscale-fdroid.apk tailscale-fdroid.apk
tailscale-new-fdroid.apk tailscale-new-fdroid.apk
tailscale-new-debug.apk tailscale-new-debug.apk
tailscale-test.apk
# Signing key # Signing key
tailscale.jks tailscale.jks
@ -40,3 +41,4 @@ tailscale.jks
libtailscale.aar libtailscale.aar
libtailscale-sources.jar libtailscale-sources.jar
.DS_Store

@ -2,24 +2,24 @@
# Use of this source code is governed by a BSD-style # Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file. # license that can be found in the LICENSE file.
## For signed release build JKS_PASSWORD must be set to the password for the jks keystore
## and JKS_PATH must be set to the path to the jks keystore.
DEBUG_APK=tailscale-debug.apk DEBUG_APK=tailscale-debug.apk
RELEASE_AAB=tailscale-release.aab RELEASE_AAB=tailscale-release.aab
APPID=com.tailscale.ipn LIBTAILSCALE=android/libs/libtailscale.aar
AAR=android_legacy/libs/ipn.aar
KEYSTORE=tailscale.jks
KEYSTORE_ALIAS=tailscale
TAILSCALE_VERSION=$(shell ./version/tailscale-version.sh 200) TAILSCALE_VERSION=$(shell ./version/tailscale-version.sh 200)
OUR_VERSION=$(shell git describe --dirty --exclude "*" --always --abbrev=200) OUR_VERSION=$(shell git describe --dirty --exclude "*" --always --abbrev=200)
TAILSCALE_VERSION_ABBREV=$(shell ./version/tailscale-version.sh 11) TAILSCALE_VERSION_ABBREV=$(shell ./version/tailscale-version.sh 11)
OUR_VERSION_ABBREV=$(shell git describe --dirty --exclude "*" --always --abbrev=11) OUR_VERSION_ABBREV=$(shell git describe --dirty --exclude "*" --always --abbrev=11)
VERSION_LONG=$(TAILSCALE_VERSION_ABBREV)-g$(OUR_VERSION_ABBREV) VERSION_LONG=$(TAILSCALE_VERSION_ABBREV)-g$(OUR_VERSION_ABBREV)
# Extract the long version build.gradle's versionName and strip quotes. # Extract the long version build.gradle's versionName and strip quotes.
VERSIONNAME=$(patsubst "%",%,$(lastword $(shell grep versionName android_legacy/build.gradle))) VERSIONNAME=$(patsubst "%",%,$(lastword $(shell grep versionName android/build.gradle)))
# Extract the x.y.z part for the short version. # Extract the x.y.z part for the short version.
VERSIONNAME_SHORT=$(shell echo $(VERSIONNAME) | cut -d - -f 1) VERSIONNAME_SHORT=$(shell echo $(VERSIONNAME) | cut -d - -f 1)
TAILSCALE_COMMIT=$(shell echo $(TAILSCALE_VERSION) | cut -d - -f 2 | cut -d t -f 2) TAILSCALE_COMMIT=$(shell echo $(TAILSCALE_VERSION) | cut -d - -f 2 | cut -d t -f 2)
# Extract the version code from build.gradle. # Extract the version code from build.gradle.
VERSIONCODE=$(lastword $(shell grep versionCode android_legacy/build.gradle)) VERSIONCODE=$(lastword $(shell grep versionCode android/build.gradle))
VERSIONCODE_PLUSONE=$(shell expr $(VERSIONCODE) + 1) VERSIONCODE_PLUSONE=$(shell expr $(VERSIONCODE) + 1)
VERSION_LDFLAGS=-X tailscale.com/version.longStamp=$(VERSIONNAME) -X tailscale.com/version.shortStamp=$(VERSIONNAME_SHORT) -X tailscale.com/version.gitCommitStamp=$(TAILSCALE_COMMIT) -X tailscale.com/version.extraGitCommitStamp=$(OUR_VERSION) VERSION_LDFLAGS=-X tailscale.com/version.longStamp=$(VERSIONNAME) -X tailscale.com/version.shortStamp=$(VERSIONNAME_SHORT) -X tailscale.com/version.gitCommitStamp=$(TAILSCALE_COMMIT) -X tailscale.com/version.extraGitCommitStamp=$(OUR_VERSION)
FULL_LDFLAGS=$(VERSION_LDFLAGS) -w FULL_LDFLAGS=$(VERSION_LDFLAGS) -w
@ -59,6 +59,8 @@ export JAVA_HOME ?= $(shell find "$(ANDROID_STUDIO_ROOT)/jbr" "$(ANDROID_STUDIO_
# If JAVA_HOME is still unset, remove it, because SDK tools go into a CPU spin if it is set and empty. # If JAVA_HOME is still unset, remove it, because SDK tools go into a CPU spin if it is set and empty.
ifeq ($(JAVA_HOME),) ifeq ($(JAVA_HOME),)
unexport JAVA_HOME unexport JAVA_HOME
else
export PATH := $(JAVA_HOME)/bin:$(PATH)
endif endif
# TOOLCHAINDIR is set by fdoid CI and used by tool/* scripts. # TOOLCHAINDIR is set by fdoid CI and used by tool/* scripts.
@ -68,11 +70,68 @@ export TOOLCHAINDIR
GOBIN ?= $(PWD)/android/build/go/bin GOBIN ?= $(PWD)/android/build/go/bin
export GOBIN export GOBIN
export PATH := $(PWD)/tool:$(GOBIN):$(JAVA_HOME)/bin:$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$(PATH) export PATH := $(PWD)/tool:$(GOBIN):$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$(PATH)
export GOROOT := # Unset export GOROOT := # Unset
all: tailscale-new-debug.apk test $(DEBUG_APK) tailscale-fdroid.apk ## Build and test everything #
# Android Builds:
#
.PHONY: apk
apk: $(DEBUG_APK) ## Build the debug APK
.PHONY: tailscale-debug
tailscale-debug: $(DEBUG_APK) ## Build the debug APK
.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
# gradle-dependencies groups together the android sources and libtailscale needed to assemble tests/debug/release builds.
.PHONY: gradle-dependencies
gradle-dependencies: $(shell find android -type f -not -path "android/build/*" -not -path '*/.*') $(LIBTAILSCALE)
$(DEBUG_APK): gradle-dependencies
(cd android && ./gradlew test assembleDebug)
install -C android/build/outputs/apk/debug/android-debug.apk $@
$(RELEASE_AAB): gradle-dependencies
(cd android && ./gradlew test bundleRelease)
install -C ./android/build/outputs/bundle/release/android-release.aab $@
tailscale-test.apk: gradle-dependencies
(cd android && ./gradlew assembleApplicationTestAndroidTest)
install -C ./android/build/outputs/apk/androidTest/applicationTest/android-applicationTest-androidTest.apk $@
#
# Go Builds:
#
android/libs:
mkdir -p android/libs
$(GOBIN)/gomobile: $(GOBIN)/gobind go.mod go.sum
go install golang.org/x/mobile/cmd/gomobile
$(GOBIN)/gobind: go.mod go.sum
go install golang.org/x/mobile/cmd/gobind
$(LIBTAILSCALE): Makefile android/libs $(shell find libtailscale -name *.go) go.mod go.sum $(GOBIN)/gomobile
gomobile bind -target android -androidapi 26 \
-ldflags "$(FULL_LDFLAGS)" \
-o $@ ./libtailscale
.PHONY: libtailscale
libtailscale: $(LIBTAILSCALE) ## Build the libtailscale AAR
#
# Utility tasks:
#
.PHONY: all
all: test $(DEBUG_APK) ## Build and test everything
.PHONY: env
env: env:
@echo PATH=$(PATH) @echo PATH=$(PATH)
@echo ANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT) @echo ANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT)
@ -81,12 +140,35 @@ env:
@echo JAVA_HOME=$(JAVA_HOME) @echo JAVA_HOME=$(JAVA_HOME)
@echo TOOLCHAINDIR=$(TOOLCHAINDIR) @echo TOOLCHAINDIR=$(TOOLCHAINDIR)
# Ensure that JKS_PATH and JKS_PASSWORD are set before we attempt a build
# that requires signing.
.PHONY: jarsign-env
jarsign-env:
ifeq ($(JKS_PATH),)
$(error JKS_PATH is not set. export JKS_PATH=/path/to/tailcale.jks)
endif
ifeq ($(JKS_PASSWORD),)
$(error JKS_PASSWORD is not set. export JKS_PASSWORD=passwordForTailcale.jks)
endif
ifeq ($(wildcard $(JKS_PATH)),)
$(error JKS_PATH does not point to a file)
endif
@echo "keystore path set to $(JKS_PATH)"
.PHONY: androidpath
androidpath:
@echo "export ANDROID_HOME=$(ANDROID_HOME)"
@echo "export ANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT)"
@echo 'export PATH=$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$$PATH'
.PHONY: tag_release
tag_release: ## Tag a release tag_release: ## Tag a release
sed -i'.bak' 's/versionCode $(VERSIONCODE)/versionCode $(VERSIONCODE_PLUSONE)/' android_legacy/build.gradle && rm android_legacy/build.gradle.bak sed -i'.bak' 's/versionCode $(VERSIONCODE)/versionCode $(VERSIONCODE_PLUSONE)/' android/build.gradle && rm android/build.gradle.bak
sed -i'.bak' 's/versionName .*/versionName "$(VERSION_LONG)"/' android_legacy/build.gradle && rm android_legacy/build.gradle.bak sed -i'.bak' 's/versionName .*/versionName "$(VERSION_LONG)"/' android/build.gradle && rm android/build.gradle.bak
git commit -sm "android: bump version code" android_legacy/build.gradle git commit -sm "android: bump version code" android/build.gradle
git tag -a "$(VERSION_LONG)" git tag -a "$(VERSION_LONG)"
.PHONY: bumposs
bumposs: ## Update the tailscale.com go module bumposs: ## Update the tailscale.com go module
GOPROXY=direct go get tailscale.com@main GOPROXY=direct go get tailscale.com@main
go run tailscale.com/cmd/printdep --go > go.toolchain.rev go run tailscale.com/cmd/printdep --go > go.toolchain.rev
@ -103,6 +185,7 @@ $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager:
mv $(ANDROID_HOME)/tmp/cmdline-tools $(ANDROID_HOME)/cmdline-tools/latest mv $(ANDROID_HOME)/tmp/cmdline-tools $(ANDROID_HOME)/cmdline-tools/latest
rm -rf $(ANDROID_HOME)/tmp rm -rf $(ANDROID_HOME)/tmp
.PHONY: androidsdk
androidsdk: $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager ## Install the set of Android SDK packages we need. androidsdk: $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager ## Install the set of Android SDK packages we need.
yes | $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null yes | $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null
$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --update $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --update
@ -111,6 +194,7 @@ androidsdk: $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager ## Install the s
# Normally in make you would simply take a dependency on the task that provides # Normally in make you would simply take a dependency on the task that provides
# the binaries, however users may have a decision to make as to whether they # the binaries, however users may have a decision to make as to whether they
# want to install an SDK or use the one from an Android Studio installation. # want to install an SDK or use the one from an Android Studio installation.
.PHONY: checkandroidsdk
checkandroidsdk: ## Check that Android SDK is installed checkandroidsdk: ## Check that Android SDK is installed
@$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --list_installed | grep -q 'ndk' || (\ @$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --list_installed | grep -q 'ndk' || (\
echo -e "\n\tERROR: Android SDK not installed.\n\ echo -e "\n\tERROR: Android SDK not installed.\n\
@ -118,90 +202,51 @@ checkandroidsdk: ## Check that Android SDK is installed
\tANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT)\n\n\ \tANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT)\n\n\
See README.md for instructions on how to install the prerequisites.\n"; exit 1) See README.md for instructions on how to install the prerequisites.\n"; exit 1)
androidpath: .PHONY: test
@echo "export ANDROID_HOME=$(ANDROID_HOME)" test: gradle-dependencies ## Run the Android tests
@echo "export ANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT)" (cd android && ./gradlew test)
@echo 'export PATH=$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$$PATH'
$(AAR): checkandroidsdk
@mkdir -p android_legacy/libs && \
go run gioui.org/cmd/gogio \
-ldflags "$(VERSION_LDFLAGS)" \
-buildmode archive -target android -appid $(APPID) -tags novulkan,tailscale_go -o $@ github.com/tailscale/tailscale-android/cmd/tailscale
# tailscale-debug.apk builds a debuggable APK with the Google Play SDK.
$(DEBUG_APK): $(AAR)
(cd android_legacy && ./gradlew test assemblePlayDebug)
mv android_legacy/build/outputs/apk/play/debug/android_legacy-play-debug.apk $@
# tailscale-fdroid.apk builds a non-Google Play SDK, without the Google bits.
# This is effectively what the F-Droid build definition produces.
# This is useful for testing on e.g. Amazon Fire Stick devices.
tailscale-fdroid.apk: $(AAR)
(cd android_legacy && ./gradlew test assembleFdroidDebug)
mv android_legacy/build/outputs/apk/fdroid/debug/android_legacy-fdroid-debug.apk $@
$(RELEASE_AAB): $(AAR)
(cd android_legacy && ./gradlew test bundlePlayRelease)
mv ./android_legacy/build/outputs/bundle/playRelease/android_legacy-play-release.aab $@
release: $(RELEASE_AAB) ## Build the release AAB
jarsigner -sigalg SHA256withRSA -digestalg SHA-256 -keystore $(KEYSTORE) $(RELEASE_AAB) $(KEYSTORE_ALIAS)
apk: $(DEBUG_APK) ## Build the debug APK
LIBTAILSCALE=android/libs/libtailscale.aar
LIBTAILSCALE_SOURCES=$(shell find libtailscale -name *.go) go.mod go.sum
android/libs:
mkdir -p android/libs
$(GOBIN)/gomobile: $(GOBIN)/gobind go.mod go.sum
go install golang.org/x/mobile/cmd/gomobile
$(GOBIN)/gobind: go.mod go.sum
go install golang.org/x/mobile/cmd/gobind
# TODO: the version names used below parse the legacy version information,
# though hopefully they won't substantially differ for now.
$(LIBTAILSCALE): Makefile android/libs $(LIBTAILSCALE_SOURCES) $(GOBIN)/gomobile
gomobile bind -target android -androidapi 26 \
-ldflags "$(FULL_LDFLAGS)" \
-o $@ ./libtailscale
libtailscale: $(LIBTAILSCALE) .PHONY: install
install: $(DEBUG_APK) ## Install the debug APK on a connected device
adb install -r $<
tailscale-new-fdroid.apk: $(LIBTAILSCALE) .PHONY: run
(cd android && ./gradlew test assembleFdroidDebug) run: install ## Run the debug APK on a connected device
mv android/build/outputs/apk/fdroid/debug/android-fdroid-debug.apk $@ adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.MainActivity
tailscale-new-debug.apk: $(LIBTAILSCALE) .PHONY: docker-build-image
(cd android && ./gradlew test assemblePlayDebug) docker-build-image: ## Builds the docker image for the android build environment
mv android/build/outputs/apk/play/debug/android-play-debug.apk $@ docker build -f docker/DockerFile.amd64-build -t tailscale-android-build-amd64 .
tailscale-new-debug: tailscale-new-debug.apk ## Build the new debug APK .PHONY: docker-run-build
docker-run-build: jarsign-env docker-build-image ## Runs the docker image for the android build environment and builds release
@docker run -v $(CURDIR):/build/tailscale-android --env JKS_PASSWORD=$(JKS_PASSWORD) --env JKS_PATH=$(JKS_PATH) tailscale-android-build-amd64
test: $(LIBTAILSCALE) ## Run the Android tests .PHONY: docker-remove-build-image
(cd android && ./gradlew test) docker-remove-build-image: ## Removes all docker build image
docker rmi --force tailscale-android-build-amd64
install: tailscale-new-debug.apk ## Install the debug APK on a connected device .PHONY: docker-all ## Makes a fresh docker environment, builds docker and cleans up. For CI.
adb install -r $< docker-all: docker-build-image docker-run-build docker-remove-build-image
run: install ## Run the debug APK on a connected device .PHONY: docker-shell
adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.IPNActivity 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 -v $(CURDIR):/build/tailscale-android -it tailscale-android-shell-amd64
dockershell: ## Run a shell in the Docker build container .PHONY: docker-remove-shell-image
docker build -t tailscale-android . docker-remove-shell-image: ## Removes all docker shell image
docker run -v $(CURDIR):/build/tailscale-android -it --rm tailscale-android docker rmi --force tailscale-android-shell-amd64
clean: ## Remove build artifacts .PHONY: clean
-rm -rf android/build android_legacy/build $(DEBUG_APK) $(RELEASE_AAB) $(AAR) $(LIBTAILSCALE) android/libs tailscale-fdroid.apk *.apk clean: ## Remove build artifacts. Does not purge docker build envs. Use dockerRemoveEnv for that.
-rm -rf android/build $(DEBUG_APK) $(RELEASE_AAB) $(LIBTAILSCALE) android/libs *.apk *.aab
-pkill -f gradle -pkill -f gradle
.PHONY: help
help: ## Show this help help: ## Show this help
@echo "\nSpecify a command. The choices are:\n" @echo "\nSpecify a command. The choices are:\n"
@grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' ${MAKEFILE_LIST} | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-20s\033[m %s\n", $$1, $$2}' @grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' ${MAKEFILE_LIST} | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-20s\033[m %s\n", $$1, $$2}'
@echo "" @echo ""
.PHONY: all clean install android_legacy/lib $(DEBUG_APK) $(RELEASE_AAB) $(AAR) release bump_version dockershell lib tailscale-new-debug help
.DEFAULT_GOAL := help .DEFAULT_GOAL := help

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

@ -1,6 +1,6 @@
buildscript { buildscript {
ext.kotlin_version = "1.9.22" ext.kotlin_version = "1.9.22"
ext.kotlin_compose_version = "1.5.10" ext.compose_version = "1.5.10"
ext.accompanist_version = "0.34.0" ext.accompanist_version = "0.34.0"
repositories { repositories {
@ -11,7 +11,7 @@ buildscript {
} }
} }
dependencies { dependencies {
classpath "com.android.tools.build:gradle:8.1.4" classpath 'com.android.tools.build:gradle:8.4.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
classpath("com.ncorti.ktfmt.gradle:plugin:0.17.0") classpath("com.ncorti.ktfmt.gradle:plugin:0.17.0")
@ -37,30 +37,50 @@ android {
defaultConfig { defaultConfig {
minSdkVersion 26 minSdkVersion 26
targetSdkVersion 34 targetSdkVersion 34
versionCode 198 versionCode 230
versionName "1.59.53-t0f042b981-g1017015de26" versionName "1.69.75-t27033c627-gb6cacdfd6a2"
} }
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_17 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17
} }
kotlinOptions {
jvmTarget = "17"
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures { buildFeatures {
compose true compose true
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = "$kotlin_compose_version" kotlinCompilerExtensionVersion = "$compose_version"
} }
flavorDimensions "version" flavorDimensions "version"
productFlavors { namespace 'com.tailscale.ipn'
fdroid {
// The fdroid flavor contains only free dependencies and is suitable buildTypes {
// for the F-Droid app store. applicationTest {
initWith debug
buildConfigField "String", "GITHUB_USERNAME", "\"" + getLocalProperty("githubUsername")+"\""
buildConfigField "String", "GITHUB_PASSWORD", "\"" + getLocalProperty("githubPassword")+"\""
buildConfigField "String", "GITHUB_2FA_SECRET", "\"" + getLocalProperty("github2FASecret")+"\""
} }
play { release {
// The play flavor contains all features and is for the Play Store. minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile(
'proguard-android-optimize.txt'),
'proguard-rules.pro'
} }
} }
namespace 'com.tailscale.ipn'
testBuildType "applicationTest"
} }
dependencies { dependencies {
@ -74,19 +94,18 @@ dependencies {
// Kotlin dependencies. // Kotlin dependencies.
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0" implementation 'junit:junit:4.12'
runtimeOnly "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
// Compose dependencies. // Compose dependencies.
def composeBom = platform('androidx.compose:compose-bom:2023.06.01') def composeBom = platform('androidx.compose:compose-bom:2023.06.01')
implementation composeBom implementation composeBom
androidTestImplementation composeBom
implementation 'androidx.compose.material3:material3:1.2.1' implementation 'androidx.compose.material3:material3:1.2.1'
implementation 'androidx.compose.material:material-icons-core:1.6.3' implementation 'androidx.compose.material:material-icons-core:1.6.3'
implementation "androidx.compose.ui:ui:1.6.3" implementation "androidx.compose.ui:ui:1.6.3"
implementation "androidx.compose.ui:ui-tooling:1.6.3" implementation "androidx.compose.ui:ui-tooling:1.6.3"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
implementation 'androidx.activity:activity-compose:1.8.2' implementation 'androidx.activity:activity-compose:1.8.2'
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version" implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
@ -95,23 +114,44 @@ dependencies {
// Navigation dependencies. // Navigation dependencies.
def nav_version = "2.7.7" def nav_version = "2.7.7"
implementation "androidx.navigation:navigation-fragment:$nav_version"
implementation "androidx.navigation:navigation-ui:$nav_version"
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
implementation "androidx.navigation:navigation-dynamic-features-fragment:$nav_version"
implementation "androidx.navigation:navigation-compose:$nav_version" implementation "androidx.navigation:navigation-compose:$nav_version"
implementation "androidx.navigation:navigation-ui:$nav_version"
// Supporting libraries. // Supporting libraries.
implementation("io.coil-kt:coil-compose:2.6.0") implementation("io.coil-kt:coil-compose:2.6.0")
implementation("com.google.zxing:core:3.5.1")
implementation("com.patrykandpatrick.vico:compose:1.15.0")
implementation("com.patrykandpatrick.vico:compose-m3:1.15.0")
// Tailscale dependencies. // Tailscale dependencies.
implementation ':libtailscale@aar' implementation ':libtailscale@aar'
// Tests // Integration Tests
testImplementation "junit:junit:4.12" androidTestImplementation composeBom
androidTestImplementation "androidx.navigation:navigation-testing:$nav_version" androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
implementation 'androidx.test.uiautomator:uiautomator:2.3.0'
// Non-free dependencies. // Authentication only for tests
playImplementation 'com.google.android.gms:play-services-auth:20.7.0' androidTestImplementation 'dev.turingcomplete:kotlin-onetimepassword:2.4.0'
androidTestImplementation 'commons-codec:commons-codec:1.16.1'
// Unit Tests
testImplementation 'junit:junit:4.13.2'
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.compose.ui:ui-tooling-preview")
}
def getLocalProperty(key) {
try {
Properties properties = new Properties()
properties.load(project.file('local.properties').newDataInputStream())
return properties.getProperty(key)
} catch(Throwable ignored) {
return ""
}
} }

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

@ -0,0 +1,21 @@
# Keep all classes with native methods
-keepclasseswithmembernames class * {
native <methods>;
}
# Keep specific classes from Tink library
-keep class com.google.crypto.tink.** { *; }
# Ignore warnings about missing Error Prone annotations
-dontwarn com.google.errorprone.annotations.**
# Keep Error Prone annotations if referenced
-keep class com.google.errorprone.annotations.** { *; }
# Keep Google HTTP Client classes
-keep class com.google.api.client.http.** { *; }
-dontwarn com.google.api.client.http.**
# Keep Joda-Time classes
-keep class org.joda.time.** { *; }
-dontwarn org.joda.time.**

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

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

@ -4,12 +4,13 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" /> android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" /> <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED"/>
<!-- Disable input emulation on ChromeOS --> <!-- Disable input emulation on ChromeOS -->
<uses-feature <uses-feature
@ -28,6 +29,7 @@
android:name=".App" android:name=".App"
android:allowBackup="false" android:allowBackup="false"
android:banner="@drawable/tv_banner" android:banner="@drawable/tv_banner"
android:requestLegacyExternalStorage="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="Tailscale" android:label="Tailscale"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
@ -82,20 +84,21 @@
</intent-filter> </intent-filter>
</activity> </activity>
<receiver <receiver
android:name="IPNReceiver" android:name="IPNReceiver"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="com.tailscale.ipn.CONNECT_VPN" /> <action android:name="com.tailscale.ipn.CONNECT_VPN" />
<action android:name="com.tailscale.ipn.DISCONNECT_VPN" /> <action android:name="com.tailscale.ipn.DISCONNECT_VPN" />
<action android:name="com.tailscale.ipn.USE_EXIT_NODE" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<service <service
android:name=".IPNService" android:name=".IPNService"
android:exported="false" android:exported="false"
android:permission="android.permission.BIND_VPN_SERVICE"> android:permission="android.permission.BIND_VPN_SERVICE"
android:foregroundServiceType="systemExempted">
<intent-filter> <intent-filter>
<action android:name="android.net.VpnService" /> <action android:name="android.net.VpnService" />
</intent-filter> </intent-filter>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@ -3,49 +3,40 @@
package com.tailscale.ipn package com.tailscale.ipn
import android.Manifest import android.Manifest
import android.app.Activity
import android.app.Application import android.app.Application
import android.app.DownloadManager import android.app.Notification
import android.app.Fragment
import android.app.FragmentTransaction
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.PendingIntent import android.app.PendingIntent
import android.app.UiModeManager
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.Configuration
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.LinkProperties import android.net.LinkProperties
import android.net.Network import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import android.net.Uri
import android.net.VpnService
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.MediaStore
import android.provider.Settings
import android.util.Log import android.util.Log
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.localapi.Request import com.tailscale.ipn.ui.localapi.Request
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.HealthNotifier
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import libtailscale.Libtailscale import libtailscale.Libtailscale
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
@ -54,42 +45,34 @@ import java.net.NetworkInterface
import java.security.GeneralSecurityException import java.security.GeneralSecurityException
import java.util.Locale import java.util.Locale
class App : Application(), libtailscale.AppContext { class App : UninitializedApp(), libtailscale.AppContext {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
companion object { companion object {
const val STATUS_CHANNEL_ID = "tailscale-status"
const val STATUS_NOTIFICATION_ID = 1
const val NOTIFY_CHANNEL_ID = "tailscale-notify"
const val NOTIFY_NOTIFICATION_ID = 2
private const val PEER_TAG = "peer"
private const val FILE_CHANNEL_ID = "tailscale-files" private const val FILE_CHANNEL_ID = "tailscale-files"
private const val FILE_NOTIFICATION_ID = 3
private const val TAG = "App" private const val TAG = "App"
private val networkConnectivityRequest = private val networkConnectivityRequest =
NetworkRequest.Builder() NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
.build() .build()
lateinit var appInstance: App private lateinit var appInstance: App
@JvmStatic
fun startActivityForResult(act: Activity, intent: Intent?, request: Int) {
val f: Fragment = act.fragmentManager.findFragmentByTag(PEER_TAG)
f.startActivityForResult(intent, request)
}
/**
* Initializes the app (if necessary) and returns the singleton app instance. Always use this
* function to obtain an App reference to make sure the app initializes.
*/
@JvmStatic @JvmStatic
fun getApplication(): App { fun get(): App {
appInstance.initOnce()
return appInstance return appInstance
} }
} }
val dns = DnsConfig() val dns = DnsConfig()
var autoConnect = false
var vpnReady = false
private lateinit var connectivityManager: ConnectivityManager private lateinit var connectivityManager: ConnectivityManager
private lateinit var app: libtailscale.Application private lateinit var app: libtailscale.Application
private var healthNotifier: HealthNotifier? = null
override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString
@ -101,6 +84,41 @@ class App : Application(), libtailscale.AppContext {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
createNotificationChannel(
STATUS_CHANNEL_ID,
getString(R.string.vpn_status),
getString(R.string.optional_notifications_which_display_the_status_of_the_vpn_tunnel),
NotificationManagerCompat.IMPORTANCE_MIN)
createNotificationChannel(
FILE_CHANNEL_ID,
getString(R.string.taildrop_file_transfers),
getString(R.string.notifications_delivered_when_a_file_is_received_using_taildrop),
NotificationManagerCompat.IMPORTANCE_DEFAULT)
createNotificationChannel(
HealthNotifier.HEALTH_CHANNEL_ID,
getString(R.string.health_channel_name),
getString(R.string.health_channel_description),
NotificationManagerCompat.IMPORTANCE_HIGH)
appInstance = this
setUnprotectedInstance(this)
}
override fun onTerminate() {
super.onTerminate()
Notifier.stop()
notificationManager.cancelAll()
applicationScope.cancel()
}
private var isInitialized = false
@Synchronized
private fun initOnce() {
if (isInitialized) {
return
}
isInitialized = true
val dataDir = this.filesDir.absolutePath val dataDir = this.filesDir.absolutePath
// Set this to enable direct mode for taildrop whereby downloads will be saved directly // Set this to enable direct mode for taildrop whereby downloads will be saved directly
@ -108,35 +126,32 @@ class App : Application(), libtailscale.AppContext {
// an app local directory "Taildrop" if we cannot create that. This mode does not support // an app local directory "Taildrop" if we cannot create that. This mode does not support
// user notifications for incoming files. // user notifications for incoming files.
val directFileDir = this.prepareDownloadsFolder() val directFileDir = this.prepareDownloadsFolder()
app = Libtailscale.start(dataDir, directFileDir.absolutePath, this) app = Libtailscale.start(dataDir, directFileDir.absolutePath, this)
Request.setApp(app) Request.setApp(app)
Notifier.setApp(app) Notifier.setApp(app)
Notifier.start(applicationScope) Notifier.start(applicationScope)
healthNotifier = HealthNotifier(Notifier.health, applicationScope)
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
setAndRegisterNetworkCallbacks() setAndRegisterNetworkCallbacks()
createNotificationChannel(
NOTIFY_CHANNEL_ID, "Notifications", NotificationManagerCompat.IMPORTANCE_DEFAULT)
createNotificationChannel(
STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW)
createNotificationChannel(
FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT)
appInstance = this
applicationScope.launch { applicationScope.launch {
Notifier.tileReady.collect { isTileReady -> setTileReady(isTileReady) } Notifier.state.collect { state ->
val ableToStartVPN = state > Ipn.State.NeedsMachineAuth
// If VPN is stopped, show a disconnected notification. If it is running as a foregrround
// service, IPNService will show a connected notification.
if (state == Ipn.State.Stopped) {
notifyStatus(false)
}
val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running
updateConnStatus(ableToStartVPN)
QuickToggleService.setVPNRunning(vpnRunning)
} }
} }
override fun onTerminate() {
super.onTerminate()
Notifier.stop()
applicationScope.cancel()
} }
fun setWantRunning(wantRunning: Boolean) { fun setWantRunning(wantRunning: Boolean) {
val callback: (Result<Ipn.Prefs>) -> Unit = { result -> val callback: (Result<Ipn.Prefs>) -> Unit = { result ->
result.fold( result.fold(
onSuccess = { _ -> setTileStatus(wantRunning) }, onSuccess = {},
onFailure = { error -> onFailure = { error ->
Log.d("TAG", "Set want running: failed to update preferences: ${error.message}") Log.d("TAG", "Set want running: failed to update preferences: ${error.message}")
}) })
@ -167,31 +182,19 @@ class App : Application(), libtailscale.AppContext {
} }
if (dns.updateDNSFromNetwork(sb.toString())) { if (dns.updateDNSFromNetwork(sb.toString())) {
Libtailscale.onDnsConfigChanged() Libtailscale.onDNSConfigChanged(linkProperties?.interfaceName)
} }
} }
override fun onLost(network: Network) { override fun onLost(network: Network) {
super.onLost(network) super.onLost(network)
if (dns.updateDNSFromNetwork("")) { if (dns.updateDNSFromNetwork("")) {
Libtailscale.onDnsConfigChanged() Libtailscale.onDNSConfigChanged("")
} }
} }
}) })
} }
fun startVPN() {
val intent = Intent(this, IPNService::class.java)
intent.setAction(IPNService.ACTION_REQUEST_VPN)
startService(intent)
}
fun stopVPN() {
val intent = Intent(this, IPNService::class.java)
intent.setAction(IPNService.ACTION_STOP_VPN)
startService(intent)
}
// encryptToPref a byte array of data using the Jetpack Security // encryptToPref a byte array of data using the Jetpack Security
// library and writes it to a global encrypted preference store. // library and writes it to a global encrypted preference store.
@Throws(IOException::class, GeneralSecurityException::class) @Throws(IOException::class, GeneralSecurityException::class)
@ -218,30 +221,15 @@ class App : Application(), libtailscale.AppContext {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM) EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
} }
fun setTileReady(ready: Boolean) { /*
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { * setAbleToStartVPN remembers whether or not we're able to start the VPN
return * by storing this in a shared preference. This allows us to check this
} * value without needing a fully initialized instance of the application.
QuickToggleService.setReady(this, ready) */
Log.d("App", "Set Tile Ready: $ready $autoConnect") private fun updateConnStatus(ableToStartVPN: Boolean) {
vpnReady = ready setAbleToStartVPN(ableToStartVPN)
if (ready && autoConnect) { QuickToggleService.updateTile()
startVPN() Log.d("App", "Set Tile Ready: $ableToStartVPN")
}
}
fun setTileStatus(status: Boolean) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return
}
QuickToggleService.setStatus(this, status)
}
fun getHostname(): String {
val userConfiguredDeviceName = getUserConfiguredDeviceName()
if (!userConfiguredDeviceName.isNullOrEmpty()) return userConfiguredDeviceName
return modelName
} }
override fun getModelName(): String { override fun getModelName(): String {
@ -257,138 +245,10 @@ class App : Application(), libtailscale.AppContext {
override fun getOSVersion(): String = Build.VERSION.RELEASE override fun getOSVersion(): String = Build.VERSION.RELEASE
// get user defined nickname from Settings
// returns null if not available
private fun getUserConfiguredDeviceName(): String? {
val nameFromSystemDevice = Settings.Secure.getString(contentResolver, "device_name")
if (!nameFromSystemDevice.isNullOrEmpty()) return nameFromSystemDevice
return null
}
// attachPeer adds a Peer fragment for tracking the Activity
// lifecycle.
fun attachPeer(act: Activity) {
act.runOnUiThread(
Runnable {
val ft: FragmentTransaction = act.fragmentManager.beginTransaction()
ft.add(Peer(), PEER_TAG)
ft.commit()
act.fragmentManager.executePendingTransactions()
})
}
override fun isChromeOS(): Boolean { override fun isChromeOS(): Boolean {
return packageManager.hasSystemFeature("android.hardware.type.pc") return packageManager.hasSystemFeature("android.hardware.type.pc")
} }
fun prepareVPN(act: Activity, reqCode: Int) {
act.runOnUiThread(
Runnable {
val intent: Intent? = VpnService.prepare(act)
if (intent == null) {
startVPN()
} else {
startActivityForResult(act, intent, reqCode)
}
})
}
fun showURL(act: Activity, url: String?) {
act.runOnUiThread(
Runnable {
val builder: CustomTabsIntent.Builder = CustomTabsIntent.Builder()
val headerColor = -0xb69b6b
builder.setToolbarColor(headerColor)
val intent: CustomTabsIntent = builder.build()
intent.launchUrl(act, Uri.parse(url))
})
}
@get:Throws(Exception::class)
val packageCertificate: ByteArray?
// getPackageSignatureFingerprint returns the first package signing certificate, if any.
get() {
val info: PackageInfo
info = packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES)
for (signature in info.signatures) {
return signature.toByteArray()
}
return null
}
@Throws(IOException::class)
fun insertMedia(name: String?, mimeType: String): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver: ContentResolver = contentResolver
val contentValues = ContentValues()
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name)
if ("" != mimeType) {
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
}
val root: Uri = MediaStore.Files.getContentUri("external")
resolver.insert(root, contentValues).toString()
} else {
val dir: File = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
dir.mkdirs()
val f = File(dir, name)
Uri.fromFile(f).toString()
}
}
@Throws(IOException::class)
fun openUri(uri: String?, mode: String?): Int? {
val resolver: ContentResolver = contentResolver
return mode?.let { resolver.openFileDescriptor(Uri.parse(uri), it)?.detachFd() }
}
fun deleteUri(uri: String?) {
val resolver: ContentResolver = contentResolver
resolver.delete(Uri.parse(uri), null, null)
}
fun notifyFile(uri: String?, msg: String?) {
val viewIntent: Intent
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
viewIntent = Intent(Intent.ACTION_VIEW, Uri.parse(uri))
} else {
// uri is a file:// which is not allowed to be shared outside the app.
viewIntent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS)
}
val pending: PendingIntent =
PendingIntent.getActivity(this, 0, viewIntent, PendingIntent.FLAG_UPDATE_CURRENT)
val builder: NotificationCompat.Builder =
NotificationCompat.Builder(this, FILE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("File received")
.setContentText(msg)
.setContentIntent(pending)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
val nm: NotificationManagerCompat = NotificationManagerCompat.from(this)
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED) {
// TODO: Consider calling
// ActivityCompat#requestPermissions
// here to request the missing permissions, and then overriding
// public void onRequestPermissionsResult(int requestCode, String[] permissions,
// int[] grantResults)
// to handle the case where the user grants the permission. See the documentation
// for ActivityCompat#requestPermissions for more details.
return
}
nm.notify(FILE_NOTIFICATION_ID, builder.build())
}
fun createNotificationChannel(id: String?, name: String?, importance: Int) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val channel = NotificationChannel(id, name, importance)
val nm: NotificationManagerCompat = NotificationManagerCompat.from(this)
nm.createNotificationChannel(channel)
}
override fun getInterfacesAsString(): String { override fun getInterfacesAsString(): String {
val interfaces: ArrayList<NetworkInterface> = val interfaces: ArrayList<NetworkInterface> =
java.util.Collections.list(NetworkInterface.getNetworkInterfaces()) java.util.Collections.list(NetworkInterface.getNetworkInterfaces())
@ -424,12 +284,7 @@ class App : Application(), libtailscale.AppContext {
return sb.toString() return sb.toString()
} }
fun isTV(): Boolean { private fun prepareDownloadsFolder(): File {
val mm = getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
return mm.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
}
fun prepareDownloadsFolder(): File {
var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
try { try {
@ -451,4 +306,149 @@ class App : Application(), libtailscale.AppContext {
return downloads 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 {
return MDMSettings.allSettingsByKey[key]?.flow?.value?.toString()
?: run {
Log.d("MDM", "$key is not defined on Android. Throwing NoSuchKeyException.")
throw MDMSettings.NoSuchKeyException()
}
}
@Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyStringArrayJSONValue(key: String): String {
val list = MDMSettings.allSettingsByKey[key]?.flow?.value as? List<String>
try {
return Json.encodeToString(list)
} catch (e: Exception) {
Log.d("MDM", "$key is not defined on Android. Throwing NoSuchKeyException.")
throw MDMSettings.NoSuchKeyException()
}
}
}
/**
* 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 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"
// File for shared preferences that are not encrypted.
private const val UNENCRYPTED_PREFERENCES = "unencrypted"
private lateinit var appInstance: UninitializedApp
lateinit var notificationManager: NotificationManagerCompat
@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 }
startForegroundService(intent)
}
fun stopVPN() {
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_STOP_VPN }
startService(intent)
}
fun createNotificationChannel(id: String, name: String, description: String, importance: Int) {
val channel = NotificationChannel(id, name, importance)
channel.description = description
notificationManager = NotificationManagerCompat.from(this)
notificationManager.createNotificationChannel(channel)
}
fun notifyStatus(vpnRunning: Boolean) {
notifyStatus(buildStatusNotification(vpnRunning))
}
fun notifyStatus(notification: Notification) {
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED) {
// TODO: Consider calling
// ActivityCompat#requestPermissions
// here to request the missing permissions, and then overriding
// public void onRequestPermissionsResult(int requestCode, String[] permissions,
// int[] grantResults)
// to handle the case where the user grants the permission. See the documentation
// for ActivityCompat#requestPermissions for more details.
return
}
notificationManager.notify(STATUS_NOTIFICATION_ID, notification)
}
fun buildStatusNotification(vpnRunning: Boolean): Notification {
val message = getString(if (vpnRunning) R.string.connected else R.string.not_connected)
val icon = if (vpnRunning) R.drawable.ic_notification else R.drawable.ic_notification_disabled
val action =
if (vpnRunning) IPNReceiver.INTENT_DISCONNECT_VPN else IPNReceiver.INTENT_CONNECT_VPN
val actionLabel = getString(if (vpnRunning) R.string.disconnect else R.string.connect)
val buttonIntent = Intent(this, IPNReceiver::class.java).apply { this.action = action }
val pendingButtonIntent: PendingIntent =
PendingIntent.getBroadcast(
this,
0,
buttonIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val intent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent: PendingIntent =
PendingIntent.getActivity(
this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
return NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
.setSmallIcon(icon)
.setContentTitle("Tailscale")
.setContentText(message)
.setAutoCancel(!vpnRunning)
.setOnlyAlertOnce(!vpnRunning)
.setOngoing(vpnRunning)
.setSilent(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.addAction(NotificationCompat.Action.Builder(0, actionLabel, pendingButtonIntent).build())
.setContentIntent(pendingIntent)
.build()
}
} }

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

@ -8,8 +8,6 @@ import android.content.pm.PackageManager
import android.net.VpnService import android.net.VpnService
import android.os.Build import android.os.Build
import android.system.OsConstants import android.system.OsConstants
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import libtailscale.Libtailscale import libtailscale.Libtailscale
import java.util.UUID import java.util.UUID
@ -20,35 +18,51 @@ open class IPNService : VpnService(), libtailscale.IPNService {
return randomID return randomID
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onCreate() {
if (intent != null && ACTION_STOP_VPN == intent.action) { super.onCreate()
(applicationContext as App).autoConnect = false // grab app to make sure it initializes
App.get()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
when (intent?.action) {
ACTION_STOP_VPN -> {
App.get().setWantRunning(false)
close() close()
return START_NOT_STICKY START_NOT_STICKY
} }
val app = applicationContext as App ACTION_START_VPN -> {
if (intent != null && "android.net.VpnService" == intent.action) { showForegroundNotification()
// Start VPN and connect to it due to Always-on VPN App.get().setWantRunning(true)
val i = Intent(IPNReceiver.INTENT_CONNECT_VPN)
i.setPackage(packageName)
i.setClass(applicationContext, IPNReceiver::class.java)
sendBroadcast(i)
Libtailscale.requestVPN(this) Libtailscale.requestVPN(this)
app.setWantRunning(true) START_STICKY
return START_STICKY
} }
"android.net.VpnService" -> {
// This means we were started by Android due to Always On VPN.
// We show a non-foreground notification because we weren't
// started as a foreground service.
App.get().notifyStatus(true)
App.get().setWantRunning(true)
Libtailscale.requestVPN(this) Libtailscale.requestVPN(this)
if (app.vpnReady && app.autoConnect) { START_STICKY
app.setWantRunning(true) }
else -> {
// This means that we were restarted after the service was killed
// (potentially due to OOM).
if (UninitializedApp.get().isAbleToStartVPN()) {
showForegroundNotification()
App.get()
Libtailscale.requestVPN(this)
START_STICKY
} else {
START_NOT_STICKY
}
} }
return START_STICKY
} }
override public fun close() { override fun close() {
stopForeground(true) stopForeground(STOP_FOREGROUND_REMOVE)
Libtailscale.serviceDisconnect(this) Libtailscale.serviceDisconnect(this)
val app = applicationContext as App
app.setWantRunning(false)
} }
override fun onDestroy() { override fun onDestroy() {
@ -61,6 +75,12 @@ open class IPNService : VpnService(), libtailscale.IPNService {
super.onRevoke() super.onRevoke()
} }
private fun showForegroundNotification() {
startForeground(
UninitializedApp.STATUS_NOTIFICATION_ID,
UninitializedApp.get().buildStatusNotification(true))
}
private fun configIntent(): PendingIntent { private fun configIntent(): PendingIntent {
return PendingIntent.getActivity( return PendingIntent.getActivity(
this, this,
@ -81,9 +101,9 @@ open class IPNService : VpnService(), libtailscale.IPNService {
.setConfigureIntent(configIntent()) .setConfigureIntent(configIntent())
.allowFamily(OsConstants.AF_INET) .allowFamily(OsConstants.AF_INET)
.allowFamily(OsConstants.AF_INET6) .allowFamily(OsConstants.AF_INET6)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
b.setMetered(false) // Inherit the metered status from the underlying networks. b.setMetered(false) // Inherit the metered status from the underlying networks.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) }
b.setUnderlyingNetworks(null) // Use all available networks. b.setUnderlyingNetworks(null) // Use all available networks.
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322 // RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
@ -107,33 +127,8 @@ open class IPNService : VpnService(), libtailscale.IPNService {
return VPNServiceBuilder(b) return VPNServiceBuilder(b)
} }
fun notify(title: String?, message: String?) {
val builder: NotificationCompat.Builder =
NotificationCompat.Builder(this, App.NOTIFY_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(message)
.setContentIntent(configIntent())
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
val nm: NotificationManagerCompat = NotificationManagerCompat.from(this)
nm.notify(App.NOTIFY_NOTIFICATION_ID, builder.build())
}
fun updateStatusNotification(title: String?, message: String?) {
val builder: NotificationCompat.Builder =
NotificationCompat.Builder(this, App.STATUS_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(message)
.setContentIntent(configIntent())
.setPriority(NotificationCompat.PRIORITY_LOW)
startForeground(App.STATUS_NOTIFICATION_ID, builder.build())
}
companion object { companion object {
const val ACTION_REQUEST_VPN = "com.tailscale.ipn.REQUEST_VPN" const val ACTION_START_VPN = "com.tailscale.ipn.START_VPN"
const val ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN" const val ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN"
} }
} }

@ -3,6 +3,7 @@
package com.tailscale.ipn package com.tailscale.ipn
import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -10,8 +11,6 @@ import android.content.RestrictionsManager
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE import android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE
import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK
import android.net.Uri
import android.net.VpnService
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
@ -19,30 +18,41 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.viewModels
import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.animation.core.tween 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.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally 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.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavHostController
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import androidx.navigation.navigation
import com.tailscale.ipn.Peer.RequestCodes
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.AppTheme 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.AboutView
import com.tailscale.ipn.ui.view.BackNavigation
import com.tailscale.ipn.ui.view.BugReportView import com.tailscale.ipn.ui.view.BugReportView
import com.tailscale.ipn.ui.view.DNSSettingsView import com.tailscale.ipn.ui.view.DNSSettingsView
import com.tailscale.ipn.ui.view.ExitNodePicker import com.tailscale.ipn.ui.view.ExitNodePicker
import com.tailscale.ipn.ui.view.IntroView 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.MDMSettingsDebugView
import com.tailscale.ipn.ui.view.MainView import com.tailscale.ipn.ui.view.MainView
import com.tailscale.ipn.ui.view.MainViewNavigation import com.tailscale.ipn.ui.view.MainViewNavigation
@ -54,24 +64,26 @@ import com.tailscale.ipn.ui.view.PermissionsView
import com.tailscale.ipn.ui.view.RunExitNodeView import com.tailscale.ipn.ui.view.RunExitNodeView
import com.tailscale.ipn.ui.view.SettingsView import com.tailscale.ipn.ui.view.SettingsView
import com.tailscale.ipn.ui.view.TailnetLockSetupView import com.tailscale.ipn.ui.view.TailnetLockSetupView
import com.tailscale.ipn.ui.view.UserSwitcherNav
import com.tailscale.ipn.ui.view.UserSwitcherView import com.tailscale.ipn.ui.view.UserSwitcherView
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.PingViewModel
import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsNav
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var requestVpnPermission: ActivityResultLauncher<Unit> private lateinit var navController: NavHostController
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
private val viewModel: MainViewModel by viewModels()
companion object { companion object {
// Request codes for Android callbacks.
// requestPrepareVPN is for when Android's VpnService.prepare completes.
@JvmStatic val requestPrepareVPN: Int = 1001
const val WRITE_STORAGE_RESULT = 1000
private const val TAG = "Main Activity" private const val TAG = "Main Activity"
private const val START_AT_ROOT = "startAtRoot"
} }
private fun Context.isLandscapeCapable(): Boolean { private fun Context.isLandscapeCapable(): Boolean {
@ -79,9 +91,18 @@ class MainActivity : ComponentActivity() {
SCREENLAYOUT_SIZE_LARGE 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// grab app to make sure it initializes
App.get()
// (jonathan) TODO: Force the app to be portrait on small screens until we have // (jonathan) TODO: Force the app to be portrait on small screens until we have
// proper landscape layout support // proper landscape layout support
if (!isLandscapeCapable()) { if (!isLandscapeCapable()) {
@ -90,9 +111,24 @@ class MainActivity : ComponentActivity() {
installSplashScreen() installSplashScreen()
vpnPermissionLauncher =
registerForActivityResult(VpnPermissionContract()) { granted ->
if (granted) {
Log.d("VpnPermission", "VPN permission granted")
viewModel.setVpnPrepared(true)
App.get().startVPN()
} else {
Log.d("VpnPermission", "VPN permission denied")
viewModel.setVpnPrepared(false)
}
}
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher)
setContent { setContent {
AppTheme { AppTheme {
val navController = rememberNavController() navController = rememberNavController()
Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox
Surface(modifier = Modifier.universalFit()) { // Letterbox for AndroidTV
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = "main", startDestination = "main",
@ -108,6 +144,10 @@ class MainActivity : ComponentActivity() {
popExitTransition = { popExitTransition = {
slideOutHorizontally(animationSpec = tween(150), targetOffsetX = { it }) slideOutHorizontally(animationSpec = tween(150), targetOffsetX = { it })
}) { }) {
fun backTo(route: String): () -> Unit = {
navController.popBackStack(route = route, inclusive = false)
}
val mainViewNav = val mainViewNav =
MainViewNavigation( MainViewNavigation(
onNavigateToSettings = { navController.navigate("settings") }, onNavigateToSettings = { navController.navigate("settings") },
@ -127,57 +167,71 @@ class MainActivity : ComponentActivity() {
onNavigateToManagedBy = { navController.navigate("managedBy") }, onNavigateToManagedBy = { navController.navigate("managedBy") },
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") }, onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
onNavigateToPermissions = { navController.navigate("permissions") }, onNavigateToPermissions = { navController.navigate("permissions") },
onBackPressed = { navController.popBackStack() }, onBackToSettings = backTo("settings"),
) onNavigateBackHome = backTo("main"))
val backNav = BackNavigation(onBack = { navController.popBackStack() })
val exitNodePickerNav = val exitNodePickerNav =
ExitNodePickerNav( ExitNodePickerNav(
onNavigateHome = { onNavigateBackHome = {
navController.popBackStack(route = "main", inclusive = false) navController.popBackStack(route = "main", inclusive = false)
}, },
onNavigateBack = { navController.popBackStack() }, onNavigateBackToExitNodes = backTo("exitNodes"),
onNavigateToExitNodePicker = { navController.popBackStack() },
onNavigateToMullvad = { navController.navigate("mullvad") }, onNavigateToMullvad = { navController.navigate("mullvad") },
onNavigateBackToMullvad = backTo("mullvad"),
onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") }, onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") },
onNavigateToRunAsExitNode = { navController.navigate("runExitNode") }) onNavigateToRunAsExitNode = { navController.navigate("runExitNode") })
composable("main") { MainView(navigation = mainViewNav) } val userSwitcherNav =
UserSwitcherNav(
backToSettings = backTo("settings"),
onNavigateHome = backTo("main"),
onNavigateCustomControl = {
navController.navigate("loginWithCustomControl")
},
onNavigateToAuthKey = { navController.navigate("loginWithAuthKey") })
composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) {
MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel)
}
composable("settings") { SettingsView(settingsNav) } composable("settings") { SettingsView(settingsNav) }
navigation(startDestination = "list", route = "exitNodes") { composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
composable("list") { ExitNodePicker(exitNodePickerNav) }
composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) } composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) }
composable( composable(
"mullvad/{countryCode}", "mullvad/{countryCode}",
arguments = listOf(navArgument("countryCode") { type = NavType.StringType })) { arguments =
listOf(navArgument("countryCode") { type = NavType.StringType })) {
MullvadExitNodePicker( MullvadExitNodePicker(
it.arguments!!.getString("countryCode")!!, exitNodePickerNav) it.arguments!!.getString("countryCode")!!, exitNodePickerNav)
} }
composable("runExitNode") { RunExitNodeView(exitNodePickerNav) } composable("runExitNode") { RunExitNodeView(exitNodePickerNav) }
}
composable( composable(
"peerDetails/{nodeId}", "peerDetails/{nodeId}",
arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) { arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) {
PeerDetails(nav = backNav, it.arguments?.getString("nodeId") ?: "") PeerDetails(
} backTo("main"),
composable("bugReport") { BugReportView(nav = backNav) } it.arguments?.getString("nodeId") ?: "",
composable("dnsSettings") { DNSSettingsView(nav = backNav) } PingViewModel())
composable("tailnetLock") { TailnetLockSetupView(nav = backNav) } }
composable("about") { AboutView(nav = backNav) } composable("bugReport") { BugReportView(backTo("settings")) }
composable("mdmSettings") { MDMSettingsDebugView(nav = backNav) } composable("dnsSettings") { DNSSettingsView(backTo("settings")) }
composable("managedBy") { ManagedByView(nav = backNav) } composable("tailnetLock") { TailnetLockSetupView(backTo("settings")) }
composable("userSwitcher") { composable("about") { AboutView(backTo("settings")) }
UserSwitcherView( composable("mdmSettings") { MDMSettingsDebugView(backTo("settings")) }
nav = backNav, composable("managedBy") { ManagedByView(backTo("settings")) }
onNavigateHome = { composable("userSwitcher") { UserSwitcherView(userSwitcherNav) }
navController.popBackStack(route = "main", inclusive = false)
})
}
composable("permissions") { composable("permissions") {
PermissionsView(nav = backNav, openApplicationSettings = ::openApplicationSettings) PermissionsView(backTo("settings"), ::openApplicationSettings)
}
composable("intro", exitTransition = { fadeOut(animationSpec = tween(150)) }) {
IntroView(backTo("main"))
}
composable("loginWithAuthKey") {
LoginWithAuthKeyView(onNavigateHome = backTo("main"), backTo("userSwitcher"))
}
composable("loginWithCustomControl") {
LoginWithCustomControlURLView(
onNavigateHome = backTo("main"), backTo("userSwitcher"))
} }
composable("intro") { IntroView { navController.popBackStack() } }
} }
// Show the intro screen one time // Show the intro screen one time
@ -187,26 +241,53 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
lifecycleScope.launch {
Notifier.readyToPrepareVPN.collect { isReady -> // Login actions are app wide. If we are told about a browse-to-url, we should render it
if (isReady) // over whatever screen we happen to be on.
App.getApplication().prepareVPN(this@MainActivity, RequestCodes.requestPrepareVPN) loginQRCode.collectAsState().value?.let {
LoginQRView(onDismiss = { loginQRCode.set(null) })
}
} }
} }
} }
init { init {
// Watch the model's browseToURL and launch the browser when it changes // Watch the model's browseToURL and launch the browser when it changes or
// This will trigger the login flow // pop up a QR code to scan
lifecycleScope.launch { lifecycleScope.launch {
Notifier.browseToURL.collect { url -> url?.let { Dispatchers.Main.run { login(it) } } } Notifier.browseToURL.collect { url ->
url?.let {
when (useQRCodeLogin()) {
false -> Dispatchers.Main.run { login(it) }
true -> loginQRCode.set(it)
}
}
}
}
// Once we see a loginFinished event, clear the QR code which will dismiss the QR dialog.
lifecycleScope.launch { Notifier.loginFinished.collect { _ -> loginQRCode.set(null) } }
}
// 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) == true) {
if (this::navController.isInitialized) {
navController.popBackStack(route = "main", inclusive = false)
}
} }
} }
private fun login(urlString: String) { private fun login(urlString: String) {
// Launch coroutine to listen for state changes. When the user completes login, relaunch // Launch coroutine to listen for state changes. When the user completes login, relaunch
// MainActivity to bring the app back to focus. // MainActivity to bring the app back to focus.
App.getApplication().applicationScope.launch { App.get().applicationScope.launch {
try { try {
Notifier.state.collect { state -> Notifier.state.collect { state ->
if (state > Ipn.State.NeedsMachineAuth) { if (state > Ipn.State.NeedsMachineAuth) {
@ -215,6 +296,7 @@ class MainActivity : ComponentActivity() {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
action = Intent.ACTION_MAIN action = Intent.ACTION_MAIN
addCategory(Intent.CATEGORY_LAUNCHER) addCategory(Intent.CATEGORY_LAUNCHER)
putExtra(START_AT_ROOT, true)
} }
startActivity(intent) startActivity(intent)
@ -246,45 +328,25 @@ class MainActivity : ComponentActivity() {
super.onResume() super.onResume()
val restrictionsManager = val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
MDMSettings.update(App.getApplication(), restrictionsManager)
}
} }
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
// (jonathan) TODO: Requesting VPN permissions onStart is a bit aggressive. This should
// be done when the user initiall starts the VPN
requestVpnPermission()
} }
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
val restrictionsManager = val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
MDMSettings.update(App.getApplication(), restrictionsManager)
}
}
private fun requestVpnPermission() {
val vpnIntent = VpnService.prepare(this)
if (vpnIntent != null) {
val contract = VpnPermissionContract()
requestVpnPermission =
registerForActivityResult(contract) { granted ->
Log.i("VPN", "VPN permission ${if (granted) "granted" else "denied"}")
}
requestVpnPermission.launch(Unit)
}
} }
private fun openApplicationSettings() { private fun openApplicationSettings() {
val intent = val intent =
Intent( Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
Settings.ACTION_APPLICATION_DETAILS_SETTINGS, putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
Uri.fromParts("package", packageName, null)) }
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent) startActivity(intent)
} }
@ -301,9 +363,9 @@ class MainActivity : ComponentActivity() {
} }
} }
class VpnPermissionContract : ActivityResultContract<Unit, Boolean>() { class VpnPermissionContract : ActivityResultContract<Intent, Boolean>() {
override fun createIntent(context: Context, input: Unit): Intent { override fun createIntent(context: Context, input: Intent): Intent {
return VpnService.prepare(context) ?: Intent() return input
} }
override fun parseResult(resultCode: Int, intent: Intent?): Boolean { override fun parseResult(resultCode: Int, intent: Intent?): Boolean {

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

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

@ -11,11 +11,13 @@ import android.provider.OpenableColumns
import android.util.Log import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.lifecycle.lifecycleScope import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.AppTheme import com.tailscale.ipn.ui.theme.AppTheme
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.util.universalFit
import com.tailscale.ipn.ui.view.TaildropView import com.tailscale.ipn.ui.view.TaildropView
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -29,21 +31,23 @@ class ShareActivity : ComponentActivity() {
override fun onCreate(state: Bundle?) { override fun onCreate(state: Bundle?) {
super.onCreate(state) super.onCreate(state)
setContent { setContent {
AppTheme { TaildropView(requestedTransfers, (application as App).applicationScope) } AppTheme {
Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox
Surface(modifier = Modifier.universalFit()) {
TaildropView(requestedTransfers, (application as App).applicationScope)
}
}
}
} }
} }
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
Notifier.start(lifecycleScope) // Ensure our app instance is initialized
App.get()
loadFiles() loadFiles()
} }
override fun onStop() {
super.onStop()
Notifier.stop()
}
override fun onNewIntent(intent: Intent?) { override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)

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

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

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

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

@ -11,42 +11,78 @@ import kotlin.reflect.full.isSubclassOf
import kotlin.reflect.jvm.jvmErasure import kotlin.reflect.jvm.jvmErasure
object MDMSettings { object MDMSettings {
// The String message used in this NoSuchKeyException must match the value of
// syspolicy.ErrNoSuchKey defined in Go, since the backend checks the value
// returned by the handler for equality using errors.Is().
class NoSuchKeyException : Exception("no such key")
val forceEnabled = BooleanMDMSetting("ForceEnabled", "Force Enabled Connection Toggle") val forceEnabled = BooleanMDMSetting("ForceEnabled", "Force Enabled Connection Toggle")
// Handled on the backed
val exitNodeID = StringMDMSetting("ExitNodeID", "Forced Exit Node: Stable ID") val exitNodeID = StringMDMSetting("ExitNodeID", "Forced Exit Node: Stable ID")
// (jonathan) TODO: Unused but required. There is some funky go string duration parsing required
// here.
val keyExpirationNotice = StringMDMSetting("KeyExpirationNotice", "Key Expiration Notice Period") val keyExpirationNotice = StringMDMSetting("KeyExpirationNotice", "Key Expiration Notice Period")
val loginURL = StringMDMSetting("LoginURL", "Custom control server URL") val loginURL = StringMDMSetting("LoginURL", "Custom control server URL")
val managedByCaption = StringMDMSetting("ManagedByCaption", "Managed By - Caption") val managedByCaption = StringMDMSetting("ManagedByCaption", "Managed By - Caption")
val managedByOrganizationName = val managedByOrganizationName =
StringMDMSetting("ManagedByOrganizationName", "Managed By - Organization Name") StringMDMSetting("ManagedByOrganizationName", "Managed By - Organization Name")
val managedByURL = StringMDMSetting("ManagedByURL", "Managed By - Support URL") val managedByURL = StringMDMSetting("ManagedByURL", "Managed By - Support URL")
// Handled on the backend
val tailnet = StringMDMSetting("Tailnet", "Recommended/Required Tailnet Name") val tailnet = StringMDMSetting("Tailnet", "Recommended/Required Tailnet Name")
val hiddenNetworkDevices = val hiddenNetworkDevices =
StringArrayListMDMSetting("HiddenNetworkDevices", "Hidden Network Device Categories") StringArrayListMDMSetting("HiddenNetworkDevices", "Hidden Network Device Categories")
// Unused on Android
val allowIncomingConnections = val allowIncomingConnections =
AlwaysNeverUserDecidesMDMSetting("AllowIncomingConnections", "Allow Incoming Connections") AlwaysNeverUserDecidesMDMSetting("AllowIncomingConnections", "Allow Incoming Connections")
// Unused on Android
val detectThirdPartyAppConflicts = val detectThirdPartyAppConflicts =
AlwaysNeverUserDecidesMDMSetting( AlwaysNeverUserDecidesMDMSetting(
"DetectThirdPartyAppConflicts", "Detect potentially problematic third-party apps") "DetectThirdPartyAppConflicts", "Detect potentially problematic third-party apps")
val exitNodeAllowLANAccess = val exitNodeAllowLANAccess =
AlwaysNeverUserDecidesMDMSetting( AlwaysNeverUserDecidesMDMSetting(
"ExitNodeAllowLANAccess", "Allow LAN Access when using an exit node") "ExitNodeAllowLANAccess", "Allow LAN Access when using an exit node")
// Handled on the backend
val postureChecking = val postureChecking =
AlwaysNeverUserDecidesMDMSetting("PostureChecking", "Enable Posture Checking") AlwaysNeverUserDecidesMDMSetting("PostureChecking", "Enable Posture Checking")
val useTailscaleDNSSettings = val useTailscaleDNSSettings =
AlwaysNeverUserDecidesMDMSetting("UseTailscaleDNSSettings", "Use Tailscale DNS Settings") AlwaysNeverUserDecidesMDMSetting("UseTailscaleDNSSettings", "Use Tailscale DNS Settings")
// Unused on Android
val useTailscaleSubnets = val useTailscaleSubnets =
AlwaysNeverUserDecidesMDMSetting("UseTailscaleSubnets", "Use Tailscale Subnets") AlwaysNeverUserDecidesMDMSetting("UseTailscaleSubnets", "Use Tailscale Subnets")
val exitNodesPicker = ShowHideMDMSetting("ExitNodesPicker", "Exit Nodes Picker") val exitNodesPicker = ShowHideMDMSetting("ExitNodesPicker", "Exit Nodes Picker")
val manageTailnetLock = ShowHideMDMSetting("ManageTailnetLock", "“Manage Tailnet lock” menu item") val manageTailnetLock = ShowHideMDMSetting("ManageTailnetLock", "“Manage Tailnet lock” menu item")
// Unused on Android
val resetToDefaults = ShowHideMDMSetting("ResetToDefaults", "“Reset to Defaults” menu item") val resetToDefaults = ShowHideMDMSetting("ResetToDefaults", "“Reset to Defaults” menu item")
val runExitNode = ShowHideMDMSetting("RunExitNode", "Run as Exit Node") val runExitNode = ShowHideMDMSetting("RunExitNode", "Run as Exit Node")
// Unused on Android
val testMenu = ShowHideMDMSetting("TestMenu", "Show Debug Menu") val testMenu = ShowHideMDMSetting("TestMenu", "Show Debug Menu")
// Unused on Android
val updateMenu = ShowHideMDMSetting("UpdateMenu", "“Update Available” menu item") val updateMenu = ShowHideMDMSetting("UpdateMenu", "“Update Available” menu item")
// (jonathan) TODO: Use this when suggested exit nodes are implemented
val allowedSuggestedExitNodes =
StringArrayListMDMSetting("AllowedSuggestedExitNodes", "Allowed Suggested Exit Nodes")
val allSettings by lazy { val allSettings by lazy {
MDMSettings::class MDMSettings::class
.declaredMemberProperties .declaredMemberProperties
@ -57,6 +93,8 @@ object MDMSettings {
.map { it.call(MDMSettings) as MDMSetting<*> } .map { it.call(MDMSettings) as MDMSetting<*> }
} }
val allSettingsByKey by lazy { allSettings.associateBy { it.key } }
fun update(app: App, restrictionsManager: RestrictionsManager?) { fun update(app: App, restrictionsManager: RestrictionsManager?) {
val bundle = restrictionsManager?.applicationRestrictions val bundle = restrictionsManager?.applicationRestrictions
allSettings.forEach { it.setFrom(bundle, app) } allSettings.forEach { it.setFrom(bundle, app) }

@ -44,7 +44,7 @@ class AlwaysNeverUserDecidesMDMSetting(key: String, localizedTitle: String) :
override fun getFrom(bundle: Bundle?, app: App): AlwaysNeverUserDecides { override fun getFrom(bundle: Bundle?, app: App): AlwaysNeverUserDecides {
val storedString = val storedString =
bundle?.getString(key) bundle?.getString(key)
?: App.getApplication().getEncryptedPrefs().getString(key, null) ?: App.get().getEncryptedPrefs().getString(key, null)
?: "user-decides" ?: "user-decides"
return when (storedString) { return when (storedString) {
"always" -> { "always" -> {
@ -64,9 +64,7 @@ class ShowHideMDMSetting(key: String, localizedTitle: String) :
MDMSetting<ShowHide>(ShowHide.Show, key, localizedTitle) { MDMSetting<ShowHide>(ShowHide.Show, key, localizedTitle) {
override fun getFrom(bundle: Bundle?, app: App): ShowHide { override fun getFrom(bundle: Bundle?, app: App): ShowHide {
val storedString = val storedString =
bundle?.getString(key) bundle?.getString(key) ?: App.get().getEncryptedPrefs().getString(key, null) ?: "show"
?: App.getApplication().getEncryptedPrefs().getString(key, null)
?: "show"
return when (storedString) { return when (storedString) {
"hide" -> { "hide" -> {
ShowHide.Hide ShowHide.Hide
@ -81,7 +79,12 @@ class ShowHideMDMSetting(key: String, localizedTitle: String) :
enum class AlwaysNeverUserDecides(val value: String) { enum class AlwaysNeverUserDecides(val value: String) {
Always("always"), Always("always"),
Never("never"), Never("never"),
UserDecides("user-decides") UserDecides("user-decides");
val hiddenFromUser: Boolean
get() {
return this != UserDecides
}
} }
enum class ShowHide(val value: String) { enum class ShowHide(val value: String) {

@ -11,6 +11,7 @@ import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.IpnState import com.tailscale.ipn.ui.model.IpnState
import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.util.InputStreamAdapter import com.tailscale.ipn.ui.util.InputStreamAdapter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -46,6 +47,7 @@ private object Endpoint {
const val FILES = "files" const val FILES = "files"
const val FILE_PUT = "file-put" const val FILE_PUT = "file-put"
const val TAILFS_SERVER_ADDRESS = "tailfs/fileserver-address" const val TAILFS_SERVER_ADDRESS = "tailfs/fileserver-address"
const val ENABLE_EXIT_NODE = "set-use-exit-node-enabled"
} }
typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit
@ -56,6 +58,8 @@ typealias BugReportIdHandler = (Result<BugReportID>) -> Unit
typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
typealias PingResultHandler = (Result<IpnState.PingResult>) -> Unit
/** /**
* Client provides a mechanism for calling Go's LocalAPIClient. Every LocalAPI endpoint has a * Client provides a mechanism for calling Go's LocalAPIClient. Every LocalAPI endpoint has a
* corresponding method on this Client. * corresponding method on this Client.
@ -72,6 +76,17 @@ class Client(private val scope: CoroutineScope) {
get(Endpoint.STATUS, responseHandler = responseHandler) get(Endpoint.STATUS, responseHandler = responseHandler)
} }
fun ping(peer: Tailcfg.Node, responseHandler: PingResultHandler) {
val ip = peer.primaryIPv4Address.orEmpty()
if (ip.isEmpty()) {
responseHandler(Result.failure(Exception("No IP address for peer $peer")))
return
}
val path = "${Endpoint.PING}?ip=${ip}&type=disco"
post(path, timeoutMillis = 2000L, responseHandler = responseHandler)
}
fun bugReportId(responseHandler: BugReportIdHandler) { fun bugReportId(responseHandler: BugReportIdHandler) {
post(Endpoint.BUG_REPORT, responseHandler = responseHandler) post(Endpoint.BUG_REPORT, responseHandler = responseHandler)
} }
@ -85,6 +100,11 @@ class Client(private val scope: CoroutineScope) {
return patch(Endpoint.PREFS, body, responseHandler = responseHandler) return patch(Endpoint.PREFS, body, responseHandler = responseHandler)
} }
fun setUseExitNode(use: Boolean, responseHandler: (Result<Ipn.Prefs>) -> Unit) {
val path = "${Endpoint.ENABLE_EXIT_NODE}?enabled=$use"
return post(path, responseHandler = responseHandler)
}
fun profiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit) { fun profiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit) {
get(Endpoint.PROFILES, responseHandler = responseHandler) get(Endpoint.PROFILES, responseHandler = responseHandler)
} }
@ -200,6 +220,7 @@ class Client(private val scope: CoroutineScope) {
private inline fun <reified T> post( private inline fun <reified T> post(
path: String, path: String,
body: ByteArray? = null, body: ByteArray? = null,
timeoutMillis: Long = 30000,
noinline responseHandler: (Result<T>) -> Unit noinline responseHandler: (Result<T>) -> Unit
) { ) {
Request( Request(
@ -207,6 +228,7 @@ class Client(private val scope: CoroutineScope) {
method = "POST", method = "POST",
path = path, path = path,
body = body, body = body,
timeoutMillis = timeoutMillis,
responseType = typeOf<T>(), responseType = typeOf<T>(),
responseHandler = responseHandler) responseHandler = responseHandler)
.execute() .execute()

@ -0,0 +1,38 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.model
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 DependsOn: List<String>? = null, // an array of WarnableCodes this depends on
) {
fun hiddenByDependencies(currentWarnableCodes: Set<String>): Boolean {
return this.DependsOn?.let {
it.any { depWarnableCode -> currentWarnableCodes.contains(depWarnableCode) }
} == true
}
}
@Serializable
enum class Severity {
high,
medium,
low
}
}

@ -46,6 +46,7 @@ class Ipn {
var IncomingFiles: List<PartialFile>? = null, var IncomingFiles: List<PartialFile>? = null,
var ClientVersion: Tailcfg.ClientVersion? = null, var ClientVersion: Tailcfg.ClientVersion? = null,
var TailFSShares: List<String>? = null, var TailFSShares: List<String>? = null,
var Health: Health.State? = null,
) )
@Serializable @Serializable
@ -65,7 +66,22 @@ class Ipn {
var ForceDaemon: Boolean = false, var ForceDaemon: Boolean = false,
var HostName: String = "", var HostName: String = "",
var AutoUpdate: AutoUpdatePrefs? = AutoUpdatePrefs(true, true), var AutoUpdate: AutoUpdatePrefs? = AutoUpdatePrefs(true, true),
) var InternalExitNodePrior: String? = null,
) {
// For the InternalExitNodePrior and ExitNodeId, these will treats the empty string as null to
// simplify the downstream logic.
val selectedExitNodeID: String?
get() {
return if (InternalExitNodePrior.isNullOrEmpty()) null else InternalExitNodePrior
}
val activeExitNodeID: String?
get() {
return if (ExitNodeID.isNullOrEmpty()) null else ExitNodeID
}
}
@Serializable @Serializable
data class MaskedPrefs( data class MaskedPrefs(
@ -79,6 +95,7 @@ class Ipn {
var AdvertiseRoutesSet: Boolean? = null, var AdvertiseRoutesSet: Boolean? = null,
var ForceDaemonSet: Boolean? = null, var ForceDaemonSet: Boolean? = null,
var HostnameSet: Boolean? = null, var HostnameSet: Boolean? = null,
var InternalExitNodePriorSet: Boolean? = null,
) { ) {
var ControlURL: String? = null var ControlURL: String? = null
@ -105,6 +122,12 @@ class Ipn {
ExitNodeIDSet = true ExitNodeIDSet = true
} }
var InternalExitNodePrior: String? = null
set(value) {
field = value
InternalExitNodePriorSet = true
}
var ExitNodeAllowLANAccess: Boolean? = null var ExitNodeAllowLANAccess: Boolean? = null
set(value) { set(value) {
field = value field = value
@ -194,7 +217,8 @@ class Ipn {
@Serializable @Serializable
data class Options( data class Options(
var FrontendLogID: String? = null, var FrontendLogID: String? = null,
var Prefs: Prefs? = null, var UpdatePrefs: Prefs? = null,
var AuthKey: String? = null,
) )
} }

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

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

@ -0,0 +1,117 @@
// 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 android.util.Log
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
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 ->
Log.d(TAG, "Health updated: ${health?.Warnings?.keys?.sorted()}")
health?.Warnings?.let {
notifyHealthUpdated(it.values.mapNotNull { it }.toTypedArray())
}
}
}
}
private val currentWarnings: MutableSet<String> = mutableSetOf()
private fun notifyHealthUpdated(warnings: Array<UnhealthyState>) {
val warningsBeforeAdd = currentWarnings
val currentWarnableCodes = warnings.map { it.WarnableCode }.toSet()
val addedWarnings: MutableSet<String> = mutableSetOf()
for (warning in warnings) {
if (ignoredWarnableCodes.contains(warning.WarnableCode)) {
continue
}
addedWarnings.add(warning.WarnableCode)
if (this.currentWarnings.contains(warning.WarnableCode)) {
// Already notified, skip
continue
} else if (warning.hiddenByDependencies(currentWarnableCodes)) {
// Ignore this warning because a dependency is also unhealthy
Log.d(TAG, "Ignoring ${warning.WarnableCode} because of dependency")
continue
} else {
Log.d(TAG, "Adding health warning: ${warning.WarnableCode}")
this.currentWarnings.add(warning.WarnableCode)
this.sendNotification(warning.Title, warning.Text, warning.WarnableCode)
}
}
val warningsToDrop = warningsBeforeAdd.minus(addedWarnings)
if (warningsToDrop.isNotEmpty()) {
Log.d(TAG, "Dropping health warnings with codes $warningsToDrop")
this.removeNotifications(warningsToDrop)
}
currentWarnings.subtract(warningsToDrop)
}
private fun sendNotification(title: String, text: String, code: String) {
Log.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) {
Log.d(TAG, "Notification permission not granted")
return
}
notificationManager.notify(code.hashCode(), notification)
}
private fun removeNotifications(codes: Set<String>) {
Log.d(TAG, "Removing notifications for $codes")
for (code in codes) {
notificationManager.cancel(code.hashCode())
}
}
}

@ -4,7 +4,9 @@
package com.tailscale.ipn.ui.notifier package com.tailscale.ipn.ui.notifier
import android.util.Log import android.util.Log
import com.tailscale.ipn.App
import com.tailscale.ipn.ui.model.Empty import com.tailscale.ipn.ui.model.Empty
import com.tailscale.ipn.ui.model.Health
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Ipn.Notify import com.tailscale.ipn.ui.model.Ipn.Notify
import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Netmap
@ -30,10 +32,6 @@ object Notifier {
private val TAG = Notifier::class.simpleName private val TAG = Notifier::class.simpleName
private val decoder = Json { ignoreUnknownKeys = true } private val decoder = Json { ignoreUnknownKeys = true }
// Global App State
val tileReady: StateFlow<Boolean> = MutableStateFlow(false)
val readyToPrepareVPN: StateFlow<Boolean> = MutableStateFlow(false)
// General IPN Bus State // General IPN Bus State
val state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState) val state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState)
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null) val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
@ -43,6 +41,7 @@ object Notifier {
val browseToURL: StateFlow<String?> = MutableStateFlow(null) val browseToURL: StateFlow<String?> = MutableStateFlow(null)
val loginFinished: StateFlow<String?> = MutableStateFlow(null) val loginFinished: StateFlow<String?> = MutableStateFlow(null)
val version: StateFlow<String?> = MutableStateFlow(null) val version: StateFlow<String?> = MutableStateFlow(null)
val health: StateFlow<Health.State?> = MutableStateFlow(null)
// Taildrop-specific State // Taildrop-specific State
val outgoingFiles: StateFlow<List<Ipn.OutgoingFile>?> = MutableStateFlow(null) val outgoingFiles: StateFlow<List<Ipn.OutgoingFile>?> = MutableStateFlow(null)
@ -60,11 +59,15 @@ object Notifier {
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
fun start(scope: CoroutineScope) { fun start(scope: CoroutineScope) {
Log.d(TAG, "Starting") Log.d(TAG, "Starting")
if (!::app.isInitialized) {
App.get()
}
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
val mask = val mask =
NotifyWatchOpt.Netmap.value or NotifyWatchOpt.Netmap.value or
NotifyWatchOpt.Prefs.value or NotifyWatchOpt.Prefs.value or
NotifyWatchOpt.InitialState.value NotifyWatchOpt.InitialState.value or
NotifyWatchOpt.InitialHealthState.value
manager = manager =
app.watchNotifications(mask.toLong()) { notification -> app.watchNotifications(mask.toLong()) { notification ->
val notify = decoder.decodeFromStream<Notify>(notification.inputStream()) val notify = decoder.decodeFromStream<Notify>(notification.inputStream())
@ -79,10 +82,7 @@ object Notifier {
notify.OutgoingFiles?.let(outgoingFiles::set) notify.OutgoingFiles?.let(outgoingFiles::set)
notify.FilesWaiting?.let(filesWaiting::set) notify.FilesWaiting?.let(filesWaiting::set)
notify.IncomingFiles?.let(incomingFiles::set) notify.IncomingFiles?.let(incomingFiles::set)
} notify.Health?.let(health::set)
state.collect { currstate ->
readyToPrepareVPN.set(currstate > Ipn.State.Stopped)
tileReady.set(currstate >= Ipn.State.Stopped)
} }
} }
} }
@ -103,6 +103,8 @@ object Notifier {
Prefs(4), Prefs(4),
Netmap(8), Netmap(8),
NoPrivateKey(16), NoPrivateKey(16),
InitialTailFSShares(32) InitialTailFSShares(32),
InitialOutgoingFiles(64),
InitialHealthState(128),
} }
} }

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

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

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

@ -20,19 +20,28 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.titledListItem import com.tailscale.ipn.ui.theme.titledListItem
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
@Composable @Composable
fun ClipboardValueView(value: String, title: String? = null, subtitle: String? = null) { fun ClipboardValueView(value: String, title: String? = null, subtitle: String? = null) {
val localClipboardManager = LocalClipboardManager.current val localClipboardManager = LocalClipboardManager.current
val modifier =
if (isAndroidTV()) {
Modifier
} else {
Modifier.clickable { localClipboardManager.setText(AnnotatedString(value)) }
}
ListItem( ListItem(
colors = MaterialTheme.colorScheme.titledListItem, colors = MaterialTheme.colorScheme.titledListItem,
modifier = Modifier.clickable { localClipboardManager.setText(AnnotatedString(value)) }, modifier = modifier,
overlineContent = { title?.let { Text(it, style = MaterialTheme.typography.titleMedium) } }, overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } },
headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) }, headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) },
supportingContent = { supportingContent =
subtitle?.let { subtitle -> subtitle?.let {
{
Text( Text(
subtitle, it,
modifier = Modifier.padding(top = 8.dp), modifier = Modifier.padding(top = 8.dp),
style = MaterialTheme.typography.bodyMedium) style = MaterialTheme.typography.bodyMedium)
} }

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

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

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

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

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

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

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

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

@ -7,12 +7,14 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.ListItem import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -21,32 +23,40 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.defaultTextColor
import com.tailscale.ipn.ui.theme.link import com.tailscale.ipn.ui.theme.link
import com.tailscale.ipn.ui.util.ClipboardValueView import com.tailscale.ipn.ui.util.ClipboardValueView
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.BugReportViewModel import com.tailscale.ipn.ui.viewModel.BugReportViewModel
@Composable @Composable
fun BugReportView(nav: BackNavigation, model: BugReportViewModel = viewModel()) { fun BugReportView(backToSettings: BackNavigation, model: BugReportViewModel = viewModel()) {
val handler = LocalUriHandler.current val handler = LocalUriHandler.current
val bugReportID = model.bugReportID.collectAsState().value val bugReportID by model.bugReportID.collectAsState()
Scaffold(topBar = { Header(R.string.bug_report_title, onBack = nav.onBack) }) { innerPadding -> Scaffold(topBar = { Header(R.string.bug_report_title, onBack = backToSettings) }) { innerPadding
Column(modifier = Modifier.padding(innerPadding).fillMaxWidth().fillMaxHeight()) { ->
ListItem( Column(
headlineContent = { modifier =
Modifier.padding(innerPadding)
.fillMaxWidth()
.fillMaxHeight()
.verticalScroll(rememberScrollState())) {
Lists.MultilineDescription {
ClickableText( ClickableText(
text = contactText(), text = contactText(),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
onClick = { handler.openUri(Links.SUPPORT_URL) }) onClick = { handler.openUri(Links.SUPPORT_URL) })
}) }
ClipboardValueView(bugReportID, title = stringResource(R.string.bug_report_id))
ClipboardValueView( Lists.InfoItem(stringResource(id = R.string.bug_report_id_desc))
bugReportID,
title = stringResource(R.string.bug_report_id),
subtitle = stringResource(id = R.string.bug_report_id_desc))
} }
} }
} }
@ -54,7 +64,9 @@ fun BugReportView(nav: BackNavigation, model: BugReportViewModel = viewModel())
@Composable @Composable
fun contactText(): AnnotatedString { fun contactText(): AnnotatedString {
val annotatedString = buildAnnotatedString { val annotatedString = buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
append(stringResource(id = R.string.bug_report_instructions_prefix)) append(stringResource(id = R.string.bug_report_instructions_prefix))
}
pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL) pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL)
withStyle( withStyle(
@ -66,7 +78,17 @@ fun contactText(): AnnotatedString {
} }
pop() pop()
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
append(stringResource(id = R.string.bug_report_instructions_suffix)) append(stringResource(id = R.string.bug_report_instructions_suffix))
} }
}
return annotatedString return annotatedString
} }
@Preview
@Composable
fun BugReportPreview() {
val vm = BugReportViewModel()
vm.bugReportID.set("12345678ABCDEF-12345678ABCDEF")
BugReportView({}, vm)
}

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

@ -0,0 +1,153 @@
// 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.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.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)
})
})
ListItem(
colors = MaterialTheme.colorScheme.listItem,
headlineContent = {
Box(modifier = Modifier.fillMaxWidth()) {
Button(
onClick = { onSubmitAction(textVal) },
content = { Text(stringResource(id = R.string.add_account_short)) })
}
})
}
}

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

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

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

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

@ -0,0 +1,80 @@
// 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()
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())
}
}
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))
AppTheme { LoginQRView({}, vm) }
}

@ -12,6 +12,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -24,9 +25,9 @@ import com.tailscale.ipn.ui.viewModel.IpnViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MDMSettingsDebugView(nav: BackNavigation, model: IpnViewModel = viewModel()) { fun MDMSettingsDebugView(backToSettings: BackNavigation, model: IpnViewModel = viewModel()) {
Scaffold(topBar = { Header(R.string.current_mdm_settings, onBack = nav.onBack) }) { innerPadding Scaffold(topBar = { Header(R.string.current_mdm_settings, onBack = backToSettings) }) {
-> innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
itemsWithDividers(MDMSettings.allSettings.sortedBy { "${it::class.java.name}|${it.key}" }) { itemsWithDividers(MDMSettings.allSettings.sortedBy { "${it::class.java.name}|${it.key}" }) {
setting -> setting ->
@ -38,7 +39,7 @@ fun MDMSettingsDebugView(nav: BackNavigation, model: IpnViewModel = viewModel())
@Composable @Composable
fun MDMSettingView(setting: MDMSetting<*>) { fun MDMSettingView(setting: MDMSetting<*>) {
val value = setting.flow.collectAsState().value val value by setting.flow.collectAsState()
ListItem( ListItem(
headlineContent = { Text(setting.localizedTitle, maxLines = 3) }, headlineContent = { Text(setting.localizedTitle, maxLines = 3) },
supportingContent = { supportingContent = {

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

@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.safeContentPadding 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.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -14,6 +16,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
@ -21,13 +24,13 @@ import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.viewModel.IpnViewModel import com.tailscale.ipn.ui.viewModel.IpnViewModel
@Composable @Composable
fun ManagedByView(nav: BackNavigation, model: IpnViewModel = viewModel()) { fun ManagedByView(backToSettings: BackNavigation, model: IpnViewModel = viewModel()) {
Scaffold(topBar = { Header(R.string.managed_by, onBack = nav.onBack) }) { innerPadding -> Scaffold(topBar = { Header(R.string.managed_by, onBack = backToSettings) }) { innerPadding ->
Column( Column(
verticalArrangement = verticalArrangement =
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically), Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
modifier = Modifier.fillMaxWidth().safeContentPadding()) { modifier = Modifier.fillMaxWidth().safeContentPadding().verticalScroll(rememberScrollState())) {
val managedByOrganization = val managedByOrganization =
MDMSettings.managedByOrganizationName.flow.collectAsState().value MDMSettings.managedByOrganizationName.flow.collectAsState().value
val managedByCaption = MDMSettings.managedByCaption.flow.collectAsState().value val managedByCaption = MDMSettings.managedByCaption.flow.collectAsState().value
@ -44,3 +47,10 @@ fun ManagedByView(nav: BackNavigation, model: IpnViewModel = viewModel()) {
} }
} }
} }
@Preview
@Composable
fun ManagedByViewPreview() {
val vm = IpnViewModel()
ManagedByView(backToSettings = {}, vm)
}

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

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

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

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

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

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

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

@ -6,13 +6,15 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
@ -21,42 +23,70 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.BuildConfig import com.tailscale.ipn.BuildConfig
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHide
import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.link import com.tailscale.ipn.ui.theme.link
import com.tailscale.ipn.ui.theme.listItem import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.SettingsViewModel import com.tailscale.ipn.ui.viewModel.SettingsViewModel
@Composable @Composable
fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel()) { fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel()) {
val handler = LocalUriHandler.current val handler = LocalUriHandler.current
val user = viewModel.loggedInUser.collectAsState().value
val isAdmin = viewModel.isAdmin.collectAsState().value val user by viewModel.loggedInUser.collectAsState()
val managedByOrganization = viewModel.managedByOrganization.collectAsState().value val isAdmin by viewModel.isAdmin.collectAsState()
val managedByOrganization by viewModel.managedByOrganization.collectAsState()
val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState()
val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState()
val isVPNPrepared by viewModel.vpnPrepared.collectAsState()
val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState()
Scaffold( Scaffold(
topBar = { topBar = {
Header(titleRes = R.string.settings_title, onBack = settingsNav.onBackPressed) Header(titleRes = R.string.settings_title, onBack = settingsNav.onNavigateBackHome)
}) { innerPadding -> }) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) { Column(modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) {
if (isVPNPrepared){
UserView( UserView(
profile = user, profile = user,
actionState = UserActionState.NAV, actionState = UserActionState.NAV,
onClick = settingsNav.onNavigateToUserSwitcher) onClick = settingsNav.onNavigateToUserSwitcher)
}
if (isAdmin) { if (isAdmin && !isAndroidTV()) {
Lists.ItemDivider()
AdminTextView { handler.openUri(Links.ADMIN_URL) } AdminTextView { handler.openUri(Links.ADMIN_URL) }
} }
Lists.SectionDivider() Lists.SectionDivider()
Setting.Text(R.string.dns_settings, onClick = settingsNav.onNavigateToDNSSettings) Setting.Text(
R.string.dns_settings,
subtitle =
corpDNSEnabled?.let {
stringResource(
if (it) R.string.using_tailscale_dns else R.string.not_using_tailscale_dns)
},
onClick = settingsNav.onNavigateToDNSSettings)
if (showTailnetLock == ShowHide.Show) {
Lists.ItemDivider() Lists.ItemDivider()
Setting.Text(R.string.tailnet_lock, onClick = settingsNav.onNavigateToTailnetLock) Setting.Text(
R.string.tailnet_lock,
subtitle =
tailnetLockEnabled?.let {
stringResource(if (it) R.string.enabled else R.string.disabled)
},
onClick = settingsNav.onNavigateToTailnetLock)
}
Lists.ItemDivider() Lists.ItemDivider()
Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions) Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions)
@ -72,11 +102,15 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
Setting.Text(R.string.bug_report, onClick = settingsNav.onNavigateToBugReport) Setting.Text(R.string.bug_report, onClick = settingsNav.onNavigateToBugReport)
Lists.ItemDivider() Lists.ItemDivider()
Setting.Text(R.string.about_tailscale, onClick = settingsNav.onNavigateToAbout) Setting.Text(
R.string.about_tailscale,
subtitle = "${stringResource(id = R.string.version)} ${BuildConfig.VERSION_NAME}",
onClick = settingsNav.onNavigateToAbout)
// TODO: put a heading for the debug section // TODO: put a heading for the debug section
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Lists.SectionDivider() Lists.SectionDivider()
Lists.MutedHeader(text = stringResource(R.string.internal_debug_options))
Setting.Text(R.string.mdm_settings, onClick = settingsNav.onNavigateToMDMSettings) Setting.Text(R.string.mdm_settings, onClick = settingsNav.onNavigateToMDMSettings)
} }
} }
@ -88,6 +122,7 @@ object Setting {
fun Text( fun Text(
titleRes: Int = 0, titleRes: Int = 0,
title: String? = null, title: String? = null,
subtitle: String? = null,
destructive: Boolean = false, destructive: Boolean = false,
enabled: Boolean = true, enabled: Boolean = true,
onClick: (() -> Unit)? = null onClick: (() -> Unit)? = null
@ -105,7 +140,15 @@ object Setting {
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified) color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified)
}, },
) supportingContent =
subtitle?.let {
{
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
})
} }
@Composable @Composable
@ -133,9 +176,7 @@ object Setting {
@Composable @Composable
fun AdminTextView(onNavigateToAdminConsole: () -> Unit) { fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
val adminStr = buildAnnotatedString { val adminStr = buildAnnotatedString {
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.onSurfaceVariant)) {
append(stringResource(id = R.string.settings_admin_prefix)) append(stringResource(id = R.string.settings_admin_prefix))
}
pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL) pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL)
withStyle( withStyle(
@ -145,14 +186,18 @@ fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
textDecoration = TextDecoration.Underline)) { textDecoration = TextDecoration.Underline)) {
append(stringResource(id = R.string.settings_admin_link)) append(stringResource(id = R.string.settings_admin_link))
} }
pop()
} }
ListItem( Lists.InfoItem(adminStr, onClick = onNavigateToAdminConsole)
headlineContent = { }
ClickableText(
text = adminStr, @Preview
style = MaterialTheme.typography.bodyMedium, @Composable
onClick = { onNavigateToAdminConsole() }) fun SettingsPreview() {
}) val vm = SettingsViewModel()
vm.corpDNSEnabled.set(true)
vm.tailNetLockEnabled.set(true)
vm.isAdmin.set(true)
vm.managedByOrganization.set("Tails and Scales Inc.")
SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
} }

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

@ -19,6 +19,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -57,7 +58,7 @@ fun TaildropView(
when (viewModel.state.collectAsState().value) { when (viewModel.state.collectAsState().value) {
Ipn.State.Running -> { Ipn.State.Running -> {
val peers = viewModel.myPeers.collectAsState().value val peers by viewModel.myPeers.collectAsState()
val context = LocalContext.current val context = LocalContext.current
FileSharePeerList( FileSharePeerList(
peers = peers, peers = peers,

@ -3,7 +3,6 @@
package com.tailscale.ipn.ui.view package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@ -15,6 +14,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@ -24,27 +24,30 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.defaultTextColor
import com.tailscale.ipn.ui.theme.link import com.tailscale.ipn.ui.theme.link
import com.tailscale.ipn.ui.util.ClipboardValueView import com.tailscale.ipn.ui.util.ClipboardValueView
import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.TailnetLockSetupViewModel import com.tailscale.ipn.ui.viewModel.TailnetLockSetupViewModel
import com.tailscale.ipn.ui.viewModel.TailnetLockSetupViewModelFactory import com.tailscale.ipn.ui.viewModel.TailnetLockSetupViewModelFactory
@Composable @Composable
fun TailnetLockSetupView( fun TailnetLockSetupView(
nav: BackNavigation, backToSettings: BackNavigation,
model: TailnetLockSetupViewModel = viewModel(factory = TailnetLockSetupViewModelFactory()) model: TailnetLockSetupViewModel = viewModel(factory = TailnetLockSetupViewModelFactory())
) { ) {
val statusItems = model.statusItems.collectAsState().value val statusItems by model.statusItems.collectAsState()
val nodeKey = model.nodeKey.collectAsState().value val nodeKey by model.nodeKey.collectAsState()
val tailnetLockKey = model.tailnetLockKey.collectAsState().value val tailnetLockKey by model.tailnetLockKey.collectAsState()
val tailnetLockTlPubKey = tailnetLockKey.replace("nlpub", "tlpub")
Scaffold(topBar = { Header(R.string.tailnet_lock, onBack = nav.onBack) }) { innerPadding -> Scaffold(topBar = { Header(R.string.tailnet_lock, onBack = backToSettings) }) { innerPadding ->
LoadingIndicator.Wrap { LoadingIndicator.Wrap {
LazyColumn(modifier = Modifier.padding(innerPadding)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "header") { ExplainerView() } item(key = "header") { ExplainerView() }
@ -75,7 +78,7 @@ fun TailnetLockSetupView(
Lists.SectionDivider() Lists.SectionDivider()
ClipboardValueView( ClipboardValueView(
value = tailnetLockKey, value = tailnetLockTlPubKey,
title = stringResource(R.string.tailnet_lock_key), title = stringResource(R.string.tailnet_lock_key),
subtitle = stringResource(R.string.tailnet_lock_key_explainer)) subtitle = stringResource(R.string.tailnet_lock_key_explainer))
} }
@ -88,21 +91,20 @@ fun TailnetLockSetupView(
private fun ExplainerView() { private fun ExplainerView() {
val handler = LocalUriHandler.current val handler = LocalUriHandler.current
ListItem( Lists.MultilineDescription {
headlineContent = {
Box(modifier = Modifier.padding(vertical = 8.dp)) {
ClickableText( ClickableText(
explainerText(), explainerText(),
onClick = { handler.openUri(Links.TAILNET_LOCK_KB_URL) }, onClick = { handler.openUri(Links.TAILNET_LOCK_KB_URL) },
style = MaterialTheme.typography.bodyMedium) style = MaterialTheme.typography.bodyMedium)
} }
})
} }
@Composable @Composable
fun explainerText(): AnnotatedString { fun explainerText(): AnnotatedString {
val annotatedString = buildAnnotatedString { val annotatedString = buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
append(stringResource(id = R.string.tailnet_lock_explainer)) append(stringResource(id = R.string.tailnet_lock_explainer))
}
pushStringAnnotation(tag = "tailnetLockSupportURL", annotation = Links.TAILNET_LOCK_KB_URL) pushStringAnnotation(tag = "tailnetLockSupportURL", annotation = Links.TAILNET_LOCK_KB_URL)
@ -117,3 +119,12 @@ fun explainerText(): AnnotatedString {
} }
return annotatedString return annotatedString
} }
@Composable
@Preview
fun TailnetLockSetupViewPreview() {
val vm = TailnetLockSetupViewModel()
vm.nodeKey.set("8BADF00D-EA7-1337-DEAD-BEEF")
vm.tailnetLockKey.set("C0FFEE-CAFE-50DA")
TailnetLockSetupView(backToSettings = {}, vm)
}

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

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

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

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

@ -0,0 +1,52 @@
// 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") && 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() }
}
}
}
}
}

@ -20,10 +20,10 @@ import kotlinx.coroutines.launch
import java.util.TreeMap import java.util.TreeMap
data class ExitNodePickerNav( data class ExitNodePickerNav(
val onNavigateHome: () -> Unit, val onNavigateBackHome: () -> Unit,
val onNavigateBack: () -> Unit, val onNavigateBackToExitNodes: () -> Unit,
val onNavigateToExitNodePicker: () -> Unit,
val onNavigateToMullvad: () -> Unit, val onNavigateToMullvad: () -> Unit,
val onNavigateBackToMullvad: () -> Unit,
val onNavigateToMullvadCountry: (String) -> Unit, val onNavigateToMullvadCountry: (String) -> Unit,
val onNavigateToRunAsExitNode: () -> Unit, val onNavigateToRunAsExitNode: () -> Unit,
) )
@ -39,7 +39,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
data class ExitNode( data class ExitNode(
val id: StableNodeID? = null, val id: StableNodeID? = null,
val label: String, val label: String,
val online: Boolean, val online: StateFlow<Boolean>,
val selected: Boolean, val selected: Boolean,
val mullvad: Boolean = false, val mullvad: Boolean = false,
val priority: Int = 0, val priority: Int = 0,
@ -54,7 +54,6 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
val mullvadBestAvailableByCountry: StateFlow<Map<String, ExitNode>> = MutableStateFlow(TreeMap()) val mullvadBestAvailableByCountry: StateFlow<Map<String, ExitNode>> = MutableStateFlow(TreeMap())
val mullvadExitNodeCount: StateFlow<Int> = MutableStateFlow(0) val mullvadExitNodeCount: StateFlow<Int> = MutableStateFlow(0)
val anyActive: StateFlow<Boolean> = MutableStateFlow(false) val anyActive: StateFlow<Boolean> = MutableStateFlow(false)
val isRunningExitNode: StateFlow<Boolean> = MutableStateFlow(false)
init { init {
viewModelScope.launch { viewModelScope.launch {
@ -62,8 +61,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
.combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) } .combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) }
.stateIn(viewModelScope) .stateIn(viewModelScope)
.collect { (netmap, prefs) -> .collect { (netmap, prefs) ->
isRunningExitNode.set(prefs?.let { AdvertisedRoutesHelper.exitNodeOnFromPrefs(it) }) val exitNodeId = prefs?.activeExitNodeID ?: prefs?.selectedExitNodeID
val exitNodeId = prefs?.ExitNodeID
netmap?.Peers?.let { peers -> netmap?.Peers?.let { peers ->
val allNodes = val allNodes =
peers peers
@ -72,7 +70,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
ExitNode( ExitNode(
id = it.StableID, id = it.StableID,
label = it.displayName, label = it.displayName,
online = it.Online ?: false, online = MutableStateFlow(it.Online ?: false),
selected = it.StableID == exitNodeId, selected = it.StableID == exitNodeId,
mullvad = it.Name.endsWith(".mullvad.ts.net."), mullvad = it.Name.endsWith(".mullvad.ts.net."),
priority = it.Hostinfo.Location?.Priority ?: 0, priority = it.Hostinfo.Location?.Priority ?: 0,
@ -86,9 +84,10 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
tailnetExitNodes.set(tailnetNodes.sortedWith { a, b -> a.label.compareTo(b.label) }) tailnetExitNodes.set(tailnetNodes.sortedWith { a, b -> a.label.compareTo(b.label) })
val allMullvadExitNodes = val allMullvadExitNodes =
allNodes.filter { allNodes.filter { node ->
// Pick all mullvad nodes that are online or the currently selected // Pick all mullvad nodes that are online or the currently selected
it.mullvad && (it.selected || it.online) val online = node.online.value
node.mullvad && (node.selected || online)
} }
val mullvadExitNodes = val mullvadExitNodes =
allMullvadExitNodes allMullvadExitNodes
@ -137,8 +136,9 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
LoadingIndicator.start() LoadingIndicator.start()
val prefsOut = Ipn.MaskedPrefs() val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeID = node.id prefsOut.ExitNodeID = node.id
Client(viewModelScope).editPrefs(prefsOut) { Client(viewModelScope).editPrefs(prefsOut) {
nav.onNavigateHome() nav.onNavigateBackHome()
LoadingIndicator.stop() LoadingIndicator.stop()
} }
} }

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

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

@ -3,27 +3,41 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import android.content.Intent
import android.net.VpnService
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Ipn.State import com.tailscale.ipn.ui.model.Ipn.State
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.PeerCategorizer import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Duration
class MainViewModel : IpnViewModel() { class MainViewModel : IpnViewModel() {
// The user readable state of the system // The user readable state of the system
val stateRes: StateFlow<Int> = MutableStateFlow(State.NoState.userStringRes()) val stateRes: StateFlow<Int> = MutableStateFlow(userStringRes(State.NoState, State.NoState, true))
// The expected state of the VPN toggle // The expected state of the VPN toggle
val vpnToggleState: StateFlow<Boolean> = MutableStateFlow(false) private val _vpnToggleState = MutableStateFlow(false)
val vpnToggleState: StateFlow<Boolean> = _vpnToggleState
// Permission to prepare VPN
private var vpnPermissionLauncher: ActivityResultLauncher<Intent>? = null
// The list of peers // The list of peers
val peers: StateFlow<List<PeerSet>> = MutableStateFlow(emptyList<PeerSet>()) val peers: StateFlow<List<PeerSet>> = MutableStateFlow(emptyList<PeerSet>())
@ -31,20 +45,52 @@ class MainViewModel : IpnViewModel() {
// The current state of the IPN for determining view visibility // The current state of the IPN for determining view visibility
val ipnState = Notifier.state val ipnState = Notifier.state
val prefs = Notifier.prefs
val netmap = Notifier.netmap
// The active search term for filtering peers // The active search term for filtering peers
val searchTerm: StateFlow<String> = MutableStateFlow("") val searchTerm: StateFlow<String> = MutableStateFlow("")
// True if we should render the key expiry bannder
val showExpiry: StateFlow<Boolean> = MutableStateFlow(false)
// The peer for which the dropdown menu is currently expanded. Null if no menu is expanded
var expandedMenuPeer: StateFlow<Tailcfg.Node?> = MutableStateFlow(null)
var pingViewModel: PingViewModel = PingViewModel()
fun hidePeerDropdownMenu() {
expandedMenuPeer.set(null)
}
fun copyIpAddress(peer: Tailcfg.Node, clipboardManager: ClipboardManager) {
clipboardManager.setText(AnnotatedString(peer.primaryIPv4Address ?: ""))
}
fun startPing(peer: Tailcfg.Node) {
this.pingViewModel.startPing(peer)
}
fun onPingDismissal() {
this.pingViewModel.handleDismissal()
}
private val peerCategorizer = PeerCategorizer() private val peerCategorizer = PeerCategorizer()
init { init {
viewModelScope.launch { viewModelScope.launch {
Notifier.state.collect { state -> var previousState: State? = null
stateRes.set(state.userStringRes())
vpnToggleState.set( combine(Notifier.state, vpnPrepared) { state, prepared -> state to prepared }
(state == State.Running || state == State.Starting || state == State.NoState)) .collect { (currentState, prepared) ->
stateRes.set(userStringRes(currentState, previousState, prepared))
val isOn =
when {
currentState == State.Running || currentState == State.Starting -> true
previousState == State.NoState && currentState == State.Starting -> true
else -> false
}
_vpnToggleState.value = isOn
previousState = currentState
} }
} }
@ -53,6 +99,18 @@ class MainViewModel : IpnViewModel() {
it?.let { netmap -> it?.let { netmap ->
peerCategorizer.regenerateGroupedPeers(netmap) peerCategorizer.regenerateGroupedPeers(netmap)
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value)) peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value))
if (netmap.SelfNode.keyDoesNotExpire) {
showExpiry.set(false)
return@let
} else {
val expiryNotificationWindowMDM = MDMSettings.keyExpirationNotice.flow.value
val window =
expiryNotificationWindowMDM?.let { TimeUtil.duration(it) } ?: Duration.ofHours(24)
val expiresSoon =
TimeUtil.isWithinExpiryNotificationWindow(window, it.SelfNode.KeyExpiry)
showExpiry.set(expiresSoon)
}
} }
} }
} }
@ -62,27 +120,49 @@ class MainViewModel : IpnViewModel() {
} }
} }
fun showVPNPermissionLauncherIfUnauthorized() {
val vpnIntent = VpnService.prepare(App.get())
if (vpnIntent != null) {
vpnPermissionLauncher?.launch(vpnIntent)
} else {
setVpnPrepared(true)
startVPN()
}
}
fun toggleVpn() {
val state = Notifier.state.value
val isPrepared = vpnPrepared.value
when {
!isPrepared -> showVPNPermissionLauncherIfUnauthorized()
state == Ipn.State.Running -> stopVPN()
state == Ipn.State.NeedsLogin && isAndroidTV() -> login()
else -> startVPN()
}
}
fun searchPeers(searchTerm: String) { fun searchPeers(searchTerm: String) {
this.searchTerm.set(searchTerm) this.searchTerm.set(searchTerm)
} }
fun disableExitNode() { fun setVpnPermissionLauncher(launcher: ActivityResultLauncher<Intent>) {
LoadingIndicator.start() // No intent means we're already authorized
val prefsOut = Ipn.MaskedPrefs() vpnPermissionLauncher = launcher
prefsOut.ExitNodeID = null
Client(viewModelScope).editPrefs(prefsOut) { LoadingIndicator.stop() }
} }
} }
private fun State?.userStringRes(): Int { private fun userStringRes(currentState: State?, previousState: State?, vpnPrepared: Boolean): Int {
return when (this) { return when {
State.NoState -> R.string.waiting previousState == State.NoState && currentState == State.Starting -> R.string.starting
State.InUseOtherUser -> R.string.placeholder currentState == State.NoState -> R.string.placeholder
State.NeedsLogin -> R.string.please_login currentState == State.InUseOtherUser -> R.string.placeholder
State.NeedsMachineAuth -> R.string.placeholder currentState == State.NeedsLogin ->
State.Stopped -> R.string.stopped if (vpnPrepared) R.string.please_login else R.string.connect_to_vpn
State.Starting -> R.string.starting currentState == State.NeedsMachineAuth -> R.string.needs_machine_auth
State.Running -> R.string.connected currentState == State.Stopped -> R.string.stopped
currentState == State.Starting -> R.string.starting
currentState == State.Running -> R.string.connected
else -> R.string.placeholder else -> R.string.placeholder
} }
} }

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

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

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

@ -5,7 +5,9 @@ package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -20,17 +22,37 @@ data class SettingsNav(
val onNavigateToManagedBy: () -> Unit, val onNavigateToManagedBy: () -> Unit,
val onNavigateToUserSwitcher: () -> Unit, val onNavigateToUserSwitcher: () -> Unit,
val onNavigateToPermissions: () -> Unit, val onNavigateToPermissions: () -> Unit,
val onBackPressed: () -> Unit, val onNavigateBackHome: () -> Unit,
val onBackToSettings: () -> Unit,
) )
class SettingsViewModel() : IpnViewModel() { class SettingsViewModel : IpnViewModel() {
// Display name for the logged in user // Display name for the logged in user
val isAdmin: StateFlow<Boolean> = MutableStateFlow(false) val isAdmin: StateFlow<Boolean> = MutableStateFlow(false)
val managedByOrganization = MDMSettings.managedByOrganizationName.flow // True if tailnet lock is enabled. nil if not yet known.
val tailNetLockEnabled: StateFlow<Boolean?> = MutableStateFlow(null)
// True if tailscaleDNS is enabled. nil if not yet known.
val corpDNSEnabled: StateFlow<Boolean?> = MutableStateFlow(null)
init { init {
viewModelScope.launch { viewModelScope.launch {
Notifier.netmap.collect { netmap -> isAdmin.set(netmap?.SelfNode?.isAdmin ?: false) } Notifier.netmap.collect { netmap -> isAdmin.set(netmap?.SelfNode?.isAdmin ?: false) }
} }
Client(viewModelScope).tailnetLockStatus { result ->
result.onSuccess { status -> tailNetLockEnabled.set(status.Enabled) }
LoadingIndicator.stop()
}
viewModelScope.launch {
Notifier.prefs.collect {
it?.let {
corpDNSEnabled.set(it.CorpDNS)
} ?: run {
corpDNSEnabled.set(null)
}
}
}
} }
} }

@ -153,7 +153,7 @@ class TaildropViewModel(
fun TrailingContentForPeer(peerId: String) { fun TrailingContentForPeer(peerId: String) {
// Check our outgoing files for the peer and determine the state of the transfer. // Check our outgoing files for the peer and determine the state of the transfer.
val transfers = this.transfers.collectAsState().value.filter { it.PeerID == peerId } val transfers = this.transfers.collectAsState().value.filter { it.PeerID == peerId }
var status: TransferState = transferState(transfers) ?: return val status: TransferState = transferState(transfers) ?: return
// Still no status? Nothing to render for this peer // Still no status? Nothing to render for this peer

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 641 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 879 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

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

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

@ -1,36 +1,38 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="100" android:viewportWidth="108"
android:viewportHeight="100"> android:viewportHeight="108">
<group android:translateX="20"
android:translateY="20">
<path <path
android:pathData="M15,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0" android:pathData="M34.5,39.37a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
android:fillColor="#FFFDFA"/> android:fillColor="#ffffff"
android:fillAlpha="0.4"/>
<path <path
android:pathData="M30,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0" android:pathData="M49.13,39.37a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
android:fillColor="#FFFDFA"/> android:fillColor="#ffffff"
android:fillAlpha="0.4"/>
<path <path
android:pathData="M15,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0" android:pathData="M63.75,39.37a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
android:fillColor="#54514D"/> android:fillColor="#ffffff"
android:fillAlpha="0.4"/>
<path <path
android:pathData="M45,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0" android:pathData="M34.5,54a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
android:fillColor="#54514D"/> android:fillColor="#ffffff"/>
<path <path
android:pathData="M30,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0" android:pathData="M49.13,54a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
android:fillColor="#FFFDFA"/> android:fillColor="#ffffff"/>
<path <path
android:pathData="M45,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0" android:pathData="M63.75,54a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
android:fillColor="#FFFDFA"/> android:fillColor="#ffffff"/>
<path <path
android:pathData="M15,15.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0" android:pathData="M34.5,68.62a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
android:fillColor="#54514D"/> android:fillColor="#ffffff"
android:fillAlpha="0.4"/>
<path <path
android:pathData="M30,14.9999m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0" android:pathData="M49.13,68.62a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
android:fillColor="#54514D"/> android:fillColor="#ffffff"/>
<path <path
android:pathData="M45,14.9999m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0" android:pathData="M63.75,68.62a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
android:fillColor="#54514D"/> android:fillColor="#ffffff"
</group> android:fillAlpha="0.4"/>
</vector> </vector>

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

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

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M3.9,12c0,-1.71 1.39,-3.1 3.1,-3.1h4L11,7L7,7c-2.76,0 -5,2.24 -5,5s2.24,5 5,5h4v-1.9L7,15.1c-1.71,0 -3.1,-1.39 -3.1,-3.1zM8,13h8v-2L8,11v2zM17,7h-4v1.9h4c1.71,0 3.1,1.39 3.1,3.1s-1.39,3.1 -3.1,3.1h-4L13,17h4c2.76,0 5,-2.24 5,-5s-2.24,-5 -5,-5z"/>
</vector>

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M17,7h-4v1.9h4c1.71,0 3.1,1.39 3.1,3.1 0,1.43 -0.98,2.63 -2.31,2.98l1.46,1.46C20.88,15.61 22,13.95 22,12c0,-2.76 -2.24,-5 -5,-5zM16,11h-2.19l2,2L16,13zM2,4.27l3.11,3.11C3.29,8.12 2,9.91 2,12c0,2.76 2.24,5 5,5h4v-1.9L7,15.1c-1.71,0 -3.1,-1.39 -3.1,-3.1 0,-1.59 1.21,-2.9 2.76,-3.07L8.73,11L8,11v2h2.73L13,15.27L13,17h1.73l4.01,4L20,19.74 3.27,3 2,4.27z"/>
</vector>

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M480,840Q406,840 340.5,811.5Q275,783 226,734Q177,685 148.5,619.5Q120,554 120,480Q120,402 150,334Q180,266 234,217Q246,206 262.5,206.5Q279,207 290,218L508,436Q519,447 519,464Q519,481 508,492Q497,503 480,503Q463,503 452,492L264,304Q234,340 217,384.5Q200,429 200,480Q200,596 282,678Q364,760 480,760Q596,760 678,678Q760,596 760,480Q760,373 691.5,295.5Q623,218 520,204L520,240Q520,257 508.5,268.5Q497,280 480,280Q463,280 451.5,268.5Q440,257 440,240L440,160Q440,143 451.5,131.5Q463,120 480,120Q554,120 619.5,148.5Q685,177 734,226Q783,275 811.5,340.5Q840,406 840,480Q840,554 811.5,619.5Q783,685 734,734Q685,783 619.5,811.5Q554,840 480,840ZM280,520Q263,520 251.5,508.5Q240,497 240,480Q240,463 251.5,451.5Q263,440 280,440Q297,440 308.5,451.5Q320,463 320,480Q320,497 308.5,508.5Q297,520 280,520ZM480,720Q463,720 451.5,708.5Q440,697 440,680Q440,663 451.5,651.5Q463,640 480,640Q497,640 508.5,651.5Q520,663 520,680Q520,697 508.5,708.5Q497,720 480,720ZM680,520Q663,520 651.5,508.5Q640,497 640,480Q640,463 651.5,451.5Q663,440 680,440Q697,440 708.5,451.5Q720,463 720,480Q720,497 708.5,508.5Q697,520 680,520Z"/>
</vector>

@ -2,4 +2,6 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>

@ -2,4 +2,6 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

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

Loading…
Cancel
Save