Compare commits

...

73 Commits

Author SHA1 Message Date
kari-ts 4fa86dbf03
App: tap on notification brings up main view (#407)
Updates tailscale/tailscale#10104

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

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

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

Signed-off-by: kari-ts <kari@tailscale.com>
1 day 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 day 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>
2 days 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>
3 days 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
4 days ago
kari-ts c47ead9412
android: bump version code (#393)
Signed-off-by: kari-ts <kari@tailscale.com>
4 days 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>
4 days ago
kari-ts 5476288100
bump oss and version num for 217 (#390)
* go.mod: update for 217

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

* android: bump version code

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

---------

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

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

* android: bump version code

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

---------

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

Prevents BackgroundServiceStartNotAllowedException.

Updates #cleanup

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

* Use system exempted foreground service type

---------

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

Updates #cleanup

Signed-off-by: Percy Wegmann <percy@tailscale.com>
5 days 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>
1 week ago
Percy Wegmann 698fb868a7 android: only navigate to main if navController is initialized
Updates #cleanup

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

Signed-off-by: kari-ts <kari@tailscale.com>
3 weeks 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>
3 weeks 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>
3 weeks 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>
3 weeks ago
Brad Fitzpatrick 9dda2cc470 go.mod: bump oss, plumb new health.Tracker
Updates tailscale/tailscale#11874

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
3 weeks 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>
3 weeks 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>
3 weeks 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>
3 weeks ago
Percy Wegmann 16fa0e9b9e pull latest OSS
Updates tailscale/corp#19332

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

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

Signed-off-by: Percy Wegmann <percy@tailscale.com>
4 weeks 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>
4 weeks 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>
4 weeks ago
kari-ts 75e2d8983b
Revert "android: pass interface name to go" (#339)
Revert "android: pass interface name to go (#336)"

This reverts commit bbb3c86fa8.
4 weeks 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>
4 weeks ago
Percy Wegmann bc8985126d android: enable Taildrive
Updates tailscale/corp#16827

Signed-off-by: Percy Wegmann <percy@tailscale.com>
4 weeks 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>
1 month ago
kari-ts 81acaef5b7
android: rip android_legacy (#335)
Updates #cleanup
Signed-off-by: kari-ts <kari@tailscale.com>
1 month 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>
1 month 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>
1 month 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>
1 month 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>
1 month 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>
1 month ago
Andrea Gottardo 56da7b66d0
android: bump OSS and bump version code to 206 (#329)
Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
1 month 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>
1 month 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>
1 month 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>
1 month 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>
1 month ago
Andrea Gottardo a73025b36f
mdm: throw ErrNoSuchKey when a value not defined in Android syspolicy handler (#325) 1 month ago
Andrea Gottardo 4d86c1a6f6
ui: don't show key expiry warning if key doesn't expire (#320) 1 month ago
Andrea Gottardo a1d97baeb0
Update README.md (#323) 1 month 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>
1 month 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>
1 month ago

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

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

@ -4,8 +4,7 @@
DEBUG_APK=tailscale-debug.apk
RELEASE_AAB=tailscale-release.aab
KEYSTORE=tailscale.jks
KEYSTORE_ALIAS=tailscale
LIBTAILSCALE=android/libs/libtailscale.aar
TAILSCALE_VERSION=$(shell ./version/tailscale-version.sh 200)
OUR_VERSION=$(shell git describe --dirty --exclude "*" --always --abbrev=200)
TAILSCALE_VERSION_ABBREV=$(shell ./version/tailscale-version.sh 11)
@ -57,6 +56,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.
ifeq ($(JAVA_HOME),)
unexport JAVA_HOME
else
export PATH := $(JAVA_HOME)/bin:$(PATH)
endif
# TOOLCHAINDIR is set by fdoid CI and used by tool/* scripts.
@ -66,11 +67,68 @@ export TOOLCHAINDIR
GOBIN ?= $(PWD)/android/build/go/bin
export GOBIN
export PATH := $(PWD)/tool:$(GOBIN):$(JAVA_HOME)/bin:$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$(PATH)
export PATH := $(PWD)/tool:$(GOBIN):$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$(PATH)
export GOROOT := # Unset
all: tailscale-debug.apk test $(DEBUG_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: tailscale.jks $(RELEASE_AAB) ## Build the release AAB
jarsigner -sigalg SHA256withRSA -digestalg SHA-256 -keystore tailscale.jks $(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:
@echo PATH=$(PATH)
@echo ANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT)
@ -79,12 +137,20 @@ env:
@echo JAVA_HOME=$(JAVA_HOME)
@echo TOOLCHAINDIR=$(TOOLCHAINDIR)
.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
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/build.gradle && rm android/build.gradle.bak
git commit -sm "android: bump version code" android/build.gradle
git tag -a "$(VERSION_LONG)"
.PHONY: bumposs
bumposs: ## Update the tailscale.com go module
GOPROXY=direct go get tailscale.com@main
go run tailscale.com/cmd/printdep --go > go.toolchain.rev
@ -101,6 +167,7 @@ $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager:
mv $(ANDROID_HOME)/tmp/cmdline-tools $(ANDROID_HOME)/cmdline-tools/latest
rm -rf $(ANDROID_HOME)/tmp
.PHONY: androidsdk
androidsdk: $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager ## Install the set of Android SDK packages we need.
yes | $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null
$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --update
@ -109,6 +176,7 @@ androidsdk: $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager ## Install the s
# Normally in make you would simply take a dependency on the task that provides
# the binaries, however users may have a decision to make as to whether they
# want to install an SDK or use the one from an Android Studio installation.
.PHONY: checkandroidsdk
checkandroidsdk: ## Check that Android SDK is installed
@$(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --list_installed | grep -q 'ndk' || (\
echo -e "\n\tERROR: Android SDK not installed.\n\
@ -116,80 +184,32 @@ checkandroidsdk: ## Check that Android SDK is installed
\tANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT)\n\n\
See README.md for instructions on how to install the prerequisites.\n"; exit 1)
androidpath:
@echo "export ANDROID_HOME=$(ANDROID_HOME)"
@echo "export ANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT)"
@echo 'export PATH=$(ANDROID_HOME)/cmdline-tools/latest/bin:$(ANDROID_HOME)/platform-tools:$$PATH'
$(RELEASE_AAB): $(LIBTAILSCALE)
(cd android && ./gradlew test bundleRelease)
mv ./android/build/outputs/bundle/release/android-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
$(LIBTAILSCALE): Makefile android/libs $(LIBTAILSCALE_SOURCES) $(GOBIN)/gomobile
gomobile bind -target android -androidapi 26 \
-ldflags "$(FULL_LDFLAGS)" \
-o $@ ./libtailscale
libtailscale: $(LIBTAILSCALE)
ANDROID_SOURCES=$(shell find android -type f -not -path "android/build/*" -not -path '*/.*')
DEBUG_INTERMEDIARY = android/build/outputs/apk/debug/android-debug.apk
$(DEBUG_INTERMEDIARY): $(ANDROID_SOURCES) $(LIBTAILSCALE)
cd android && ./gradlew test assembleDebug
$(DEBUG_APK): $(DEBUG_INTERMEDIARY)
(cd android && ./gradlew test assembleDebug)
mv $(DEBUG_INTERMEDIARY) $@
tailscale-debug: tailscale-debug.apk ## Build the debug APK
ANDROID_TEST_INTERMEDIARY=./android/build/outputs/apk/androidTest/applicationTest/android-applicationTest-androidTest.apk
$(ANDROID_TEST_INTERMEDIARY): $(ANDROID_SOURCES) $(LIBTAILSCALE)
cd android && ./gradlew assembleApplicationTestAndroidTest
tailscale-test.apk: $(ANDROID_TEST_INTERMEDIARY)
mv $(ANDROID_TEST_INTERMEDIARY) $@
test: $(LIBTAILSCALE) ## Run the Android tests
.PHONY: test
test: gradle-dependencies ## Run the Android tests
(cd android && ./gradlew test)
install: tailscale-debug.apk ## Install the debug APK on a connected device
.PHONY: install
install: $(DEBUG_APK) ## Install the debug APK on a connected device
adb install -r $<
.PHONY: run
run: install ## Run the debug APK on a connected device
adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.IPNActivity
adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.MainActivity
.PHONY: dockershell
dockershell: ## Run a shell in the Docker build container
docker build -t tailscale-android .
docker run -v $(CURDIR):/build/tailscale-android -it --rm tailscale-android
.PHONY: clean
clean: ## Remove build artifacts
-rm -rf android/build $(DEBUG_APK) $(RELEASE_AAB) $(LIBTAILSCALE) android/libs *.apk
-rm -rf android/build $(DEBUG_APK) $(RELEASE_AAB) $(LIBTAILSCALE) android/libs *.apk *.aab
-pkill -f gradle
.PHONY: help
help: ## Show this help
@echo "\nSpecify a command. The choices are:\n"
@grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' ${MAKEFILE_LIST} | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-20s\033[m %s\n", $$1, $$2}'
@echo ""
.PHONY: all clean install android_legacy/lib $(DEBUG_APK) $(RELEASE_AAB) release bump_version dockershell lib tailscale-debug help
.DEFAULT_GOAL := help

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

@ -37,8 +37,8 @@ android {
defaultConfig {
minSdkVersion 26
targetSdkVersion 34
versionCode 204
versionName "1.65.0-t220764331-g50bb25aca2d"
versionCode 220
versionName "1.67.30-t6831a29f8-g8acfc1f7a07"
}
compileOptions {
@ -85,6 +85,7 @@ dependencies {
// Kotlin dependencies.
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core: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-reflect:$kotlin_version"
@ -92,7 +93,6 @@ dependencies {
// Compose dependencies.
def composeBom = platform('androidx.compose:compose-bom:2023.06.01')
implementation composeBom
androidTestImplementation composeBom
implementation 'androidx.compose.material3:material3:1.2.1'
implementation 'androidx.compose.material:material-icons-core:1.6.3'
implementation "androidx.compose.ui:ui:1.6.3"
@ -115,25 +115,32 @@ dependencies {
// Tailscale dependencies.
implementation ':libtailscale@aar'
// Tests
testImplementation 'junit:junit:4.13.2'
// Integration Tests
androidTestImplementation composeBom
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'
// Authentication only for tests
androidTestImplementation 'dev.turingcomplete:kotlin-onetimepassword:2.4.0'
androidTestImplementation 'commons-codec:commons-codec:1.16.1'
// Unit Tests
testImplementation 'junit:junit:4.13.2'
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);
return properties.getProperty(key)
} catch(Throwable ignored) {
return ""
}
}
}

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

@ -3,35 +3,22 @@
package com.tailscale.ipn
import android.Manifest
import android.app.Activity
import android.app.Application
import android.app.DownloadManager
import android.app.Fragment
import android.app.FragmentTransaction
import android.app.Notification
import android.app.NotificationChannel
import android.app.PendingIntent
import android.app.UiModeManager
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.Uri
import android.net.VpnService
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.provider.Settings
import android.util.Log
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
@ -47,6 +34,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import libtailscale.Libtailscale
import java.io.File
import java.io.IOException
@ -55,40 +44,31 @@ import java.net.NetworkInterface
import java.security.GeneralSecurityException
import java.util.Locale
class App : Application(), libtailscale.AppContext {
class App : UninitializedApp(), libtailscale.AppContext {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
companion object {
const val STATUS_CHANNEL_ID = "tailscale-status"
const val STATUS_NOTIFICATION_ID = 1
const val NOTIFY_CHANNEL_ID = "tailscale-notify"
const val NOTIFY_NOTIFICATION_ID = 2
private const val PEER_TAG = "peer"
private const val FILE_CHANNEL_ID = "tailscale-files"
private const val FILE_NOTIFICATION_ID = 3
private const val TAG = "App"
private val networkConnectivityRequest =
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
.build()
lateinit var appInstance: App
@JvmStatic
fun startActivityForResult(act: Activity, intent: Intent?, request: Int) {
val f: Fragment = act.fragmentManager.findFragmentByTag(PEER_TAG)
f.startActivityForResult(intent, request)
}
private lateinit var appInstance: App
/**
* Initializes the app (if necessary) and returns the singleton app instance. Always use this
* function to obtain an App reference to make sure the app initializes.
*/
@JvmStatic
fun getApplication(): App {
fun get(): App {
appInstance.initOnce()
return appInstance
}
}
val dns = DnsConfig()
var autoConnect = false
var vpnReady = false
private lateinit var connectivityManager: ConnectivityManager
private lateinit var app: libtailscale.Application
@ -102,6 +82,35 @@ class App : Application(), libtailscale.AppContext {
override fun onCreate() {
super.onCreate()
createNotificationChannel(
STATUS_CHANNEL_ID,
getString(R.string.vpn_status),
getString(R.string.optional_notifications_which_display_the_status_of_the_vpn_tunnel),
NotificationManagerCompat.IMPORTANCE_MIN)
createNotificationChannel(
FILE_CHANNEL_ID,
getString(R.string.taildrop_file_transfers),
getString(R.string.notifications_delivered_when_a_file_is_received_using_taildrop),
NotificationManagerCompat.IMPORTANCE_DEFAULT)
appInstance = this
setUnprotectedInstance(this)
}
override fun onTerminate() {
super.onTerminate()
Notifier.stop()
applicationScope.cancel()
}
private var isInitialized = false
@Synchronized
private fun initOnce() {
if (isInitialized) {
return
}
isInitialized = true
val dataDir = this.filesDir.absolutePath
// Set this to enable direct mode for taildrop whereby downloads will be saved directly
@ -109,35 +118,26 @@ class App : Application(), libtailscale.AppContext {
// an app local directory "Taildrop" if we cannot create that. This mode does not support
// user notifications for incoming files.
val directFileDir = this.prepareDownloadsFolder()
app = Libtailscale.start(dataDir, directFileDir.absolutePath, this)
Request.setApp(app)
Notifier.setApp(app)
Notifier.start(applicationScope)
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
setAndRegisterNetworkCallbacks()
createNotificationChannel(
NOTIFY_CHANNEL_ID, "Notifications", NotificationManagerCompat.IMPORTANCE_DEFAULT)
createNotificationChannel(
STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW)
createNotificationChannel(
FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT)
appInstance = this
applicationScope.launch {
Notifier.tileReady.collect { isTileReady -> setTileReady(isTileReady) }
Notifier.state.collect { state ->
val ableToStartVPN = state > Ipn.State.NeedsMachineAuth
val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running
updateConnStatus(ableToStartVPN, vpnRunning)
QuickToggleService.setVPNRunning(vpnRunning)
}
}
}
override fun onTerminate() {
super.onTerminate()
Notifier.stop()
applicationScope.cancel()
}
fun setWantRunning(wantRunning: Boolean) {
val callback: (Result<Ipn.Prefs>) -> Unit = { result ->
result.fold(
onSuccess = { _ -> setTileStatus(wantRunning) },
onSuccess = {},
onFailure = { error ->
Log.d("TAG", "Set want running: failed to update preferences: ${error.message}")
})
@ -168,31 +168,19 @@ class App : Application(), libtailscale.AppContext {
}
if (dns.updateDNSFromNetwork(sb.toString())) {
Libtailscale.onDnsConfigChanged()
Libtailscale.onDNSConfigChanged(linkProperties?.interfaceName)
}
}
override fun onLost(network: Network) {
super.onLost(network)
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
// library and writes it to a global encrypted preference store.
@Throws(IOException::class, GeneralSecurityException::class)
@ -219,30 +207,16 @@ class App : Application(), libtailscale.AppContext {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
}
fun setTileReady(ready: Boolean) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return
}
QuickToggleService.setReady(this, ready)
Log.d("App", "Set Tile Ready: $ready $autoConnect")
vpnReady = ready
if (ready && autoConnect) {
startVPN()
}
}
fun setTileStatus(status: Boolean) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return
}
QuickToggleService.setStatus(this, status)
}
fun getHostname(): String {
val userConfiguredDeviceName = getUserConfiguredDeviceName()
if (!userConfiguredDeviceName.isNullOrEmpty()) return userConfiguredDeviceName
return modelName
/*
* setAbleToStartVPN remembers whether or not we're able to start the VPN
* by storing this in a shared preference. This allows us to check this
* value without needing a fully initialized instance of the application.
*/
private fun updateConnStatus(ableToStartVPN: Boolean, vpnRunning: Boolean) {
setAbleToStartVPN(ableToStartVPN)
QuickToggleService.updateTile()
Log.d("App", "Set Tile Ready: $ableToStartVPN")
notifyStatus(vpnRunning)
}
override fun getModelName(): String {
@ -258,138 +232,10 @@ class App : Application(), libtailscale.AppContext {
override fun getOSVersion(): String = Build.VERSION.RELEASE
// get user defined nickname from Settings
// returns null if not available
private fun getUserConfiguredDeviceName(): String? {
val nameFromSystemDevice = Settings.Secure.getString(contentResolver, "device_name")
if (!nameFromSystemDevice.isNullOrEmpty()) return nameFromSystemDevice
return null
}
// attachPeer adds a Peer fragment for tracking the Activity
// lifecycle.
fun attachPeer(act: Activity) {
act.runOnUiThread(
Runnable {
val ft: FragmentTransaction = act.fragmentManager.beginTransaction()
ft.add(Peer(), PEER_TAG)
ft.commit()
act.fragmentManager.executePendingTransactions()
})
}
override fun isChromeOS(): Boolean {
return packageManager.hasSystemFeature("android.hardware.type.pc")
}
fun prepareVPN(act: Activity, reqCode: Int) {
act.runOnUiThread(
Runnable {
val intent: Intent? = VpnService.prepare(act)
if (intent == null) {
startVPN()
} else {
startActivityForResult(act, intent, reqCode)
}
})
}
fun showURL(act: Activity, url: String?) {
act.runOnUiThread(
Runnable {
val builder: CustomTabsIntent.Builder = CustomTabsIntent.Builder()
val headerColor = -0xb69b6b
builder.setToolbarColor(headerColor)
val intent: CustomTabsIntent = builder.build()
intent.launchUrl(act, Uri.parse(url))
})
}
@get:Throws(Exception::class)
val packageCertificate: ByteArray?
// getPackageSignatureFingerprint returns the first package signing certificate, if any.
get() {
val info: PackageInfo
info = packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES)
for (signature in info.signatures) {
return signature.toByteArray()
}
return null
}
@Throws(IOException::class)
fun insertMedia(name: String?, mimeType: String): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver: ContentResolver = contentResolver
val contentValues = ContentValues()
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name)
if ("" != mimeType) {
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
}
val root: Uri = MediaStore.Files.getContentUri("external")
resolver.insert(root, contentValues).toString()
} else {
val dir: File = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
dir.mkdirs()
val f = File(dir, name)
Uri.fromFile(f).toString()
}
}
@Throws(IOException::class)
fun openUri(uri: String?, mode: String?): Int? {
val resolver: ContentResolver = contentResolver
return mode?.let { resolver.openFileDescriptor(Uri.parse(uri), it)?.detachFd() }
}
fun deleteUri(uri: String?) {
val resolver: ContentResolver = contentResolver
resolver.delete(Uri.parse(uri), null, null)
}
fun notifyFile(uri: String?, msg: String?) {
val viewIntent: Intent
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
viewIntent = Intent(Intent.ACTION_VIEW, Uri.parse(uri))
} else {
// uri is a file:// which is not allowed to be shared outside the app.
viewIntent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS)
}
val pending: PendingIntent =
PendingIntent.getActivity(this, 0, viewIntent, PendingIntent.FLAG_UPDATE_CURRENT)
val builder: NotificationCompat.Builder =
NotificationCompat.Builder(this, FILE_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("File received")
.setContentText(msg)
.setContentIntent(pending)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
val nm: NotificationManagerCompat = NotificationManagerCompat.from(this)
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED) {
// TODO: Consider calling
// ActivityCompat#requestPermissions
// here to request the missing permissions, and then overriding
// public void onRequestPermissionsResult(int requestCode, String[] permissions,
// int[] grantResults)
// to handle the case where the user grants the permission. See the documentation
// for ActivityCompat#requestPermissions for more details.
return
}
nm.notify(FILE_NOTIFICATION_ID, builder.build())
}
fun createNotificationChannel(id: String?, name: String?, importance: Int) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val channel = NotificationChannel(id, name, importance)
val nm: NotificationManagerCompat = NotificationManagerCompat.from(this)
nm.createNotificationChannel(channel)
}
override fun getInterfacesAsString(): String {
val interfaces: ArrayList<NetworkInterface> =
java.util.Collections.list(NetworkInterface.getNetworkInterfaces())
@ -425,12 +271,7 @@ class App : Application(), libtailscale.AppContext {
return sb.toString()
}
fun isTV(): Boolean {
val mm = getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
return mm.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
}
fun prepareDownloadsFolder(): File {
private fun prepareDownloadsFolder(): File {
var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
try {
@ -453,17 +294,143 @@ class App : Application(), libtailscale.AppContext {
return downloads
}
@Throws(IOException::class, GeneralSecurityException::class)
@Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyBooleanValue(key: String): Boolean {
return getSyspolicyStringValue(key) == "true"
}
@Throws(IOException::class, GeneralSecurityException::class)
@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. Returning empty.")
""
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_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
@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
val nm: NotificationManagerCompat = NotificationManagerCompat.from(this)
nm.createNotificationChannel(channel)
}
protected fun notifyStatus(vpnRunning: Boolean) {
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
}
val nm: NotificationManagerCompat = NotificationManagerCompat.from(this)
nm.notify(STATUS_NOTIFICATION_ID, buildStatusNotification(vpnRunning))
}
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()
}
}

@ -12,6 +12,9 @@ import androidx.work.WorkManager;
import java.util.Objects;
/**
* IPNReceiver allows external applications to start the VPN.
*/
public class IPNReceiver extends BroadcastReceiver {
public static final String INTENT_CONNECT_VPN = "com.tailscale.ipn.CONNECT_VPN";

@ -8,8 +8,6 @@ import android.content.pm.PackageManager
import android.net.VpnService
import android.os.Build
import android.system.OsConstants
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import libtailscale.Libtailscale
import java.util.UUID
@ -20,35 +18,49 @@ open class IPNService : VpnService(), libtailscale.IPNService {
return randomID
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent != null && ACTION_STOP_VPN == intent.action) {
(applicationContext as App).autoConnect = false
close()
return START_NOT_STICKY
}
val app = applicationContext as App
if (intent != null && "android.net.VpnService" == intent.action) {
// Start VPN and connect to it due to Always-on VPN
val i = Intent(IPNReceiver.INTENT_CONNECT_VPN)
i.setPackage(packageName)
i.setClass(applicationContext, IPNReceiver::class.java)
sendBroadcast(i)
Libtailscale.requestVPN(this)
app.setWantRunning(true)
return START_STICKY
}
Libtailscale.requestVPN(this)
if (app.vpnReady && app.autoConnect) {
app.setWantRunning(true)
}
return START_STICKY
override fun onCreate() {
super.onCreate()
// grab app to make sure it initializes
App.get()
}
override public fun close() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
when (intent?.action) {
ACTION_STOP_VPN -> {
App.get().setWantRunning(false)
close()
START_NOT_STICKY
}
ACTION_START_VPN -> {
showForegroundNotification()
App.get().setWantRunning(true)
Libtailscale.requestVPN(this)
START_STICKY
}
"android.net.VpnService" -> {
// This means we were started by Android due to Always On VPN.
// We don't show a foreground notification because we weren't
// started as a foreground service.
App.get().setWantRunning(true)
Libtailscale.requestVPN(this)
START_STICKY
}
else -> {
// This means that we were restarted after the service was killed
// (potentially due to OOM).
if (UninitializedApp.get().isAbleToStartVPN()) {
App.get()
Libtailscale.requestVPN(this)
START_STICKY
} else {
START_NOT_STICKY
}
}
}
override fun close() {
stopForeground(true)
Libtailscale.serviceDisconnect(this)
val app = applicationContext as App
app.setWantRunning(false)
}
override fun onDestroy() {
@ -61,6 +73,12 @@ open class IPNService : VpnService(), libtailscale.IPNService {
super.onRevoke()
}
private fun showForegroundNotification() {
startForeground(
UninitializedApp.STATUS_NOTIFICATION_ID,
UninitializedApp.get().buildStatusNotification(true))
}
private fun configIntent(): PendingIntent {
return PendingIntent.getActivity(
this,
@ -81,10 +99,10 @@ open class IPNService : VpnService(), libtailscale.IPNService {
.setConfigureIntent(configIntent())
.allowFamily(OsConstants.AF_INET)
.allowFamily(OsConstants.AF_INET6)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
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.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
b.setMetered(false) // Inherit the metered status from the underlying networks.
}
b.setUnderlyingNetworks(null) // Use all available networks.
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
disallowApp(b, "com.google.android.apps.messaging")
@ -107,33 +125,8 @@ open class IPNService : VpnService(), libtailscale.IPNService {
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 {
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"
}
}

@ -11,7 +11,6 @@ import android.content.RestrictionsManager
import android.content.pm.ActivityInfo
import android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE
import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK
import android.net.Uri
import android.net.VpnService
import android.os.Bundle
import android.provider.Settings
@ -20,6 +19,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.viewModels
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@ -39,7 +39,6 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.tailscale.ipn.Peer.RequestCodes
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
@ -53,6 +52,8 @@ import com.tailscale.ipn.ui.view.DNSSettingsView
import com.tailscale.ipn.ui.view.ExitNodePicker
import com.tailscale.ipn.ui.view.IntroView
import com.tailscale.ipn.ui.view.LoginQRView
import com.tailscale.ipn.ui.view.LoginWithAuthKeyView
import com.tailscale.ipn.ui.view.LoginWithCustomControlURLView
import com.tailscale.ipn.ui.view.MDMSettingsDebugView
import com.tailscale.ipn.ui.view.MainView
import com.tailscale.ipn.ui.view.MainViewNavigation
@ -64,8 +65,10 @@ import com.tailscale.ipn.ui.view.PermissionsView
import com.tailscale.ipn.ui.view.RunExitNodeView
import com.tailscale.ipn.ui.view.SettingsView
import com.tailscale.ipn.ui.view.TailnetLockSetupView
import com.tailscale.ipn.ui.view.UserSwitcherNav
import com.tailscale.ipn.ui.view.UserSwitcherView
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.SettingsNav
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
@ -76,6 +79,8 @@ import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private lateinit var requestVpnPermission: ActivityResultLauncher<Unit>
private lateinit var navController: NavHostController
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
private val viewModel: MainViewModel by viewModels()
companion object {
private const val TAG = "Main Activity"
@ -96,6 +101,9 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
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
// proper landscape layout support
if (!isLandscapeCapable()) {
@ -104,6 +112,18 @@ class MainActivity : ComponentActivity() {
installSplashScreen()
vpnPermissionLauncher =
registerForActivityResult(VpnPermissionContract()) { granted ->
if (granted) {
Log.d("VpnPermission", "VPN permission granted")
viewModel.setVpnPrepared(true)
} else {
Log.d("VpnPermission", "VPN permission denied")
viewModel.setVpnPrepared(false)
}
}
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher)
setContent {
AppTheme {
navController = rememberNavController()
@ -161,8 +181,17 @@ class MainActivity : ComponentActivity() {
onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") },
onNavigateToRunAsExitNode = { navController.navigate("runExitNode") })
val userSwitcherNav =
UserSwitcherNav(
backToSettings = backTo("settings"),
onNavigateHome = backTo("main"),
onNavigateCustomControl = {
navController.navigate("loginWithCustomControl")
},
onNavigateToAuthKey = { navController.navigate("loginWithAuthKey") })
composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) {
MainView(loginAtUrl = ::login, navigation = mainViewNav)
MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel)
}
composable("settings") { SettingsView(settingsNav) }
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
@ -186,15 +215,20 @@ class MainActivity : ComponentActivity() {
composable("about") { AboutView(backTo("settings")) }
composable("mdmSettings") { MDMSettingsDebugView(backTo("settings")) }
composable("managedBy") { ManagedByView(backTo("settings")) }
composable("userSwitcher") {
UserSwitcherView(backTo("settings"), backTo("main"))
}
composable("userSwitcher") { UserSwitcherView(userSwitcherNav) }
composable("permissions") {
PermissionsView(backTo("settings"), ::openApplicationSettings)
}
composable("intro", exitTransition = { fadeOut(animationSpec = tween(150)) }) {
IntroView(backTo("main"))
}
composable("loginWithAuthKey") {
LoginWithAuthKeyView(onNavigateHome = backTo("main"), backTo("userSwitcher"))
}
composable("loginWithCustomControl") {
LoginWithCustomControlURLView(
onNavigateHome = backTo("main"), backTo("userSwitcher"))
}
}
// Show the intro screen one time
@ -212,12 +246,6 @@ class MainActivity : ComponentActivity() {
}
}
}
lifecycleScope.launch {
Notifier.readyToPrepareVPN.collect { isReady ->
if (isReady)
App.getApplication().prepareVPN(this@MainActivity, RequestCodes.requestPrepareVPN)
}
}
}
init {
@ -247,14 +275,16 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
if (intent?.getBooleanExtra(START_AT_ROOT, false) == true) {
navController.popBackStack(route = "main", inclusive = false)
if (this::navController.isInitialized) {
navController.popBackStack(route = "main", inclusive = false)
}
}
}
private fun login(urlString: String) {
// Launch coroutine to listen for state changes. When the user completes login, relaunch
// MainActivity to bring the app back to focus.
App.getApplication().applicationScope.launch {
App.get().applicationScope.launch {
try {
Notifier.state.collect { state ->
if (state > Ipn.State.NeedsMachineAuth) {
@ -295,45 +325,25 @@ class MainActivity : ComponentActivity() {
super.onResume()
val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
lifecycleScope.launch(Dispatchers.IO) {
MDMSettings.update(App.getApplication(), restrictionsManager)
}
lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
}
override fun onStart() {
super.onStart()
// (jonathan) TODO: Requesting VPN permissions onStart is a bit aggressive. This should
// be done when the user initiall starts the VPN
requestVpnPermission()
}
override fun onStop() {
super.onStop()
val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
lifecycleScope.launch(Dispatchers.IO) {
MDMSettings.update(App.getApplication(), restrictionsManager)
}
}
private fun requestVpnPermission() {
val vpnIntent = VpnService.prepare(this)
if (vpnIntent != null) {
val contract = VpnPermissionContract()
requestVpnPermission =
registerForActivityResult(contract) { granted ->
Log.i("VPN", "VPN permission ${if (granted) "granted" else "denied"}")
}
requestVpnPermission.launch(Unit)
}
lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
}
private fun openApplicationSettings() {
val intent =
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", packageName, null))
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
}
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
@ -350,9 +360,9 @@ class MainActivity : ComponentActivity() {
}
}
class VpnPermissionContract : ActivityResultContract<Unit, Boolean>() {
override fun createIntent(context: Context, input: Unit): Intent {
return VpnService.prepare(context) ?: Intent()
class VpnPermissionContract : ActivityResultContract<Intent, Boolean>() {
override fun createIntent(context: Context, input: Intent): Intent {
return input
}
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {

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

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

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

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

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

@ -11,42 +11,78 @@ import kotlin.reflect.full.isSubclassOf
import kotlin.reflect.jvm.jvmErasure
object MDMSettings {
// The String message used in this NoSuchKeyException must match the value of
// syspolicy.ErrNoSuchKey defined in Go, 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")
// Handled on the backed
val exitNodeID = StringMDMSetting("ExitNodeID", "Forced Exit Node: Stable ID")
// (jonathan) TODO: Unused but required. There is some funky go string duration parsing required
// here.
val keyExpirationNotice = StringMDMSetting("KeyExpirationNotice", "Key Expiration Notice Period")
val loginURL = StringMDMSetting("LoginURL", "Custom control server URL")
val managedByCaption = StringMDMSetting("ManagedByCaption", "Managed By - Caption")
val managedByOrganizationName =
StringMDMSetting("ManagedByOrganizationName", "Managed By - Organization Name")
val managedByURL = StringMDMSetting("ManagedByURL", "Managed By - Support URL")
// Handled on the backend
val tailnet = StringMDMSetting("Tailnet", "Recommended/Required Tailnet Name")
val hiddenNetworkDevices =
StringArrayListMDMSetting("HiddenNetworkDevices", "Hidden Network Device Categories")
// Unused on Android
val allowIncomingConnections =
AlwaysNeverUserDecidesMDMSetting("AllowIncomingConnections", "Allow Incoming Connections")
// Unused on Android
val detectThirdPartyAppConflicts =
AlwaysNeverUserDecidesMDMSetting(
"DetectThirdPartyAppConflicts", "Detect potentially problematic third-party apps")
val exitNodeAllowLANAccess =
AlwaysNeverUserDecidesMDMSetting(
"ExitNodeAllowLANAccess", "Allow LAN Access when using an exit node")
// Handled on the backend
val postureChecking =
AlwaysNeverUserDecidesMDMSetting("PostureChecking", "Enable Posture Checking")
val useTailscaleDNSSettings =
AlwaysNeverUserDecidesMDMSetting("UseTailscaleDNSSettings", "Use Tailscale DNS Settings")
// Unused on Android
val useTailscaleSubnets =
AlwaysNeverUserDecidesMDMSetting("UseTailscaleSubnets", "Use Tailscale Subnets")
val exitNodesPicker = ShowHideMDMSetting("ExitNodesPicker", "Exit Nodes Picker")
val manageTailnetLock = ShowHideMDMSetting("ManageTailnetLock", "“Manage Tailnet lock” menu item")
// Unused on Android
val resetToDefaults = ShowHideMDMSetting("ResetToDefaults", "“Reset to Defaults” menu item")
val runExitNode = ShowHideMDMSetting("RunExitNode", "Run as Exit Node")
// Unused on Android
val testMenu = ShowHideMDMSetting("TestMenu", "Show Debug Menu")
// Unused on Android
val updateMenu = ShowHideMDMSetting("UpdateMenu", "“Update Available” menu item")
// (jonathan) TODO: Use this when suggested exit nodes are implemented
val allowedSuggestedExitNodes =
StringArrayListMDMSetting("AllowedSuggestedExitNodes", "Allowed Suggested Exit Nodes")
val allSettings by lazy {
MDMSettings::class
.declaredMemberProperties

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

@ -46,6 +46,7 @@ private object Endpoint {
const val FILES = "files"
const val FILE_PUT = "file-put"
const val TAILFS_SERVER_ADDRESS = "tailfs/fileserver-address"
const val ENABLE_EXIT_NODE = "set-use-exit-node-enabled"
}
typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit
@ -85,6 +86,11 @@ class Client(private val scope: CoroutineScope) {
return patch(Endpoint.PREFS, body, responseHandler = responseHandler)
}
fun setUseExitNode(use: Boolean, responseHandler: (Result<Ipn.Prefs>) -> Unit) {
val path = "${Endpoint.ENABLE_EXIT_NODE}?enabled=$use"
return post(path, responseHandler = responseHandler)
}
fun profiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit) {
get(Endpoint.PROFILES, responseHandler = responseHandler)
}

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

@ -112,6 +112,9 @@ class Tailcfg {
val displayName: String
get() = ComputedName ?: Name
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?) =
@ -142,7 +145,13 @@ class Tailcfg {
PeerSettingInfo(R.string.os, ComposableStringFormatter(Hostinfo.OS!!)),
)
}
result.add(PeerSettingInfo(R.string.key_expiry, TimeUtil.keyExpiryFromGoTime(KeyExpiry)))
if (keyDoesNotExpire) {
result.add(
PeerSettingInfo(
R.string.key_expiry, ComposableStringFormatter(R.string.deviceKeyNeverExpires)))
} else {
result.add(PeerSettingInfo(R.string.key_expiry, TimeUtil.keyExpiryFromGoTime(KeyExpiry)))
}
return result
}

@ -4,6 +4,7 @@
package com.tailscale.ipn.ui.notifier
import android.util.Log
import com.tailscale.ipn.App
import com.tailscale.ipn.ui.model.Empty
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Ipn.Notify
@ -30,10 +31,6 @@ object Notifier {
private val TAG = Notifier::class.simpleName
private val decoder = Json { ignoreUnknownKeys = true }
// Global App State
val tileReady: StateFlow<Boolean> = MutableStateFlow(false)
val readyToPrepareVPN: StateFlow<Boolean> = MutableStateFlow(false)
// General IPN Bus State
val state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState)
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
@ -60,6 +57,9 @@ object Notifier {
@OptIn(ExperimentalSerializationApi::class)
fun start(scope: CoroutineScope) {
Log.d(TAG, "Starting")
if (!::app.isInitialized) {
App.get()
}
scope.launch(Dispatchers.IO) {
val mask =
NotifyWatchOpt.Netmap.value or
@ -80,10 +80,6 @@ object Notifier {
notify.FilesWaiting?.let(filesWaiting::set)
notify.IncomingFiles?.let(incomingFiles::set)
}
state.collect { currstate ->
readyToPrepareVPN.set(currstate > Ipn.State.Stopped)
tileReady.set(currstate >= Ipn.State.Stopped)
}
}
}

@ -86,28 +86,28 @@ private val LightColors =
private val DarkColors =
darkColorScheme(
primary = Color(0xFF3f5eb3), // blue-600
primary = Color(0xFF3E5DB3), // blue-600
onPrimary = Color(0xFFFFFFFF), // white
primaryContainer = Color(0xFFf0f5ff), // blue-0
onPrimaryContainer = Color(0xFF3f5eb3), // blue-600
error = Color(0xFF940822), // red-600
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(0xFF1f1e1e), // gray-900
background = Color(0xFF181717), // gray-1000
surfaceBright = Color(0xFF444342), // gray-600
surfaceContainerLowest = Color(0xFF232222), // gray-800
surfaceContainerLow = Color(0xFF2e2d2d), // gray-700
surfaceContainer = Color(0xFF2e2d2d), // gray-700
surfaceContainerHigh = Color(0xFF2e2d2d), // gray-700
surfaceContainerHighest = 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(0xFF444342), // gray-600
outlineVariant = Color(0xFF2E2D2D), // gray-700
inverseSurface = Color(0xFFEDEBEA), // gray-200
inverseOnSurface = Color(0xFF000000), // black
scrim = Color(0xAA000000), // black
@ -144,7 +144,7 @@ val ColorScheme.off: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFFAFACAB) // gray-400
Color(0xFF444342) // gray-600
} else {
Color(0xFFD9D6D5) // gray-300
}
@ -248,7 +248,7 @@ val ColorScheme.warningListItem: ListItemColors
containerColor = MaterialTheme.colorScheme.warning,
headlineColor = MaterialTheme.colorScheme.onPrimary,
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f),
supportingTextColor = MaterialTheme.colorScheme.onPrimary,
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
disabledHeadlineColor = default.disabledHeadlineColor,
@ -272,11 +272,19 @@ val ColorScheme.secondaryButton: ButtonColors
@Composable
get() {
val defaults = ButtonDefaults.buttonColors()
return ButtonColors(
containerColor = Color(0xFF6D94EC), // blue-400
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
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.defaultTextColor: Color
@ -333,6 +341,25 @@ val ColorScheme.onBackgroundLogoDotDisabled: Color
Color(0x66FFFFFF)
}
val ColorScheme.exitNodeToggleButton: ButtonColors
@Composable
get() {
val defaults = ButtonDefaults.buttonColors()
return if (isSystemInDarkTheme()) {
ButtonColors(
containerColor = Color(0xFF444342), // grey-600
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
} else {
ButtonColors(
containerColor = Color(0xFFEDEBEA), // grey-300
contentColor = Color(0xFF000000), // black
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
}
}
val ColorScheme.disabled: Color
get() = Color(0xFFAFACAB) // gray-400

@ -9,13 +9,14 @@ 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.App
import com.tailscale.ipn.UninitializedApp
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
object AndroidTVUtil {
fun isAndroidTV(): Boolean {
return (App.appInstance.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEVISION) ||
App.appInstance.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
val pm = UninitializedApp.get().packageManager
return (pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION) ||
pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
}
}

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

@ -3,6 +3,7 @@
package com.tailscale.ipn.ui.util
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.model.UserID
@ -19,21 +20,41 @@ class PeerCategorizer {
val selfNode = netmap.SelfNode
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)) {
// (jonathan) TODO: MDM -> There are a number of MDM settings to hide devices from the user
// (jonathan) TODO: MDM -> currentUser, otherUsers, taggedDevices
val userId = peer.User
val profile = netmap.userProfile(userId)
// Mullvad based nodes should not be shown in the peer list
if (!peer.isMullvadNode) {
if (!grouped.containsKey(userId)) {
grouped[userId] = mutableListOf()
}
grouped[userId]?.add(peer)
// Mullvad nodes should not be shown in the peer list
if (peer.isMullvadNode) {
continue
}
}
val me = netmap.currentUserProfile()
// Hide devices based on MDM settings
if (hideMyDevices && userId == me?.ID) {
continue
}
if (hideOtherDevices && userId != me?.ID) {
continue
}
if (hideTaggedDevices && (profile?.isTaggedDevice() == true)) {
continue
}
if (!grouped.containsKey(userId)) {
grouped[userId] = mutableListOf()
}
grouped[userId]?.add(peer)
}
peerSets =
grouped

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

@ -4,6 +4,7 @@
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
@ -21,9 +22,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.BuildConfig
import com.tailscale.ipn.R
@ -32,7 +36,9 @@ import com.tailscale.ipn.ui.theme.logoBackground
@Composable
fun AboutView(backToSettings: BackNavigation) {
Scaffold(topBar = { Header(R.string.about_view_title, onBack = backToSettings) }) { innerPadding
val localClipboardManager = LocalClipboardManager.current
Scaffold(topBar = { Header(R.string.about_view_header, onBack = backToSettings) }) { innerPadding
->
Column(
verticalArrangement =
@ -61,6 +67,10 @@ fun AboutView(backToSettings: BackNavigation) {
fontWeight = FontWeight.SemiBold,
fontSize = MaterialTheme.typography.titleLarge.fontSize)
Text(
modifier =
Modifier.clickable {
localClipboardManager.setText(AnnotatedString(BuildConfig.VERSION_NAME))
},
text = "${stringResource(R.string.version)} ${BuildConfig.VERSION_NAME}",
fontWeight = MaterialTheme.typography.bodyMedium.fontWeight,
fontSize = MaterialTheme.typography.bodyMedium.fontSize)
@ -80,3 +90,9 @@ fun AboutView(backToSettings: BackNavigation) {
}
}
}
@Preview
@Composable
fun AboutPreview() {
AboutView({})
}

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

@ -14,6 +14,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
@ -22,6 +23,7 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links
@ -29,12 +31,13 @@ import com.tailscale.ipn.ui.theme.defaultTextColor
import com.tailscale.ipn.ui.theme.link
import com.tailscale.ipn.ui.util.ClipboardValueView
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.BugReportViewModel
@Composable
fun BugReportView(backToSettings: BackNavigation, model: BugReportViewModel = viewModel()) {
val handler = LocalUriHandler.current
val bugReportID = model.bugReportID.collectAsState().value
val bugReportID by model.bugReportID.collectAsState()
Scaffold(topBar = { Header(R.string.bug_report_title, onBack = backToSettings) }) { innerPadding
->
@ -81,3 +84,11 @@ fun contactText(): 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.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
@ -46,19 +39,3 @@ fun OpenURLButton(title: String, url: String) {
)
}
}
@Composable
fun ClearButton(onClick: () -> Unit) {
IconButton(onClick = onClick, modifier = Modifier.size(24.dp)) {
Icon(Icons.Outlined.Clear, null)
}
}
@Composable
fun CloseButton() {
val focusManager = LocalFocusManager.current
IconButton(onClick = { focusManager.clearFocus() }, modifier = Modifier.size(24.dp)) {
Icon(Icons.Outlined.Close, null)
}
}

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

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

@ -17,10 +17,13 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHide
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.disabledListItem
import com.tailscale.ipn.ui.theme.listItem
@ -40,11 +43,13 @@ fun ExitNodePicker(
LoadingIndicator.Wrap {
Scaffold(topBar = { Header(R.string.choose_exit_node, onBack = nav.onNavigateBackHome) }) {
innerPadding ->
val tailnetExitNodes = model.tailnetExitNodes.collectAsState().value
val mullvadExitNodesByCountryCode = model.mullvadExitNodesByCountryCode.collectAsState().value
val mullvadExitNodeCount = model.mullvadExitNodeCount.collectAsState().value
val anyActive = model.anyActive.collectAsState()
val tailnetExitNodes by model.tailnetExitNodes.collectAsState()
val mullvadExitNodesByCountryCode by model.mullvadExitNodesByCountryCode.collectAsState()
val mullvadExitNodeCount by model.mullvadExitNodeCount.collectAsState()
val anyActive by model.anyActive.collectAsState()
val allowLANAccess = Notifier.prefs.collectAsState().value?.ExitNodeAllowLANAccess == true
val showRunAsExitNode by MDMSettings.runExitNode.flow.collectAsState()
val allowLanAccessMDMDisposition by MDMSettings.exitNodeAllowLANAccess.flow.collectAsState()
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "header") {
@ -53,10 +58,13 @@ fun ExitNodePicker(
ExitNodePickerViewModel.ExitNode(
label = stringResource(R.string.none),
online = true,
selected = !anyActive.value,
selected = !anyActive,
))
Lists.ItemDivider()
RunAsExitNodeItem(nav = nav, viewModel = model)
if (showRunAsExitNode == ShowHide.Show) {
Lists.ItemDivider()
RunAsExitNodeItem(nav = nav, viewModel = model)
}
}
item(key = "divider1") { Lists.SectionDivider() }
@ -71,13 +79,14 @@ fun ExitNodePicker(
}
}
// TODO: make sure this actually works, and if not, leave it out for now
item(key = "allowLANAccess") {
Lists.SectionDivider()
if (!allowLanAccessMDMDisposition.hiddenFromUser) {
item(key = "allowLANAccess") {
Lists.SectionDivider()
Setting.Switch(R.string.allow_lan_access, isOn = allowLANAccess) {
LoadingIndicator.start()
model.toggleAllowLANAccess { LoadingIndicator.stop() }
Setting.Switch(R.string.allow_lan_access, isOn = allowLANAccess) {
LoadingIndicator.start()
model.toggleAllowLANAccess { LoadingIndicator.stop() }
}
}
}
}
@ -110,7 +119,7 @@ fun ExitNodeItem(
trailingContent = {
Row {
if (node.selected) {
Icon(Icons.Outlined.Check, contentDescription = stringResource(R.string.selected))
Icon(Icons.Outlined.Check, null)
}
}
})
@ -134,7 +143,7 @@ fun MullvadItem(nav: ExitNodePickerNav, count: Int, selected: Boolean) {
},
trailingContent = {
if (selected) {
Icon(Icons.Outlined.Check, contentDescription = stringResource(R.string.selected))
Icon(Icons.Outlined.Check, null)
}
})
}

@ -16,14 +16,17 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.AppTheme
@Composable
fun IntroView(onContinue: () -> Unit) {
@ -57,3 +60,9 @@ fun IntroView(onContinue: () -> Unit) {
}
}
}
@Composable
@Preview
fun IntroViewPreview() {
AppTheme { Surface { IntroView({}) } }
}

@ -20,14 +20,18 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.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)
@ -35,7 +39,7 @@ import com.tailscale.ipn.ui.viewModel.LoginQRViewModel
fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel()) {
Surface(color = MaterialTheme.colorScheme.scrim, modifier = Modifier.fillMaxSize()) {
Dialog(onDismissRequest = onDismiss) {
val image = model.qrCode.collectAsState()
val image by model.qrCode.collectAsState()
Column(
modifier =
@ -54,7 +58,7 @@ fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel(
.background(MaterialTheme.colorScheme.onSurface)
.fillMaxWidth(),
contentAlignment = Alignment.Center) {
image.value?.let {
image?.let {
Image(
bitmap = it,
contentDescription = "Scan to login",
@ -66,3 +70,11 @@ fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel(
}
}
}
@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.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@ -38,7 +39,7 @@ fun MDMSettingsDebugView(backToSettings: BackNavigation, model: IpnViewModel = v
@Composable
fun MDMSettingView(setting: MDMSetting<*>) {
val value = setting.flow.collectAsState().value
val value by setting.flow.collectAsState()
ListItem(
headlineContent = { Text(setting.localizedTitle, maxLines = 3) },
supportingContent = {

@ -40,6 +40,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@ -48,6 +49,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalFocusManager
@ -58,16 +60,19 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHide
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.Permissions
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.theme.disabled
import com.tailscale.ipn.ui.theme.exitNodeToggleButton
import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.theme.minTextSize
import com.tailscale.ipn.ui.theme.primaryListItem
@ -80,7 +85,6 @@ import com.tailscale.ipn.ui.util.AutoResizingText
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.MainViewModel
@ -94,23 +98,40 @@ data class MainViewNavigation(
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun MainView(loginAtUrl: (String) -> Unit, navigation: MainViewNavigation, viewModel: MainViewModel = viewModel()) {
fun MainView(
loginAtUrl: (String) -> Unit,
navigation: MainViewNavigation,
viewModel: MainViewModel
) {
LoadingIndicator.Wrap {
Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets ->
Column(
modifier = Modifier.fillMaxWidth().padding(paddingInsets),
verticalArrangement = Arrangement.Center) {
val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState).value
val user = viewModel.loggedInUser.collectAsState(initial = null).value
val stateVal = viewModel.stateRes.collectAsState(initial = R.string.placeholder).value
// Assume VPN has been prepared. Whether or not it has been prepared cannot be known
// 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 netmap = viewModel.netmap.collectAsState(initial = null).value
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)
ListItem(
colors = MaterialTheme.colorScheme.surfaceContainerListItem,
leadingContent = {
val isOn = viewModel.vpnToggleState.collectAsState(initial = false)
TintedSwitch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value)
TintedSwitch(
onCheckedChange = {
if (!disableToggle) {
viewModel.toggleVpn()
}
},
enabled = !disableToggle,
checked = isOn)
},
headlineContent = {
user?.NetworkProfile?.DomainName?.let { domain ->
@ -148,9 +169,14 @@ fun MainView(loginAtUrl: (String) -> Unit, navigation: MainViewNavigation, viewM
PromptPermissionsIfNecessary()
ExpiryNotificationIfNecessary(netmap = netmap, action = { viewModel.login {} })
if (showKeyExpiry) {
ExpiryNotification(netmap = netmap, action = { viewModel.login() })
}
ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
if (showExitNodePicker == ShowHide.Show) {
ExitNodeStatus(
navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
}
PeerList(
viewModel = viewModel,
@ -162,11 +188,13 @@ fun MainView(loginAtUrl: (String) -> Unit, navigation: MainViewNavigation, viewM
else -> {
ConnectView(
state,
isPrepared,
user,
{ viewModel.toggleVpn() },
{ viewModel.login {} },
{ viewModel.login() },
loginAtUrl,
netmap?.SelfNode)
netmap?.SelfNode,
{ viewModel.showVPNPermissionLauncherIfUnauthorized() })
}
}
}
@ -176,13 +204,26 @@ fun MainView(loginAtUrl: (String) -> Unit, navigation: MainViewNavigation, viewM
@Composable
fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
val prefs = viewModel.prefs.collectAsState()
val netmap = viewModel.netmap.collectAsState()
val exitNodeId = prefs.value?.ExitNodeID
val peer = exitNodeId?.let { id -> netmap.value?.Peers?.find { it.StableID == id } }
val location = peer?.Hostinfo?.Location
val name = peer?.ComputedName
val active = peer != null
val maybePrefs by viewModel.prefs.collectAsState()
val netmap by viewModel.netmap.collectAsState()
// There's nothing to render if we haven't loaded the prefs yet
val prefs = maybePrefs ?: return
// The activeExitNode is the source of truth. The selectedExitNode is only relevant if we
// don't have an active node.
val chosenExitNodeId = prefs.activeExitNodeID ?: prefs.selectedExitNodeID
val exitNodePeer = chosenExitNodeId?.let { id -> netmap?.Peers?.find { it.StableID == id } }
val location = exitNodePeer?.Hostinfo?.Location
val name = exitNodePeer?.ComputedName
// We're connected to an exit node if we found an active peer for the *active* exit node
val activeAndRunning = (exitNodePeer != null) && !prefs.activeExitNodeID.isNullOrEmpty()
// (jonathan) TODO: We will block the "enable/disable" button for an exit node for which we cannot
// find a peer on purpose and render the "No Exit Node" state, however, that should
// eventually show up in the UI as an error case so the user knows to pick an available node.
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) {
Box(
@ -193,10 +234,8 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
ListItem(
modifier = Modifier.clickable { navAction() },
colors =
if (active) MaterialTheme.colorScheme.primaryListItem
else
ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest),
if (activeAndRunning) MaterialTheme.colorScheme.primaryListItem
else ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface),
overlineContent = {
Text(
stringResource(R.string.exit_node),
@ -217,17 +256,24 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
imageVector = Icons.Outlined.ArrowDropDown,
contentDescription = null,
tint =
if (active) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
if (activeAndRunning)
MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
trailingContent = {
if (peer != null) {
if (exitNodePeer != null) {
Button(
colors = MaterialTheme.colorScheme.secondaryButton,
onClick = { viewModel.disableExitNode() }) {
Text(stringResource(R.string.stop))
colors =
if (prefs.activeExitNodeID.isNullOrEmpty())
MaterialTheme.colorScheme.exitNodeToggleButton
else MaterialTheme.colorScheme.secondaryButton,
onClick = { viewModel.toggleExitNode() }) {
Text(
if (prefs.activeExitNodeID.isNullOrEmpty())
stringResource(id = R.string.enable)
else stringResource(id = R.string.disable))
}
}
})
@ -240,8 +286,8 @@ fun SettingsButton(action: () -> Unit) {
IconButton(modifier = Modifier.size(24.dp), onClick = { action() }) {
Icon(
Icons.Outlined.Settings,
null,
)
contentDescription = "Open settings",
tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@ -251,19 +297,27 @@ fun StartingView() {
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
TailscaleLogoView(animated = true, usesOnBackgroundColors = false, Modifier.size(40.dp))
TailscaleLogoView(
animated = true, usesOnBackgroundColors = false, Modifier.size(40.dp).alpha(0.3f))
}
}
@Composable
fun ConnectView(
state: Ipn.State,
isPrepared: Boolean,
user: IpnLocal.LoginProfile?,
connectAction: () -> Unit,
loginAction: () -> Unit,
loginAtUrlAction: (String) -> Unit,
selfNode: Tailcfg.Node?
selfNode: Tailcfg.Node?,
showVPNPermissionLauncherIfUnauthorized: () -> Unit
) {
LaunchedEffect(isPrepared) {
if (!isPrepared) {
showVPNPermissionLauncherIfUnauthorized()
}
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
Column(
@ -271,7 +325,24 @@ fun ConnectView(
verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (state == Ipn.State.NeedsMachineAuth) {
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,
@ -286,12 +357,11 @@ fun ConnectView(
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)
}
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(
@ -354,9 +424,11 @@ fun PeerList(
onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
onSearch: (String) -> Unit
) {
val peerList = viewModel.peers.collectAsState(initial = emptyList<PeerSet>())
val peerList by viewModel.peers.collectAsState(initial = emptyList<PeerSet>())
val searchTermStr by viewModel.searchTerm.collectAsState(initial = "")
val showNoResults = remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.value.isEmpty() } }.value
val showNoResults =
remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value
val netmap = viewModel.netmap.collectAsState()
val focusManager = LocalFocusManager.current
@ -416,7 +488,7 @@ fun PeerList(
}
var first = true
peerList.value.forEach { peerSet ->
peerList.forEach { peerSet ->
if (!first) {
item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() }
}
@ -465,12 +537,8 @@ fun PeerList(
}
@Composable
fun ExpiryNotificationIfNecessary(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) {
// Key expiry warning shown only if the key is expiring within 24 hours (or has already expired)
val networkMap = netmap ?: return
if (!TimeUtil.isWithin24Hours(networkMap.SelfNode.KeyExpiry)) {
return
}
fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) {
if (netmap == null) return
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) {
Box(
@ -483,7 +551,7 @@ fun ExpiryNotificationIfNecessary(netmap: Netmap.NetworkMap?, action: () -> Unit
colors = MaterialTheme.colorScheme.warningListItem,
headlineContent = {
Text(
networkMap.SelfNode.expiryLabel(),
netmap.SelfNode.expiryLabel(),
style = MaterialTheme.typography.titleMedium,
)
},
@ -508,3 +576,14 @@ fun PromptPermissionsIfNecessary() {
}
}
}
@Preview
@Composable
fun MainViewPreview() {
val vm = MainViewModel()
MainView(
{},
MainViewNavigation(
onNavigateToSettings = {}, onNavigateToPeerDetails = {}, onNavigateToExitNodes = {}),
vm)
}

@ -16,6 +16,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
@ -46,3 +47,10 @@ fun ManagedByView(backToSettings: BackNavigation, model: IpnViewModel = viewMode
}
}
}
@Preview
@Composable
fun ManagedByViewPreview() {
val vm = IpnViewModel()
ManagedByView(backToSettings = {}, vm)
}

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

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

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

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

@ -60,7 +60,7 @@ fun BackArrow(action: () -> Unit) {
Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
null,
contentDescription = "Go back to the previous screen",
modifier =
Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
@ -71,7 +71,7 @@ fun BackArrow(action: () -> Unit) {
@Composable
fun CheckedIndicator() {
Icon(Icons.Default.CheckCircle, "selected", tint = ts_color_light_blue)
Icon(Icons.Default.CheckCircle, null, tint = ts_color_light_blue)
}
@Composable

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

@ -14,6 +14,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
@ -23,6 +24,7 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links
@ -31,6 +33,7 @@ import com.tailscale.ipn.ui.theme.link
import com.tailscale.ipn.ui.util.ClipboardValueView
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.TailnetLockSetupViewModel
import com.tailscale.ipn.ui.viewModel.TailnetLockSetupViewModelFactory
@ -39,9 +42,10 @@ fun TailnetLockSetupView(
backToSettings: BackNavigation,
model: TailnetLockSetupViewModel = viewModel(factory = TailnetLockSetupViewModelFactory())
) {
val statusItems = model.statusItems.collectAsState().value
val nodeKey = model.nodeKey.collectAsState().value
val tailnetLockKey = model.tailnetLockKey.collectAsState().value
val statusItems by model.statusItems.collectAsState()
val nodeKey by model.nodeKey.collectAsState()
val tailnetLockKey by model.tailnetLockKey.collectAsState()
val tailnetLockTlPubKey = tailnetLockKey.replace("nlpub", "tlpub")
Scaffold(topBar = { Header(R.string.tailnet_lock, onBack = backToSettings) }) { innerPadding ->
LoadingIndicator.Wrap {
@ -74,7 +78,7 @@ fun TailnetLockSetupView(
Lists.SectionDivider()
ClipboardValueView(
value = tailnetLockKey,
value = tailnetLockTlPubKey,
title = stringResource(R.string.tailnet_lock_key),
subtitle = stringResource(R.string.tailnet_lock_key_explainer))
}
@ -115,3 +119,12 @@ fun explainerText(): 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)
}

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

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

@ -63,7 +63,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
.stateIn(viewModelScope)
.collect { (netmap, prefs) ->
isRunningExitNode.set(prefs?.let { AdvertisedRoutesHelper.exitNodeOnFromPrefs(it) })
val exitNodeId = prefs?.ExitNodeID
val exitNodeId = prefs?.activeExitNodeID ?: prefs?.selectedExitNodeID
netmap?.Peers?.let { peers ->
val allNodes =
peers
@ -137,6 +137,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
LoadingIndicator.start()
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeID = node.id
Client(viewModelScope).editPrefs(prefsOut) {
nav.onNavigateBackHome()
LoadingIndicator.stop()

@ -3,20 +3,20 @@
package com.tailscale.ipn.ui.viewModel
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.net.VpnService
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.IPNReceiver
import com.tailscale.ipn.UninitializedApp
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.UserID
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.notifier.Notifier.prefs
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -31,11 +31,23 @@ open class IpnViewModel : ViewModel() {
val loggedInUser: StateFlow<IpnLocal.LoginProfile?> = MutableStateFlow(null)
val loginProfiles: StateFlow<List<IpnLocal.LoginProfile>?> = MutableStateFlow(null)
private val _vpnPrepared = MutableStateFlow(false)
val vpnPrepared: StateFlow<Boolean> = _vpnPrepared
// The userId associated with the current node. ie: The logged in user.
private var selfNodeUserId: UserID? = null
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 {
Notifier.state.collect {
// Reload the user profiles on all state transitions to ensure loggedInUser is correct
@ -60,55 +72,82 @@ open class IpnViewModel : ViewModel() {
Log.d(TAG, "Created")
}
protected fun Context.findActivity(): Activity? =
when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
// VPN Control
fun setVpnPrepared(prepared: Boolean) {
_vpnPrepared.value = prepared
}
fun startVPN() {
UninitializedApp.get().startVPN()
}
fun stopVPN() {
UninitializedApp.get().stopVPN()
}
// Login/Logout
fun login(
maskedPrefs: Ipn.MaskedPrefs? = null,
authKey: String? = null,
completionHandler: (Result<Unit>) -> Unit = {}
) {
val loginAction = {
Client(viewModelScope).startLoginInteractive { result ->
result
.onSuccess { Log.d(TAG, "Login started: $it") }
.onFailure { Log.e(TAG, "Error starting login: ${it.message}") }
completionHandler(result)
}
}
private fun loadUserProfiles() {
Client(viewModelScope).profiles { result ->
result.onSuccess(loginProfiles::set).onFailure {
Log.e(TAG, "Error loading profiles: ${it.message}")
// 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}") }
}
}
Client(viewModelScope).currentProfile { result ->
result
.onSuccess { loggedInUser.set(if (it.isEmpty()) null else it) }
.onFailure { Log.e(TAG, "Error loading current profile: ${it.message}") }
val startAction = {
Client(viewModelScope).start(Ipn.Options(AuthKey = authKey)) { start ->
start.onFailure { completionHandler(Result.failure(it)) }.onSuccess { stopThenLogin() }
}
}
}
fun toggleVpn() {
when (Notifier.state.value) {
Ipn.State.Running -> stopVPN()
else -> startVPN()
// 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")
}
}
fun startVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_CONNECT_VPN
context.sendBroadcast(intent)
prefs?.let {
Client(viewModelScope).editPrefs(it) { result ->
result.onFailure { completionHandler(Result.failure(it)) }.onSuccess { startAction() }
}
} ?: run { startAction() }
}
fun stopVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = IPNReceiver.INTENT_DISCONNECT_VPN
context.sendBroadcast(intent)
fun loginWithAuthKey(authKey: String, completionHandler: (Result<Unit>) -> Unit = {}) {
val prefs = Ipn.MaskedPrefs()
prefs.WantRunning = true
login(prefs, authKey = authKey, completionHandler)
}
fun login(completionHandler: (Result<Unit>) -> Unit = {}) {
Client(viewModelScope).startLoginInteractive { result ->
result
.onSuccess { Log.d(TAG, "Login started: $it") }
.onFailure { Log.e(TAG, "Error starting login: ${it.message}") }
completionHandler(result)
}
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 = {}) {
@ -120,17 +159,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) {
Client(viewModelScope).switchProfile(profile) {
startVPN()
completionHandler(it)
val switchProfile = {
Client(viewModelScope).switchProfile(profile) {
startVPN()
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) {
Client(viewModelScope).addProfile {
if (it.isSuccess) {
login {}
login()
}
startVPN()
completionHandler(it)
@ -144,51 +206,21 @@ open class IpnViewModel : ViewModel() {
}
}
// The below handle all types of preference modifications typically invoked by the UI.
// Callers generally shouldn't care about the returned prefs value - the source of
// truth is the IPNModel, who's prefs flow will change in value to reflect the true
// value of the pref setting in the back end (and will match the value returned here).
// Generally, you will want to inspect the returned value in the callback for errors
// to indicate why a particular setting did not change in the interface.
//
// Usage:
// - User/Interface changed to new value. Render the new value.
// - Submit the new value to the PrefsEditor
// - Observe the prefs on the IpnModel and update the UI when/if the value changes.
// For a typical flow, the changed value should reflect the value already shown.
// - Inform the user of any error which may have occurred
//
// The "toggle' functions here will attempt to set the pref value to the inverse of
// what is currently known in the IpnModel.prefs. If IpnModel.prefs is not available,
// the callback will be called with a NO_PREFS error
fun setWantRunning(wantRunning: Boolean, callback: (Result<Ipn.Prefs>) -> Unit) {
Ipn.MaskedPrefs().WantRunning = wantRunning
Client(viewModelScope).editPrefs(Ipn.MaskedPrefs(), callback)
}
fun toggleShieldsUp(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs =
Notifier.prefs.value
?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleShieldsUp
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ShieldsUp = !prefs.ShieldsUp
Client(viewModelScope).editPrefs(prefsOut, callback)
}
fun toggleRouteAll(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs =
Notifier.prefs.value
?: run {
callback(Result.failure(Exception("no prefs")))
return@toggleRouteAll
}
val prefsOut = Ipn.MaskedPrefs()
prefsOut.RouteAll = !prefs.RouteAll
Client(viewModelScope).editPrefs(prefsOut, callback)
// Exit Node Manipulation
fun toggleExitNode() {
val prefs = prefs.value ?: return
LoadingIndicator.start()
if (prefs.activeExitNodeID != null) {
// We have an active exit node so we should keep it, but disable it
Client(viewModelScope).setUseExitNode(false) { LoadingIndicator.stop() }
} else if (prefs.selectedExitNodeID != null) {
// We have a prior exit node to enable
Client(viewModelScope).setUseExitNode(true) { LoadingIndicator.stop() }
} else {
// This should not be possible. In this state the button is hidden
Log.e(TAG, "No exit node to disable and no prior exit node to enable")
}
}
}

@ -3,27 +3,38 @@
package com.tailscale.ipn.ui.viewModel
import android.content.Intent
import android.net.VpnService
import android.util.Log
import androidx.activity.result.ActivityResultLauncher
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Ipn.State
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import java.time.Duration
class MainViewModel : IpnViewModel() {
// The user readable state of the system
val stateRes: StateFlow<Int> = MutableStateFlow(State.NoState.userStringRes())
val stateRes: StateFlow<Int> = MutableStateFlow(userStringRes(State.NoState, State.NoState, true))
// The expected state of the VPN toggle
val vpnToggleState: StateFlow<Boolean> = MutableStateFlow(false)
private val _vpnToggleState = MutableStateFlow(false)
val vpnToggleState: StateFlow<Boolean> = _vpnToggleState
// Permission to prepare VPN
private var vpnPermissionLauncher: ActivityResultLauncher<Intent>? = null
// The list of peers
val peers: StateFlow<List<PeerSet>> = MutableStateFlow(emptyList<PeerSet>())
@ -37,15 +48,29 @@ class MainViewModel : IpnViewModel() {
// The active search term for filtering peers
val searchTerm: StateFlow<String> = MutableStateFlow("")
// True if we should render the key expiry bannder
val showExpiry: StateFlow<Boolean> = MutableStateFlow(false)
private val peerCategorizer = PeerCategorizer()
init {
viewModelScope.launch {
Notifier.state.collect { state ->
stateRes.set(state.userStringRes())
vpnToggleState.set(
(state == State.Running || state == State.Starting || state == State.NoState))
}
var previousState: State? = null
combine(Notifier.state, vpnPrepared) { state, prepared -> state to prepared }
.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
}
}
viewModelScope.launch {
@ -53,6 +78,18 @@ class MainViewModel : IpnViewModel() {
it?.let { netmap ->
peerCategorizer.regenerateGroupedPeers(netmap)
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value))
if (netmap.SelfNode.keyDoesNotExpire) {
showExpiry.set(false)
return@let
} else {
val expiryNotificationWindowMDM = MDMSettings.keyExpirationNotice.flow.value
val window =
expiryNotificationWindowMDM?.let { TimeUtil.duration(it) } ?: Duration.ofHours(24)
val expiresSoon =
TimeUtil.isWithinExpiryNotificationWindow(window, it.SelfNode.KeyExpiry)
showExpiry.set(expiresSoon)
}
}
}
}
@ -60,29 +97,53 @@ class MainViewModel : IpnViewModel() {
viewModelScope.launch {
searchTerm.collect { term -> peers.set(peerCategorizer.groupedAndFilteredPeers(term)) }
}
viewModelScope.launch {
Notifier.prefs.collect { prefs -> Log.d(TAG, "Main VM - prefs = ${prefs}") }
}
}
fun showVPNPermissionLauncherIfUnauthorized() {
val vpnIntent = VpnService.prepare(App.get())
if (vpnIntent != null) {
vpnPermissionLauncher?.launch(vpnIntent)
} else {
setVpnPrepared(true)
}
}
fun toggleVpn() {
val state = Notifier.state.value
val isPrepared = vpnPrepared.value
when {
!isPrepared -> showVPNPermissionLauncherIfUnauthorized()
state == Ipn.State.Running -> stopVPN()
else -> startVPN()
}
}
fun searchPeers(searchTerm: String) {
this.searchTerm.set(searchTerm)
}
fun disableExitNode() {
LoadingIndicator.start()
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeID = null
Client(viewModelScope).editPrefs(prefsOut) { LoadingIndicator.stop() }
fun setVpnPermissionLauncher(launcher: ActivityResultLauncher<Intent>) {
// No intent means we're already authorized
vpnPermissionLauncher = launcher
}
}
private fun State?.userStringRes(): Int {
return when (this) {
State.NoState -> R.string.waiting
State.InUseOtherUser -> R.string.placeholder
State.NeedsLogin -> R.string.please_login
State.NeedsMachineAuth -> R.string.needs_machine_auth
State.Stopped -> R.string.stopped
State.Starting -> R.string.starting
State.Running -> R.string.connected
private fun userStringRes(currentState: State?, previousState: State?, vpnPrepared: Boolean): Int {
return when {
previousState == State.NoState && currentState == State.Starting -> R.string.starting
currentState == State.NoState -> R.string.placeholder
currentState == State.InUseOtherUser -> R.string.placeholder
currentState == State.NeedsLogin ->
if (vpnPrepared) R.string.please_login else R.string.connect_to_vpn
currentState == State.NeedsMachineAuth -> R.string.needs_machine_auth
currentState == State.Stopped -> R.string.stopped
currentState == State.Starting -> R.string.starting
currentState == State.Running -> R.string.connected
else -> R.string.placeholder
}
}

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 641 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 879 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

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

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

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

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

@ -6,12 +6,12 @@
<string name="log_out">Log out</string>
<string name="none">None</string>
<string name="connect">Connect</string>
<string name="disconnect">Disconnect</string>
<string name="unknown_user">Unknown user</string>
<string name="connected">Connected</string>
<string name="not_connected">Not connected</string>
<string name="empty"> </string>
<string name="template">%s</string>
<string name="more">More</string>
<string name="empty" translatable="false"> </string>
<string name="template" translatable="false">%s</string>
<string name="selected">Selected</string>
<string name="offline">Offline</string>
<string name="ok">OK</string>
@ -22,15 +22,15 @@
<string name="no_results">No results</string>
<!-- Strings for the about screen -->
<string name="app_name">Tailscale</string>
<string name="tile_name">Tailscale</string>
<string name="app_name" translatable="false">Tailscale</string>
<string name="tile_name" translatable="false">Tailscale</string>
<string name="version">Version</string>
<string name="about_view_header">About Tailscale</string>
<string name="about_view_title">Tailscale for Android</string>
<string name="acknowledgements">Acknowledgements</string>
<string name="privacy_policy">Privacy Policy</string>
<string name="terms_of_service">Terms of Service</string>
<string name="about_view_footnotes">WireGuard is a registered trademark of Jason A. Donenfeld.\n\n© 2024 Tailscale Inc. All rights reserved.\nTailscale is a registered trademark of Tailscale Inc.</string>
<string name="app_icon_content_description">The Tailscale app icon</string>
<string name="managed_by">Managed by</string>
<!-- Strings for the bug reporting screen -->
@ -56,6 +56,7 @@
<string name="connect_to_tailnet_suffix">" tailnet."</string>
<string name="welcome_to_tailscale">Welcome to Tailscale</string>
<string name="login_to_join_your_tailnet">Log in to join your tailnet and connect your devices.</string>
<string name="give_permissions">Set up a VPN connection so you can start using Tailscale.</string>
<string name="keyExpiryExplainer">Reauthenticate to remain connected to Tailscale.</string>
<string name="deviceKeyNeverExpires">Device key does not expire</string>
<string name="deviceKeyExpires">Device key expires %s</string>
@ -64,6 +65,8 @@
<string name="machine_auth_explainer">This device must be approved by an administrator before it can connect to the tailnet.</string>
<string name="open_admin_console">Open admin console</string>
<string name="needs_machine_auth">Authorization required</string>
<string name="disable">Disable</string>
<string name="enable">Enable</string>
<!-- Strings for peer details -->
<string name="os">OS</string>
@ -79,13 +82,12 @@
<string name="open_support">Open support</string>
<!-- State strings -->
<string name="waiting">Starting…</string>
<string name="placeholder">--</string>
<string name="placeholder" translatable="false">--</string>
<string name="please_login">Login required</string>
<string name="stopped">Not connected</string>
<string name="connect_to_vpn">Connect to VPN</string>
<!-- Time conversion templates -->
<string name="expired">expired</string>
<string name="under_a_minute">under a minute</string>
<string name="in_x_minutes">in %d minutes</string>
<string name="in_x_hours">in %d hours</string>
@ -97,30 +99,35 @@
<string name="ago_x_hours">%d hours ago</string>
<string name="ago_x_days">%d days ago</string>
<string name="ago_x_months">%d months ago</string>
<string name="ago_x_years">.1f years ago</string>
<string name="ago_x_years">%.1f years ago</string>
<!-- Strings for the user switcher -->
<string name="user_switcher">Accounts</string>
<string name="logout_failed">Unable to logout at this time. Please try again.</string>
<string name="error">Error</string>
<string name="accounts">Accounts</string>
<string name="manage_accounts">Manage accounts</string>
<string name="add_account">Add another account…</string>
<string name="add_account">Add another account</string>
<string name="add_account_short">Add account</string>
<string name="reauthenticate">Reauthenticate</string>
<string name="switch_user_failed">Unable to switch users. Please try again.</string>
<string name="add_profile_failed">Unable to add a new profile. Please try again.</string>
<string name="invalidCustomUrl">Please enter a valid URL in the form https://server.com</string>
<string name="invalidCustomURLTitle">Invalid URL</string>
<string name="custom_control_menu">Add account using alternate server</string>
<string name="custom_control_menu">Use an alternate server</string>
<string name="custom_control_menu_desc">Enter the URL of an alternate Tailscale server or your self-hosted Headscale server.</string>
<string name="custom_control_placeholder">https://my.custom.server.com</string>
<string name="custom_control_placeholder" translatable="false">https://my.custom.server.com</string>
<string name="auth_key_menu">Use an auth key</string>
<string name="auth_key_title">Add an account using an auth key</string>
<string name="auth_key_explanation">Enter a valid auth key generated by the Tailscale admin console</string>
<string name="auth_key_placeholder">ie: tskey-auth-mYta1ln3tCNTRL-s3cr3tauthk3yFr0madM1n</string>
<string name="invalidAuthKey">The provided auth key is not in the correct format.</string>
<string name="invalidAuthKeyTitle">Invalid key</string>
<string name="custom_control_url_title">Custom control server URL</string>
<string name="auth_key_input_title">Auth key</string>
<!-- Strings for ExitNode picker -->
<string name="choose_exit_node">Choose exit node</string>
<string name="choose_mullvad_exit_node">Mullvad exit nodes</string>
<string name="tailnet_exit_nodes">Tailnet exit nodes</string>
<string name="mullvad_exit_nodes">Mullvad VPN</string>
<string name="mullvad_exit_nodes" translatable="false">Mullvad VPN</string>
<string name="best_available">Best available</string>
<string name="run_as_exit_node">Run as exit node</string>
<string name="run_this_device_as_an_exit_node">Run as exit node?</string>
@ -132,7 +139,6 @@
<string name="run_exit_node_explainer_running">Other devices in your tailnet can now route their internet traffic through this Android device. Make sure to approve this exit node in the admin console in order for other devices to see it.</string>
<string name="enabled">Enabled</string>
<string name="disabled">Disabled</string>
<string name="stop">Stop</string>
<!-- Strings for the tailnet lock screen -->
<string name="tailnet_lock">Tailnet lock</string>
@ -198,11 +204,10 @@
<!-- Permissions Management -->
<string name="permissions">Permissions</string>
<string name="permission_required">Permission required</string>
<string name="permission_write_external_storage">Storage</string>
<string name="permission_write_external_storage_needed">We use storage in order to receive files with Taildrop.</string>
<string name="permission_post_notifications">Notifications</string>
<string name="permission_post_notifications_needed">We use notifications to help you troubleshoot broken connections, or notify you before you need to reauthenticate to your network.</string>
<string name="permission_post_notifications_needed">We use notifications to help you troubleshoot broken connections, or notify you before you need to reauthenticate to your network. Persistent status notifications are off by default and can be enabled in system settings. </string>
<!-- Strings for the share activity -->
@ -231,4 +236,15 @@
<string name="welcome2">All connections are device-to-device, so we never see your data. We collect and use your email address and name, as well as your device name, OS version, and IP address in order to help you to connect your devices and manage your settings. We log when you are connected to your network.</string>
<string name="scan_to_connect_to_your_tailnet">Scan this QR code to log in to your tailnet</string>
<!-- Strings for the IPNReceiver -->
<string name="title_connection_failed">Tailscale Connection Failed</string>
<string name="body_open_tailscale">Tap here to open Tailscale.</string>
<!-- Strings describing notification channels -->
<string name="taildrop_file_transfers">Taildrop file transfers</string>
<string name="vpn_status">VPN status</string>
<string name="vpn_start">VPN start</string>
<string name="notifications_delivered_when_user_interaction_is_required_to_establish_the_vpn_tunnel">Notifications delivered when user interaction is required to establish the VPN tunnel.</string>
<string name="optional_notifications_which_display_the_status_of_the_vpn_tunnel">Optional notifications which display the status of the VPN tunnel.</string>
<string name="notifications_delivered_when_a_file_is_received_using_taildrop">Notifications delivered when a file is received using Taildrop.</string>
</resources>

@ -0,0 +1,30 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package android.util;
/**
* This is a mock class for the android.util.Log class. It is used to print log messages to the console.
*/
public class Log {
public static int d(String tag, String msg) {
System.out.println("DEBUG: " + tag + ": " + msg);
return 0;
}
public static int i(String tag, String msg) {
System.out.println("INFO: " + tag + ": " + msg);
return 0;
}
public static int w(String tag, String msg) {
System.out.println("WARN: " + tag + ": " + msg);
return 0;
}
public static int e(String tag, String msg) {
System.out.println("ERROR: " + tag + ": " + msg);
return 0;
}
}

@ -0,0 +1,60 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailcale.ipn.ui.util
import com.tailscale.ipn.ui.util.TimeUtil
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.time.Duration
class TimeUtilTest {
@Test
fun durationInvalidMsUnits() {
val input = "5s10ms"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun durationInvalidUsUnits() {
val input = "5s10us"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun durationTestHappyPath() {
val input = arrayOf("1.0y1.0w1.0d1.0h1.0m1.0s", "1s", "1m", "1h", "1d", "1w", "1y")
val expectedSeconds =
arrayOf((31536000 + 604800 + 86400 + 3600 + 60 + 1), 1, 60, 3600, 86400, 604800, 31536000)
val expected = expectedSeconds.map { Duration.ofSeconds(it.toLong()) }
val actual = input.map { TimeUtil.duration(it) }
assertEquals("Incorrect conversion", expected, actual)
}
@Test
fun testBadDurationString() {
val input = "1..0y1.0w1.0d1.0h1.0m1.0s"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun testBadDInputString() {
val input = "1.0yy1.0w1.0d1.0h1.0m1.0s"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
}
@Test
fun testIgnoreFractionalSeconds() {
val input = "10.9s"
val expectedSeconds = 10
val expected = Duration.ofSeconds(expectedSeconds.toLong())
val actual = TimeUtil.duration(input)
assertEquals("Should return $expectedSeconds seconds", expected, actual)
}
}

@ -1,60 +0,0 @@
buildscript {
ext.kotlin_version = "1.9.22"
repositories {
google()
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:8.1.0"
}
}
repositories {
google()
mavenCentral()
flatDir {
dirs 'libs'
}
}
apply plugin: 'com.android.application'
android {
ndkVersion "23.1.7779620"
compileSdk 33
defaultConfig {
minSdkVersion 22
targetSdkVersion 33
versionCode 201
versionName "1.61.105-t7429e8912-g12210d3f26b"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
flavorDimensions "version"
productFlavors {
fdroid {
// The fdroid flavor contains only free dependencies and is suitable
// for the F-Droid app store.
}
play {
// The play flavor contains all features and is for the Play Store.
}
}
namespace 'com.tailscale.ipn'
}
dependencies {
implementation "androidx.core:core:1.9.0"
implementation "androidx.browser:browser:1.5.0"
implementation "androidx.security:security-crypto:1.1.0-alpha06"
implementation "androidx.work:work-runtime:2.8.1"
implementation ':ipn@aar'
testImplementation "junit:junit:4.12"
// Non-free dependencies.
playImplementation 'com.google.android.gms:play-services-auth:20.7.0'
}

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

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

@ -1,183 +0,0 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx80m" "-Xms80m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

@ -1,103 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx80m" "-Xms80m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

@ -1,85 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<!-- Disable input emulation on ChromeOS -->
<uses-feature android:name="android.hardware.type.pc" android:required="false"/>
<!-- Signal support for Android TV -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application android:label="Tailscale" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round"
android:banner="@drawable/tv_banner"
android:name=".App" android:allowBackup="false">
<activity android:name="IPNActivity"
android:label="@string/app_name"
android:theme="@style/Theme.GioApp"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="application/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="image/*" />
<data android:mimeType="message/*" />
<data android:mimeType="multipart/*" />
<data android:mimeType="text/*" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="image/*" />
<data android:mimeType="message/*" />
<data android:mimeType="multipart/*" />
<data android:mimeType="text/*" />
<data android:mimeType="video/*" />
</intent-filter>
</activity>
<receiver android:name="IPNReceiver"
android:exported="true"
>
<intent-filter>
<action android:name="com.tailscale.ipn.CONNECT_VPN" />
<action android:name="com.tailscale.ipn.DISCONNECT_VPN" />
</intent-filter>
</receiver>
<service android:name=".IPNService"
android:permission="android.permission.BIND_VPN_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.net.VpnService"/>
</intent-filter>
</service>
<service
android:name=".QuickToggleService"
android:icon="@drawable/ic_tile"
android:label="@string/tile_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:exported="true">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE"/>
</intent-filter>
</service>
</application>
</manifest>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

@ -1,416 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.app.Application;
import android.app.Activity;
import android.app.DownloadManager;
import android.app.Fragment;
import android.app.FragmentTransaction;
import android.app.NotificationChannel;
import android.app.PendingIntent;
import android.app.UiModeManager;
import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.pm.PackageInfo;
import android.content.pm.Signature;
import android.content.res.Configuration;
import android.provider.MediaStore;
import android.provider.Settings;
import android.net.ConnectivityManager;
import android.net.LinkProperties;
import android.net.Network;
import android.net.NetworkInfo;
import android.net.NetworkRequest;
import android.net.Uri;
import android.net.VpnService;
import android.view.View;
import android.os.Build;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.Manifest;
import android.webkit.MimeTypeMap;
import java.io.IOException;
import java.io.File;
import java.io.FileOutputStream;
import java.lang.StringBuilder;
import java.net.InetAddress;
import java.net.InterfaceAddress;
import java.net.NetworkInterface;
import java.security.GeneralSecurityException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.content.ContextCompat;
import androidx.security.crypto.EncryptedSharedPreferences;
import androidx.security.crypto.MasterKey;
import androidx.browser.customtabs.CustomTabsIntent;
import org.gioui.Gio;
public class App extends Application {
private static final String PEER_TAG = "peer";
static final String STATUS_CHANNEL_ID = "tailscale-status";
static final int STATUS_NOTIFICATION_ID = 1;
static final String NOTIFY_CHANNEL_ID = "tailscale-notify";
static final int NOTIFY_NOTIFICATION_ID = 2;
private static final String FILE_CHANNEL_ID = "tailscale-files";
private static final int FILE_NOTIFICATION_ID = 3;
private static final Handler mainHandler = new Handler(Looper.getMainLooper());
private ConnectivityManager connectivityManager;
public DnsConfig dns = new DnsConfig();
public DnsConfig getDnsConfigObj() { return this.dns; }
@Override public void onCreate() {
super.onCreate();
// Load and initialize the Go library.
Gio.init(this);
this.connectivityManager = (ConnectivityManager) this.getSystemService(Context.CONNECTIVITY_SERVICE);
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);
}
// requestNetwork attempts to find the best network that matches the passed NetworkRequest. It is possible that
// this might return an unusuable network, eg a captive portal.
private void setAndRegisterNetworkCallbacks() {
connectivityManager.requestNetwork(dns.getDNSConfigNetworkRequest(), new ConnectivityManager.NetworkCallback(){
@Override
public void onAvailable(Network network){
super.onAvailable(network);
StringBuilder sb = new StringBuilder("");
LinkProperties linkProperties = connectivityManager.getLinkProperties(network);
List<InetAddress> dnsList = linkProperties.getDnsServers();
for (InetAddress ip : dnsList) {
sb.append(ip.getHostAddress()).append(" ");
}
String searchDomains = linkProperties.getDomains();
if (searchDomains != null) {
sb.append("\n");
sb.append(searchDomains);
}
dns.updateDNSFromNetwork(sb.toString());
onDnsConfigChanged();
}
@Override
public void onLost(Network network) {
super.onLost(network);
onDnsConfigChanged();
}
});
}
public void startVPN() {
Intent intent = new Intent(this, IPNService.class);
intent.setAction(IPNService.ACTION_REQUEST_VPN);
startService(intent);
}
public void stopVPN() {
Intent intent = new Intent(this, IPNService.class);
intent.setAction(IPNService.ACTION_STOP_VPN);
startService(intent);
}
// encryptToPref a byte array of data using the Jetpack Security
// library and writes it to a global encrypted preference store.
public void encryptToPref(String prefKey, String plaintext) throws IOException, GeneralSecurityException {
getEncryptedPrefs().edit().putString(prefKey, plaintext).commit();
}
// decryptFromPref decrypts a encrypted preference using the Jetpack Security
// library and returns the plaintext.
public String decryptFromPref(String prefKey) throws IOException, GeneralSecurityException {
return getEncryptedPrefs().getString(prefKey, null);
}
private SharedPreferences getEncryptedPrefs() throws IOException, GeneralSecurityException {
MasterKey key = new MasterKey.Builder(this)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build();
return EncryptedSharedPreferences.create(
this,
"secret_shared_prefs",
key,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
);
}
public boolean autoConnect = false;
public boolean vpnReady = false;
void setTileReady(boolean ready) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return;
}
QuickToggleService.setReady(this, ready);
android.util.Log.d("App", "Set Tile Ready: " + ready + " " + autoConnect);
vpnReady = ready;
if (ready && autoConnect) {
startVPN();
}
}
void setTileStatus(boolean status) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return;
}
QuickToggleService.setStatus(this, status);
}
String getHostname() {
String userConfiguredDeviceName = getUserConfiguredDeviceName();
if (!isEmpty(userConfiguredDeviceName)) return userConfiguredDeviceName;
return getModelName();
}
String getModelName() {
String manu = Build.MANUFACTURER;
String model = Build.MODEL;
// Strip manufacturer from model.
int idx = model.toLowerCase().indexOf(manu.toLowerCase());
if (idx != -1) {
model = model.substring(idx + manu.length());
model = model.trim();
}
return manu + " " + model;
}
String getOSVersion() {
return Build.VERSION.RELEASE;
}
// get user defined nickname from Settings
// returns null if not available
private String getUserConfiguredDeviceName() {
String nameFromSystemDevice = Settings.Secure.getString(getContentResolver(), "device_name");
if (!isEmpty(nameFromSystemDevice)) return nameFromSystemDevice;
return null;
}
private static boolean isEmpty(String str) {
return str == null || str.length() == 0;
}
// attachPeer adds a Peer fragment for tracking the Activity
// lifecycle.
void attachPeer(Activity act) {
act.runOnUiThread(new Runnable() {
@Override public void run() {
FragmentTransaction ft = act.getFragmentManager().beginTransaction();
ft.add(new Peer(), PEER_TAG);
ft.commit();
act.getFragmentManager().executePendingTransactions();
}
});
}
boolean isChromeOS() {
return getPackageManager().hasSystemFeature("android.hardware.type.pc");
}
void prepareVPN(Activity act, int reqCode) {
act.runOnUiThread(new Runnable() {
@Override public void run() {
Intent intent = VpnService.prepare(act);
if (intent == null) {
onVPNPrepared();
} else {
startActivityForResult(act, intent, reqCode);
}
}
});
}
static void startActivityForResult(Activity act, Intent intent, int request) {
Fragment f = act.getFragmentManager().findFragmentByTag(PEER_TAG);
f.startActivityForResult(intent, request);
}
void showURL(Activity act, String url) {
act.runOnUiThread(new Runnable() {
@Override public void run() {
CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
int headerColor = 0xff496495;
builder.setToolbarColor(headerColor);
CustomTabsIntent intent = builder.build();
intent.launchUrl(act, Uri.parse(url));
}
});
}
// getPackageSignatureFingerprint returns the first package signing certificate, if any.
byte[] getPackageCertificate() throws Exception {
PackageInfo info;
info = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES);
for (Signature signature : info.signatures) {
return signature.toByteArray();
}
return null;
}
void requestWriteStoragePermission(Activity act) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
// We can write files without permission.
return;
}
if (ContextCompat.checkSelfPermission(act, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
return;
}
act.requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, IPNActivity.WRITE_STORAGE_RESULT);
}
String insertMedia(String name, String mimeType) throws IOException {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ContentResolver resolver = getContentResolver();
ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name);
if (!"".equals(mimeType)) {
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
}
Uri root = MediaStore.Files.getContentUri("external");
return resolver.insert(root, contentValues).toString();
} else {
File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
dir.mkdirs();
File f = new File(dir, name);
return Uri.fromFile(f).toString();
}
}
int openUri(String uri, String mode) throws IOException {
ContentResolver resolver = getContentResolver();
return resolver.openFileDescriptor(Uri.parse(uri), mode).detachFd();
}
void deleteUri(String uri) {
ContentResolver resolver = getContentResolver();
resolver.delete(Uri.parse(uri), null, null);
}
public void notifyFile(String uri, String msg) {
Intent viewIntent;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
viewIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));
} else {
// uri is a file:// which is not allowed to be shared outside the app.
viewIntent = new Intent(DownloadManager.ACTION_VIEW_DOWNLOADS);
}
PendingIntent pending = PendingIntent.getActivity(this, 0, viewIntent, PendingIntent.FLAG_UPDATE_CURRENT);
NotificationCompat.Builder builder = new 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);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.notify(FILE_NOTIFICATION_ID, builder.build());
}
public void createNotificationChannel(String id, String name, int importance) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationChannel channel = new NotificationChannel(id, name, importance);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.createNotificationChannel(channel);
}
static native void onVPNPrepared();
private static native void onDnsConfigChanged();
static native void onShareIntent(int nfiles, int[] types, String[] mimes, String[] items, String[] names, long[] sizes);
static native void onWriteStorageGranted();
// Returns details of the interfaces in the system, encoded as a single string for ease
// of JNI transfer over to the Go environment.
//
// Example:
// rmnet_data0 10 2000 true false false false false | fe80::4059:dc16:7ed3:9c6e%rmnet_data0/64
// dummy0 3 1500 true false false false false | fe80::1450:5cff:fe13:f891%dummy0/64
// wlan0 30 1500 true true false false true | fe80::2f60:2c82:4163:8389%wlan0/64 10.1.10.131/24
// r_rmnet_data0 21 1500 true false false false false | fe80::9318:6093:d1ad:ba7f%r_rmnet_data0/64
// rmnet_data2 12 1500 true false false false false | fe80::3c8c:44dc:46a9:9907%rmnet_data2/64
// r_rmnet_data1 22 1500 true false false false false | fe80::b6cd:5cb0:8ae6:fe92%r_rmnet_data1/64
// rmnet_data1 11 1500 true false false false false | fe80::51f2:ee00:edce:d68b%rmnet_data1/64
// lo 1 65536 true false true false false | ::1/128 127.0.0.1/8
// v4-rmnet_data2 68 1472 true true false true true | 192.0.0.4/32
//
// Where the fields are:
// name ifindex mtu isUp hasBroadcast isLoopback isPointToPoint hasMulticast | ip1/N ip2/N ip3/N;
String getInterfacesAsString() {
List<NetworkInterface> interfaces;
try {
interfaces = Collections.list(NetworkInterface.getNetworkInterfaces());
} catch (Exception e) {
return "";
}
StringBuilder sb = new StringBuilder("");
for (NetworkInterface nif : interfaces) {
try {
// Android doesn't have a supportsBroadcast() but the Go net.Interface wants
// one, so we say the interface has broadcast if it has multicast.
sb.append(String.format(java.util.Locale.ROOT, "%s %d %d %b %b %b %b %b |", nif.getName(),
nif.getIndex(), nif.getMTU(), nif.isUp(), nif.supportsMulticast(),
nif.isLoopback(), nif.isPointToPoint(), nif.supportsMulticast()));
for (InterfaceAddress ia : nif.getInterfaceAddresses()) {
// InterfaceAddress == hostname + "/" + IP
String[] parts = ia.toString().split("/", 0);
if (parts.length > 1) {
sb.append(String.format(java.util.Locale.ROOT, "%s/%d ", parts[1], ia.getNetworkPrefixLength()));
}
}
} catch (Exception e) {
// TODO(dgentry) should log the exception not silently suppress it.
continue;
}
sb.append("\n");
}
return sb.toString();
}
boolean isTV() {
UiModeManager mm = (UiModeManager)getSystemService(UI_MODE_SERVICE);
return mm.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION;
}
}

@ -1,63 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.net.NetworkCapabilities;
import android.net.NetworkRequest;
import java.lang.reflect.Method;
import java.net.InetAddress;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
// Tailscale DNS Config retrieval
//
// Tailscale's DNS support can either override the local DNS servers with a set of servers
// configured in the admin panel, or supplement the local DNS servers with additional
// servers for specific domains like example.com.beta.tailscale.net. In the non-override mode,
// we need to retrieve the current set of DNS servers from the platform. These will typically
// be the DNS servers received from DHCP.
//
// Importantly, after the Tailscale VPN comes up it will set a DNS server of 100.100.100.100
// but we still want to retrieve the underlying DNS servers received from DHCP. If we roam
// from Wi-Fi to LTE, we want the DNS servers received from LTE.
public class DnsConfig {
private String dnsConfigs;
// getDnsConfigAsString returns the current DNS configuration as a multiline string:
// line[0] DNS server addresses separated by spaces
// line[1] search domains separated by spaces
//
// For example:
// 8.8.8.8 8.8.4.4
// example.com
//
// an empty string means the current DNS configuration could not be retrieved.
String getDnsConfigAsString() {
return getDnsConfigs().trim();
}
private String getDnsConfigs(){
synchronized(this) {
return this.dnsConfigs;
}
}
void updateDNSFromNetwork(String dnsConfigs){
synchronized(this) {
this.dnsConfigs = dnsConfigs;
}
}
NetworkRequest getDNSConfigNetworkRequest(){
// Request networks that are able to reach the Internet.
return new NetworkRequest.Builder().addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build();
}
}

@ -1,132 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.app.Activity;
import android.content.res.AssetFileDescriptor;
import android.content.res.Configuration;
import android.content.Intent;
import android.database.Cursor;
import android.os.Bundle;
import android.provider.OpenableColumns;
import android.net.Uri;
import android.content.pm.PackageManager;
import java.util.List;
import java.util.ArrayList;
import org.gioui.GioView;
public final class IPNActivity extends Activity {
final static int WRITE_STORAGE_RESULT = 1000;
private GioView view;
@Override public void onCreate(Bundle state) {
super.onCreate(state);
view = new GioView(this);
setContentView(view);
handleIntent();
}
@Override public void onNewIntent(Intent i) {
setIntent(i);
handleIntent();
}
private void handleIntent() {
Intent it = getIntent();
String act = it.getAction();
String[] texts;
Uri[] uris;
if (Intent.ACTION_SEND.equals(act)) {
uris = new Uri[]{it.getParcelableExtra(Intent.EXTRA_STREAM)};
texts = new String[]{it.getStringExtra(Intent.EXTRA_TEXT)};
} else if (Intent.ACTION_SEND_MULTIPLE.equals(act)) {
List<Uri> extraUris = it.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
uris = extraUris.toArray(new Uri[0]);
texts = new String[uris.length];
} else {
return;
}
String mime = it.getType();
int nitems = uris.length;
String[] items = new String[nitems];
String[] mimes = new String[nitems];
int[] types = new int[nitems];
String[] names = new String[nitems];
long[] sizes = new long[nitems];
int nfiles = 0;
for (int i = 0; i < uris.length; i++) {
String text = texts[i];
Uri uri = uris[i];
if (text != null) {
types[nfiles] = 1; // FileTypeText
names[nfiles] = "file.txt";
mimes[nfiles] = mime;
items[nfiles] = text;
// Determined by len(text) in Go to eliminate UTF-8 encoding differences.
sizes[nfiles] = 0;
nfiles++;
} else if (uri != null) {
Cursor c = getContentResolver().query(uri, null, null, null, null);
if (c == null) {
// Ignore files we have no permission to access.
continue;
}
int nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
int sizeCol = c.getColumnIndex(OpenableColumns.SIZE);
c.moveToFirst();
String name = c.getString(nameCol);
long size = c.getLong(sizeCol);
types[nfiles] = 2; // FileTypeURI
mimes[nfiles] = mime;
items[nfiles] = uri.toString();
names[nfiles] = name;
sizes[nfiles] = size;
nfiles++;
}
}
App.onShareIntent(nfiles, types, mimes, items, names, sizes);
}
@Override public void onRequestPermissionsResult(int reqCode, String[] perms, int[] grants) {
switch (reqCode) {
case WRITE_STORAGE_RESULT:
if (grants.length > 0 && grants[0] == PackageManager.PERMISSION_GRANTED) {
App.onWriteStorageGranted();
}
}
}
@Override public void onDestroy() {
view.destroy();
super.onDestroy();
}
@Override public void onStart() {
super.onStart();
view.start();
}
@Override public void onStop() {
view.stop();
super.onStop();
}
@Override public void onConfigurationChanged(Configuration c) {
super.onConfigurationChanged(c);
view.configurationChanged();
}
@Override public void onLowMemory() {
super.onLowMemory();
view.onLowMemory();
}
@Override public void onBackPressed() {
if (!view.backPressed())
super.onBackPressed();
}
}

@ -1,28 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import androidx.work.WorkManager;
import androidx.work.OneTimeWorkRequest;
public class IPNReceiver extends BroadcastReceiver {
public static final String INTENT_CONNECT_VPN = "com.tailscale.ipn.CONNECT_VPN";
public static final String INTENT_DISCONNECT_VPN = "com.tailscale.ipn.DISCONNECT_VPN";
@Override
public void onReceive(Context context, Intent intent) {
WorkManager workManager = WorkManager.getInstance(context);
// On the relevant action, start the relevant worker, which can stay active for longer than this receiver can.
if (intent.getAction() == INTENT_CONNECT_VPN) {
workManager.enqueue(new OneTimeWorkRequest.Builder(StartVPNWorker.class).build());
} else if (intent.getAction() == INTENT_DISCONNECT_VPN) {
workManager.enqueue(new OneTimeWorkRequest.Builder(StopVPNWorker.class).build());
}
}
}

@ -1,137 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.os.Build;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.VpnService;
import android.system.OsConstants;
import androidx.work.WorkManager;
import androidx.work.OneTimeWorkRequest;
import org.gioui.GioActivity;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
public class IPNService extends VpnService {
public static final String ACTION_REQUEST_VPN = "com.tailscale.ipn.REQUEST_VPN";
public static final String ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN";
@Override public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && ACTION_STOP_VPN.equals(intent.getAction())) {
((App)getApplicationContext()).autoConnect = false;
close();
return START_NOT_STICKY;
}
if (intent != null && "android.net.VpnService".equals(intent.getAction())) {
// Start VPN and connect to it due to Always-on VPN
Intent i = new Intent(IPNReceiver.INTENT_CONNECT_VPN);
i.setPackage(getPackageName());
i.setClass(getApplicationContext(), com.tailscale.ipn.IPNReceiver.class);
sendBroadcast(i);
requestVPN();
connect();
return START_STICKY;
}
requestVPN();
App app = ((App)getApplicationContext());
if (app.vpnReady && app.autoConnect) {
connect();
}
return START_STICKY;
}
private void close() {
stopForeground(true);
disconnect();
}
@Override public void onDestroy() {
close();
super.onDestroy();
}
@Override public void onRevoke() {
close();
super.onRevoke();
}
private PendingIntent configIntent() {
return PendingIntent.getActivity(this, 0, new Intent(this, IPNActivity.class),
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
}
private void disallowApp(VpnService.Builder b, String name) {
try {
b.addDisallowedApplication(name);
} catch (PackageManager.NameNotFoundException e) {
return;
}
}
protected VpnService.Builder newBuilder() {
VpnService.Builder b = new VpnService.Builder()
.setConfigureIntent(configIntent())
.allowFamily(OsConstants.AF_INET)
.allowFamily(OsConstants.AF_INET6);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
b.setMetered(false); // Inherit the metered status from the underlying networks.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
b.setUnderlyingNetworks(null); // Use all available networks.
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
this.disallowApp(b, "com.google.android.apps.messaging");
// Stadia https://github.com/tailscale/tailscale/issues/3460
this.disallowApp(b, "com.google.stadia.android");
// Android Auto https://github.com/tailscale/tailscale/issues/3828
this.disallowApp(b, "com.google.android.projection.gearhead");
// GoPro https://github.com/tailscale/tailscale/issues/2554
this.disallowApp(b, "com.gopro.smarty");
// Sonos https://github.com/tailscale/tailscale/issues/2548
this.disallowApp(b, "com.sonos.acr");
this.disallowApp(b, "com.sonos.acr2");
// Google Chromecast https://github.com/tailscale/tailscale/issues/3636
this.disallowApp(b, "com.google.android.apps.chromecast.app");
return b;
}
public void notify(String title, String message) {
NotificationCompat.Builder builder = new 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);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
nm.notify(App.NOTIFY_NOTIFICATION_ID, builder.build());
}
public void updateStatusNotification(String title, String message) {
NotificationCompat.Builder builder = new 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());
}
private native void requestVPN();
private native void disconnect();
private native void connect();
}

@ -1,16 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.app.Activity;
import android.app.Fragment;
import android.content.Intent;
public class Peer extends Fragment {
@Override public void onActivityResult(int requestCode, int resultCode, Intent data) {
onActivityResult0(getActivity(), requestCode, resultCode);
}
private static native void onActivityResult0(Activity act, int reqCode, int resCode);
}

@ -1,87 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.content.Context;
import android.content.Intent;
import android.service.quicksettings.Tile;
import android.service.quicksettings.TileService;
public class QuickToggleService extends TileService {
// lock protects the static fields below it.
private static Object lock = new Object();
// Active tracks whether the VPN is active.
private static boolean active;
// Ready tracks whether the tailscale backend is
// ready to switch on/off.
private static boolean ready;
// currentTile tracks getQsTile while service is listening.
private static Tile currentTile;
@Override public void onStartListening() {
synchronized (lock) {
currentTile = getQsTile();
}
updateTile();
}
@Override public void onStopListening() {
synchronized (lock) {
currentTile = null;
}
}
@Override public void onClick() {
boolean r;
synchronized (lock) {
r = ready;
}
if (r) {
onTileClick();
} else {
// Start main activity.
Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName());
startActivityAndCollapse(i);
}
}
private static void updateTile() {
Tile t;
boolean act;
synchronized (lock) {
t = currentTile;
act = active && ready;
}
if (t == null) {
return;
}
t.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
t.updateTile();
}
static void setReady(Context ctx, boolean rdy) {
synchronized (lock) {
ready = rdy;
}
updateTile();
}
static void setStatus(Context ctx, boolean act) {
synchronized (lock) {
active = act;
}
updateTile();
}
private void onTileClick() {
boolean act;
synchronized (lock) {
act = active && ready;
}
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);
}
}

@ -1,65 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.net.VpnService;
import android.os.Build;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
public final class StartVPNWorker extends Worker {
public StartVPNWorker(
Context appContext,
WorkerParameters workerParams) {
super(appContext, workerParams);
}
@Override public Result doWork() {
App app = ((App)getApplicationContext());
// We will start the VPN from the background
app.autoConnect = true;
// We need to make sure we prepare the VPN Service, just in case it isn't prepared.
Intent intent = VpnService.prepare(app);
if (intent == null) {
// If null then the VPN is already prepared and/or it's just been prepared because we have permission
app.startVPN();
return Result.success();
} else {
// This VPN possibly doesn't have permission, we need to display a notification which when clicked launches the intent provided.
android.util.Log.e("StartVPNWorker", "Tailscale doesn't have permission from the system to start VPN. Launching the intent provided.");
// Send notification
NotificationManager notificationManager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE);
String channelId = "start_vpn_channel";
// Use createNotificationChannel method from App.java
app.createNotificationChannel(channelId, "Start VPN Channel", NotificationManager.IMPORTANCE_DEFAULT);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
int pendingIntentFlags = PendingIntent.FLAG_ONE_SHOT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_IMMUTABLE : 0);
PendingIntent pendingIntent = PendingIntent.getActivity(app, 0, intent, pendingIntentFlags);
Notification notification = new Notification.Builder(app, channelId)
.setContentTitle("Tailscale Connection Failed")
.setContentText("Tap here to renew permission.")
.setSmallIcon(R.drawable.ic_notification)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build();
notificationManager.notify(1, notification);
return Result.failure();
}
}
}

@ -1,24 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import androidx.work.Worker;
import android.content.Context;
import androidx.work.WorkerParameters;
public final class StopVPNWorker extends Worker {
public StopVPNWorker(
Context appContext,
WorkerParameters workerParams) {
super(appContext, workerParams);
}
@Override public Result doWork() {
disconnect();
return Result.success();
}
private native void disconnect();
}

@ -1,149 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.localapi
import android.util.Log
import com.tailscale.ipn.ui.model.*
import kotlinx.coroutines.*
// A response from the echo endpoint.
typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit
typealias BugReportIdHandler = (Result<BugReportID>) -> Unit
typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
class LocalApiClient {
constructor() {
Log.d("LocalApiClient", "LocalApiClient created")
}
// Perform a request to the local API in the go backend. This is
// the primary JNI method for servicing a localAPI call. This
// is GUARANTEED to call back into onResponse with the response
// from the backend with a matching cookie.
// @see cmd/localapiclient/localapishim.go
//
// request: The path to the localAPI endpoint.
// method: The HTTP method to use.
// body: The body of the request.
// cookie: A unique identifier for this request. This is used map responses to
// the corresponding request. Cookies must be unique for each request.
external fun doRequest(request: String, method: String, body: String, cookie: String)
fun <T> executeRequest(request: LocalAPIRequest<T>) {
Log.d("LocalApiClient", "Executing request:${request.method}:${request.path}")
addRequest(request)
// The jni handler will treat the empty string in the body as null.
val body = request.body ?: ""
doRequest(request.path, request.method, body, request.cookie)
}
// This is called from the JNI layer to publish localAPIResponses. This should execute on the
// same thread that called doRequest.
fun onResponse(response: String, cookie: String) {
val request = requests[cookie]
if (request != null) {
Log.d("LocalApiClient", "Reponse for request:${request.path} cookie:${request.cookie}")
// The response handler will invoked internally by the request parser
request.parser(response)
removeRequest(cookie)
} else {
Log.e("LocalApiClient", "Received response for unknown request: ${cookie}")
}
}
// Tracks in-flight requests and their callback handlers by cookie. This should
// always be manipulated via the addRequest and removeRequest methods.
private var requests = HashMap<String, LocalAPIRequest<*>>()
private var requestLock = Any()
fun addRequest(request: LocalAPIRequest<*>) {
synchronized(requestLock) { requests[request.cookie] = request }
}
fun removeRequest(cookie: String) {
synchronized(requestLock) { requests.remove(cookie) }
}
// localapi Invocations
fun getStatus(responseHandler: StatusResponseHandler) {
val req = LocalAPIRequest.status(responseHandler)
executeRequest<IpnState.Status>(req)
}
fun getBugReportId(responseHandler: BugReportIdHandler) {
val req = LocalAPIRequest.bugReportId(responseHandler)
executeRequest<BugReportID>(req)
}
fun getPrefs(responseHandler: PrefsHandler) {
val req = LocalAPIRequest.prefs(responseHandler)
executeRequest<Ipn.Prefs>(req)
}
// (jonathan) TODO: A (likely) exhaustive list of localapi endpoints required for
// a fully functioning client. This is a work in progress and will be updated
// See: corp/xcode/Shared/LocalAPIClient.swift for the various verbs, parameters,
// and body contents for each endpoint. Endpoints are defined in LocalAPIEndpoint
//
// fetchFileTargets
// sendFiles
// getWaitingFiles
// recieveWaitingFile
// inidicateFileRecieved
// debug
// debugLog
// uploadClientMetrics
// start
// startLoginInteractive
// logout
// profiles
// currentProfile
// addProfile
// switchProfile
// deleteProfile
// tailnetLocalStatus
// signNode
// verifyDeepling
// ping
// setTailFSFileServerAddress
// Run some tests to validate the APIs work before we have anything
// that calls them. This runs after a short delay to avoid not-ready
// errors
// (jonathan) TODO: Do we need some kind of "onReady" callback?
// (jonathan) TODO: Remove these we're further along
fun runAPITests() = runBlocking {
delay(5000L)
getStatus { result ->
if (result.failed) {
Log.e("LocalApiClient", "Error getting status: ${result.error}")
} else {
val status = result.success
Log.d("LocalApiClient", "Got status: ${status}")
}
}
getBugReportId { result ->
if (result.failed) {
Log.e("LocalApiClient", "Error getting bug report id: ${result.error}")
} else {
val bugReportId = result.success
Log.d("LocalApiClient", "Got bug report id: ${bugReportId}")
}
}
getPrefs { result ->
if (result.failed) {
Log.e("LocalApiClient", "Error getting prefs: ${result.error}")
} else {
val prefs = result.success
Log.d("LocalApiClient", "Got prefs: ${prefs}")
}
}
}
}

@ -1,128 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.localapi
import com.tailscale.ipn.ui.model.*
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
enum class LocalAPIEndpoint(val rawValue: String) {
Debug("debug"),
Debug_Log("debug-log"),
BugReport("bugreport"),
Prefs("prefs"),
FileTargets("file-targets"),
UploadMetrics("upload-client-metrics"),
Start("start"),
LoginInteractive("login-interactive"),
ResetAuth("reset-auth"),
Logout("logout"),
Profiles("profiles"),
ProfilesCurrent("profiles/current"),
Status("status"),
TKAStatus("tka/status"),
TKASitng("tka/sign"),
TKAVerifyDeeplink("tka/verify-deeplink"),
Ping("ping"),
Files("files"),
FilePut("file-put"),
TailFSServerAddress("tailfs/fileserver-address");
val prefix = "/localapi/v0/"
fun path(): String {
return prefix + rawValue
}
}
// Potential local and upstream errors. Error handling in localapi in the go layer
// is inconsistent but different clients already deal with that inconsistency so
// 'fixing' it will likely break other things.
//
// For now, anything that isn't an { error: "message" } will be passed along
// as UNPARSEABLE_RESPONSE. We can add additional error translation in the parseError
// method as needed.
//
// (jonathan) TODO: Audit local API for all of the possible error results and clean
// it up if possible.
enum class APIErrorVals(val rawValue: String) {
UNPARSEABLE_RESPONSE("Unparseable localAPI response"),
NOT_READY("Not Ready");
fun toError(): Error {
return Error(rawValue)
}
}
class LocalAPIRequest<T>(
val path: String,
val method: String,
val body: String? = null,
val responseHandler: (Result<T>) -> Unit,
val parser: (String) -> Unit,
) {
companion object {
val cookieLock = Any()
var cookieCounter: Int = 0
val decoder = Json { ignoreUnknownKeys = true }
fun getCookie(): String {
synchronized(cookieLock) {
cookieCounter += 1
return cookieCounter.toString()
}
}
fun status(responseHandler: StatusResponseHandler): LocalAPIRequest<IpnState.Status> {
val path = LocalAPIEndpoint.Status.path()
return LocalAPIRequest<IpnState.Status>(path, "GET", null, responseHandler) { resp ->
responseHandler(decode<IpnState.Status>(resp))
}
}
fun bugReportId(responseHandler: BugReportIdHandler): LocalAPIRequest<BugReportID> {
val path = LocalAPIEndpoint.BugReport.path()
return LocalAPIRequest<BugReportID>(path, "POST", null, responseHandler) { resp ->
responseHandler(parseString(resp))
}
}
fun prefs(responseHandler: PrefsHandler): LocalAPIRequest<Ipn.Prefs> {
val path = LocalAPIEndpoint.Prefs.path()
return LocalAPIRequest<Ipn.Prefs>(path, "GET", null, responseHandler) { resp ->
responseHandler(decode<Ipn.Prefs>(resp))
}
}
// Check if the response was a generic error
fun parseError(respData: String): Error {
try {
val err = Json.decodeFromString<Errors.GenericError>(respData)
return Error(err.error)
} catch (e: Exception) {
return Error(APIErrorVals.UNPARSEABLE_RESPONSE.toError())
}
}
// Handles responses that are raw strings. Returns an error result if the string
// is empty
fun parseString(respData: String): Result<String> {
return if (respData.length > 0) Result(respData)
else Result(APIErrorVals.UNPARSEABLE_RESPONSE.toError())
}
// Attempt to decode the response into the expected type. If that fails, then try
// parsing as an error.
inline fun <reified T> decode(respData: String): Result<T> {
try {
val message = decoder.decodeFromString<T>(respData)
return Result(message)
} catch (e: Exception) {
return Result(parseError(respData))
}
}
}
val cookie: String = getCookie()
}

@ -1,32 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.localapi
// Go-like result type with an optional value and an optional Error
// This guarantees that only one of the two is non-null
class Result<T> {
val success: T?
val error: Error?
constructor(success: T?, error: Error?) {
if (success != null && error != null) {
throw IllegalArgumentException("Result cannot have both a success and an error")
}
if (success == null && error == null) {
throw IllegalArgumentException("Result must have either a success or an error")
}
this.success = success
this.error = error
}
constructor(success: T) : this(success, null) {}
constructor(error: Error) : this(null, error) {}
var successful: Boolean = false
get() = success != null
var failed: Boolean = false
get() = error != null
}

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.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

@ -1,36 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="100"
android:viewportHeight="100">
<group android:translateX="20"
android:translateY="20">
<path
android:pathData="M15,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#FFFDFA"/>
<path
android:pathData="M30,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#FFFDFA"/>
<path
android:pathData="M15,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#54514D"/>
<path
android:pathData="M45,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#54514D"/>
<path
android:pathData="M30,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#FFFDFA"/>
<path
android:pathData="M45,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#FFFDFA"/>
<path
android:pathData="M15,15.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#54514D"/>
<path
android:pathData="M30,14.9999m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#54514D"/>
<path
android:pathData="M45,14.9999m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#54514D"/>
</group>
</vector>

@ -1,35 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="60"
android:viewportHeight="60">
<group>
<path
android:pathData="M15,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#FFFDFA"/>
<path
android:pathData="M30,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#FFFDFA"/>
<path
android:pathData="M15,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#54514D"/>
<path
android:pathData="M45,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#54514D"/>
<path
android:pathData="M30,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#FFFDFA"/>
<path
android:pathData="M45,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#FFFDFA"/>
<path
android:pathData="M15,15.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#54514D"/>
<path
android:pathData="M30,14.9999m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#54514D"/>
<path
android:pathData="M45,14.9999m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
android:fillColor="#54514D"/>
</group>
</vector>

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1021 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

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