From 6fb006e91bddb2b00cf86d045d3d8cb82b88af94 Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Fri, 2 May 2025 10:09:03 -0400 Subject: [PATCH 01/49] android: bump OSS (#644) OSS and Version updated to 1.83.162-ta9b3e09a1-g8683c789f Fixes a breaking change in the NetMon constructor. Signed-off-by: Jonathan Nobels --- go.mod | 4 ++-- go.sum | 10 ++++++---- libtailscale/backend.go | 7 ++++++- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 94b6204..9a6d67f 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.24.0 require ( github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab - tailscale.com v1.83.0-pre.0.20250414201714-10fd61f1bb6b + tailscale.com v1.83.0-pre.0.20250430003547-a9b3e09a1f51 ) require ( @@ -41,7 +41,7 @@ require ( github.com/google/go-cmp v0.6.0 // indirect github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 // indirect + github.com/gorilla/csrf v1.7.3 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect github.com/illarion/gonotify/v3 v3.0.2 // indirect diff --git a/go.sum b/go.sum index f5c6587..86b8903 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NA github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= +github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= +github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -77,8 +79,8 @@ github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdF github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 h1:fiJdrgVBkjZ5B1HJ2WQwNOaXB+QyYcNXTA3t1XYLz0M= -github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= +github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0= +github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= @@ -215,5 +217,5 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.83.0-pre.0.20250414201714-10fd61f1bb6b h1:KfTHlSLdZd2XhZQ0t6C2AXu9/QxmZR/eXfHOzOnQ3V8= -tailscale.com v1.83.0-pre.0.20250414201714-10fd61f1bb6b/go.mod h1:CCR2Ti9anln7NMAbpSoECkd5P4N80OhjneOQ/GarSBE= +tailscale.com v1.83.0-pre.0.20250430003547-a9b3e09a1f51 h1:jk6Gnfd4ruBGefcZ+PxoNKE5GKeN+9M6rTRh8TccHJM= +tailscale.com v1.83.0-pre.0.20250430003547-a9b3e09a1f51/go.mod h1:m/VRVYF8Tt9o8TKHhmZ4NDzHHrrdY/mAGxTjTzHaKZs= diff --git a/libtailscale/backend.go b/libtailscale/backend.go index 21eea06..00ca9c6 100644 --- a/libtailscale/backend.go +++ b/libtailscale/backend.go @@ -33,6 +33,7 @@ import ( "tailscale.com/types/logger" "tailscale.com/types/logid" "tailscale.com/types/netmap" + "tailscale.com/util/eventbus" "tailscale.com/wgengine" "tailscale.com/wgengine/netstack" "tailscale.com/wgengine/router" @@ -96,6 +97,8 @@ type backend struct { logIDPublic logid.PublicID logger *logtail.Logger + bus *eventbus.Bus + // avoidEmptyDNS controls whether to use fallback nameservers // when no nameservers are provided by Tailscale. avoidEmptyDNS bool @@ -249,7 +252,9 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor devices: newTUNDevices(), settings: settings, appCtx: appCtx, + bus: eventbus.New(), } + var logID logid.PrivateID logID.UnmarshalText([]byte("dead0000dead0000dead0000dead0000dead0000dead0000dead0000dead0000")) storedLogID, err := store.read(logPrefKey) @@ -268,7 +273,7 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor logID.UnmarshalText([]byte(storedLogID)) } - netMon, err := netmon.New(logf) + netMon, err := netmon.New(b.bus, logf) if err != nil { log.Printf("netmon.New: %w", err) } From 976ba8eee4fff3d2eb59e472d34cb3e7612dadcd Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Wed, 7 May 2025 14:54:41 -0400 Subject: [PATCH 02/49] android: bump OSS (#645) OSS and Version updated to 1.83.190-tfd263adc1-g5b4eff216 Signed-off-by: Jonathan Nobels --- go.mod | 3 ++- go.sum | 30 ++++++++++++++++++++++++++++-- libtailscale/backend.go | 6 +++++- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 9a6d67f..dab17b2 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.24.0 require ( github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab - tailscale.com v1.83.0-pre.0.20250430003547-a9b3e09a1f51 + tailscale.com v1.83.0-pre.0.20250507173847-fd263adc1b5b ) require ( @@ -39,6 +39,7 @@ require ( github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/btree v1.1.2 // indirect github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-tpm v0.9.4 // indirect github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/csrf v1.7.3 // indirect diff --git a/go.sum b/go.sum index 86b8903..e8e9f52 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,17 @@ +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q= +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/aws/aws-sdk-go-v2 v1.36.0 h1:b1wM5CcE65Ujwn565qcwgtOTT1aT4ADOHHgglKjG7fk= github.com/aws/aws-sdk-go-v2 v1.36.0/go.mod h1:5PMILGVKiW32oDzjj6RU52yrNrDPUHcbZQYr1sM7qmM= github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k= @@ -42,6 +48,8 @@ github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6 github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= +github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0= +github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -65,6 +73,8 @@ github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 h1:F8d1AJ6 github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= @@ -73,6 +83,10 @@ github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I= +github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= @@ -101,6 +115,8 @@ github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IX github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -121,6 +137,8 @@ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= +github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -151,12 +169,16 @@ github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+y github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 h1:h/41LFTrwMxB9Xvvug0kRdQCU5TlV1+pAMQw0ZtDE3U= github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg= +github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= @@ -174,6 +196,8 @@ golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= +golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= +golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab h1:KONOFF8Uy3b60HEzOsGnNghORNhY4ImyOx0PGm73K9k= @@ -213,9 +237,11 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 h1:2gap+Kh/3F47cO6hAu3idFvsJ0ue6TRcEi2IUkv/F8k= gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633/go.mod h1:5DMfjtclAbTIjbXqO1qCe2K5GKKxWz2JHvCChuTcJEM= +honnef.co/go/tools v0.5.1 h1:4bH5o3b5ZULQ4UrBmP+63W9r7qIkqJClEA9ko5YKx+I= +honnef.co/go/tools v0.5.1/go.mod h1:e9irvo83WDG9/irijV44wr3tbhcFeRnfpVlRqVwpzMs= howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.83.0-pre.0.20250430003547-a9b3e09a1f51 h1:jk6Gnfd4ruBGefcZ+PxoNKE5GKeN+9M6rTRh8TccHJM= -tailscale.com v1.83.0-pre.0.20250430003547-a9b3e09a1f51/go.mod h1:m/VRVYF8Tt9o8TKHhmZ4NDzHHrrdY/mAGxTjTzHaKZs= +tailscale.com v1.83.0-pre.0.20250507173847-fd263adc1b5b h1:4CMBtWus+ZVwEB4MVc8lNehVjOpBwkc8M4G0Bsyt9eg= +tailscale.com v1.83.0-pre.0.20250507173847-fd263adc1b5b/go.mod h1:6/S63NMAhmncYT/1zIPDJkvCuZwMw+JnUuOfSPNazpo= diff --git a/libtailscale/backend.go b/libtailscale/backend.go index 00ca9c6..b95d343 100644 --- a/libtailscale/backend.go +++ b/libtailscale/backend.go @@ -18,6 +18,7 @@ import ( "tailscale.com/drive/driveimpl" _ "tailscale.com/feature/condregister" + "tailscale.com/feature/taildrop" "tailscale.com/hostinfo" "tailscale.com/ipn" "tailscale.com/ipn/ipnauth" @@ -317,7 +318,10 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor engine.Close() return nil, fmt.Errorf("runBackend: NewLocalBackend: %v", err) } - lb.SetDirectFileRoot(directFileRoot) + + if ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb); ok { + ext.SetDirectFileRoot(directFileRoot) + } if err := ns.Start(lb); err != nil { return nil, fmt.Errorf("startNetstack: %w", err) From ca7dc5f8a8d88f6c557db754053f97d0dea4617a Mon Sep 17 00:00:00 2001 From: davfsa Date: Thu, 8 May 2025 01:01:11 +0200 Subject: [PATCH 03/49] android: ensure in secure state to interact with quicktile (#622) * android: ensure in secure state to interact with quicktile Updates tailscale/tailscale#14628 Signed-off-by: davfsa * Update android/src/main/java/com/tailscale/ipn/QuickToggleService.java Signed-off-by: davfsa --------- Signed-off-by: davfsa --- .../src/main/java/com/tailscale/ipn/QuickToggleService.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/tailscale/ipn/QuickToggleService.java b/android/src/main/java/com/tailscale/ipn/QuickToggleService.java index 0ac3bd0..f2374cc 100644 --- a/android/src/main/java/com/tailscale/ipn/QuickToggleService.java +++ b/android/src/main/java/com/tailscale/ipn/QuickToggleService.java @@ -60,9 +60,13 @@ public class QuickToggleService extends TileService { } } - @SuppressWarnings("deprecation") @Override public void onClick() { + unlockAndRun(this::secureOnClick); + } + + @SuppressWarnings("deprecation") + private void secureOnClick() { boolean r; synchronized (lock) { r = UninitializedApp.get().isAbleToStartVPN(); From d3f34c579decb2a543839d90ea0759b31db1d2c0 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Mon, 12 May 2025 09:22:19 -0700 Subject: [PATCH 04/49] android: defer vpn permission until activity is resumed (#647) Right now, we register the launcher in MainActivity.onCreate(), inject this into the ViewModel, then show the launcher in MainView. There is no guarantee that the activity is in RESUMED when the Composable runs, showing the launcher. This can lead to a silent RESULT_CANCELED on some OEMs. The fix is to add a lifecycle-aware wrapper that defers the launch. Updates tailscale/tailscale#15419 Signed-off-by: kari-ts --- .../com/tailscale/ipn/ui/localapi/Client.kt | 6 ++--- .../com/tailscale/ipn/ui/view/MainView.kt | 22 +++++++++++++++++-- .../ipn/ui/viewModel/MainViewModel.kt | 9 +++++++- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt index 5b38b6a..2a30db4 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt @@ -14,9 +14,6 @@ import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.util.InputStreamAdapter import com.tailscale.ipn.util.TSLog -import java.nio.charset.Charset -import kotlin.reflect.KType -import kotlin.reflect.typeOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -26,6 +23,9 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.serializer import libtailscale.FilePart +import java.nio.charset.Charset +import kotlin.reflect.KType +import kotlin.reflect.typeOf private object Endpoint { const val DEBUG = "debug" diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index 90e99e6..fdb16bb 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -70,6 +70,9 @@ 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.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.tailscale.ipn.App import com.tailscale.ipn.R @@ -207,8 +210,8 @@ fun MainView( Ipn.State.Running -> { PromptPermissionsIfNecessary() - - viewModel.showVPNPermissionLauncherIfUnauthorized() + viewModel.maybeRequestVpnPermission() + LaunchVpnPermissionIfNeeded(viewModel) if (showKeyExpiry) { ExpiryNotification(netmap = netmap, action = { viewModel.login() }) @@ -253,6 +256,21 @@ fun MainView( } } +@Composable +fun LaunchVpnPermissionIfNeeded(viewModel: MainViewModel) { + val lifecycleOwner = LocalLifecycleOwner.current + val shouldRequest by viewModel.requestVpnPermission.collectAsState() + + LaunchedEffect(shouldRequest) { + if (!shouldRequest) return@LaunchedEffect + + // Defer showing permission launcher until activity is resumed to avoid silent RESULT_CANCELED + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.showVPNPermissionLauncherIfUnauthorized() + } + } +} + @Composable fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { val nodeState by viewModel.nodeState.collectAsState() diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index 2d75841..c0e205a 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -25,7 +25,6 @@ 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 java.time.Duration import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -34,6 +33,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch +import java.time.Duration class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") @@ -60,6 +60,8 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { // Permission to prepare VPN private var vpnPermissionLauncher: ActivityResultLauncher? = null + private val _requestVpnPermission = MutableStateFlow(false) + val requestVpnPermission: StateFlow = _requestVpnPermission // The list of peers private val _peers = MutableStateFlow>(emptyList()) @@ -187,6 +189,10 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } } + fun maybeRequestVpnPermission() { + _requestVpnPermission.value = true + } + fun showVPNPermissionLauncherIfUnauthorized() { val vpnIntent = VpnService.prepare(App.get()) if (vpnIntent != null) { @@ -195,6 +201,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { vpnViewModel.setVpnPrepared(true) startVPN() } + _requestVpnPermission.value = false // reset } fun toggleVpn(desiredState: Boolean) { From 7f56d0c0fe6148a5ed2b44a231fa4e6cedd290c6 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Fri, 16 May 2025 09:26:06 -0700 Subject: [PATCH 05/49] android: bump OSS (#651) OSS and Version updated to 1.83.223-t336b3b7df-gd3f34c579 Signed-off-by: kari-ts --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index dab17b2..2e80ca7 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.24.0 require ( github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab - tailscale.com v1.83.0-pre.0.20250507173847-fd263adc1b5b + tailscale.com v1.83.0-pre.0.20250515212619-336b3b7df0ab ) require ( diff --git a/go.sum b/go.sum index e8e9f52..1bc03ac 100644 --- a/go.sum +++ b/go.sum @@ -243,5 +243,5 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.83.0-pre.0.20250507173847-fd263adc1b5b h1:4CMBtWus+ZVwEB4MVc8lNehVjOpBwkc8M4G0Bsyt9eg= -tailscale.com v1.83.0-pre.0.20250507173847-fd263adc1b5b/go.mod h1:6/S63NMAhmncYT/1zIPDJkvCuZwMw+JnUuOfSPNazpo= +tailscale.com v1.83.0-pre.0.20250515212619-336b3b7df0ab h1:jYAOBs7APf5DopDjTZWTXbUJBp7izN9oBb3fZ4hhC4o= +tailscale.com v1.83.0-pre.0.20250515212619-336b3b7df0ab/go.mod h1:6/S63NMAhmncYT/1zIPDJkvCuZwMw+JnUuOfSPNazpo= From e3c76eb8126179fc709b12f6c0521c9bd3bf1314 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Tue, 20 May 2025 10:42:21 -0700 Subject: [PATCH 06/49] android: fix isExitNode check (#646) && takes precedence over ?:, so fix isExitNode to check both IPv4 and IPv6 Updates tailscale/tailscale#15785 Signed-off-by: kari-ts --- android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt index f79b89d..a51579e 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt @@ -105,7 +105,7 @@ class Tailcfg { // isExitNode reproduces the Go logic in local.go peerStatusFromNode val isExitNode: Boolean = - AllowedIPs?.contains("0.0.0.0/0") ?: false && AllowedIPs?.contains("::/0") ?: false + (AllowedIPs?.contains("0.0.0.0/0") ?: false) && (AllowedIPs?.contains("::/0") ?: false) val isMullvadNode: Boolean get() = Name.endsWith(".mullvad.ts.net.") From 81ff8987829f9c4200632403592edb2f27d0db36 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Tue, 20 May 2025 10:42:43 -0700 Subject: [PATCH 07/49] android: replace broadcast intent with service intent (#650) We were previously calling startService(intent), which is a direct call consumed by IPNService, but restartVPN was not working as intended because the broadcast receiver was never triggered. Rather than use a broadcast receiver, directly start the service in restartVPN as we do in stopVPN. Also, batch changes to excluded apps so that we don't restart the VPN each time the user toggles an app. Fixes tailscale/corp#28668 Signed-off-by: kari-ts --- .../src/main/java/com/tailscale/ipn/App.kt | 67 +++++-------------- .../main/java/com/tailscale/ipn/IPNService.kt | 11 ++- .../SplitTunnelAppPickerViewModel.kt | 23 +++++-- 3 files changed, 45 insertions(+), 56 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index a91325e..3e834cb 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -7,7 +7,6 @@ import android.app.Application import android.app.Notification import android.app.NotificationChannel import android.app.PendingIntent -import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter @@ -37,11 +36,6 @@ import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory import com.tailscale.ipn.util.FeatureFlags import com.tailscale.ipn.util.TSLog -import java.io.File -import java.io.IOException -import java.net.NetworkInterface -import java.security.GeneralSecurityException -import java.util.Locale import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -53,6 +47,11 @@ import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import libtailscale.Libtailscale +import java.io.File +import java.io.IOException +import java.net.NetworkInterface +import java.security.GeneralSecurityException +import java.util.Locale class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -470,25 +469,15 @@ open class UninitializedApp : Application() { } fun restartVPN() { - // Register a receiver to listen for the completion of stopVPN - val stopReceiver = - object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - // Ensure stop intent is complete - if (intent?.action == IPNService.ACTION_STOP_VPN) { - // Unregister receiver after receiving the broadcast - context?.unregisterReceiver(this) - // Now start the VPN - startVPN() - } - } - } - - // Register the receiver before stopping VPN - val intentFilter = IntentFilter(IPNService.ACTION_STOP_VPN) - this.registerReceiver(stopReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED) - - stopVPN() + val intent = + Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_RESTART_VPN } + try { + startService(intent) + } catch (illegalStateException: IllegalStateException) { + TSLog.e(TAG, "restartVPN hit IllegalStateException in startService(): $illegalStateException") + } catch (e: Exception) { + TSLog.e(TAG, "restartVPN hit exception in startService(): $e") + } } fun createNotificationChannel(id: String, name: String, description: String, importance: Int) { @@ -569,33 +558,13 @@ open class UninitializedApp : Application() { return builder.build() } - fun addUserDisallowedPackageName(packageName: String) { - if (packageName.isEmpty()) { - TSLog.e(TAG, "addUserDisallowedPackageName called with empty packageName") - return - } - - getUnencryptedPrefs() - .edit() - .putStringSet( - DISALLOWED_APPS_KEY, disallowedPackageNames().toMutableSet().union(setOf(packageName))) - .apply() - - this.restartVPN() - } - - fun removeUserDisallowedPackageName(packageName: String) { - if (packageName.isEmpty()) { - TSLog.e(TAG, "removeUserDisallowedPackageName called with empty packageName") + fun updateUserDisallowedPackageNames(packageNames: List) { + if (packageNames.any { it.isEmpty() }) { + TSLog.e(TAG, "updateUserDisallowedPackageNames called with empty packageName(s)") return } - getUnencryptedPrefs() - .edit() - .putStringSet( - DISALLOWED_APPS_KEY, - disallowedPackageNames().toMutableSet().subtract(setOf(packageName))) - .apply() + getUnencryptedPrefs().edit().putStringSet(DISALLOWED_APPS_KEY, packageNames.toSet()).apply() this.restartVPN() } diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.kt b/android/src/main/java/com/tailscale/ipn/IPNService.kt index 917b405..e861d9c 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNService.kt +++ b/android/src/main/java/com/tailscale/ipn/IPNService.kt @@ -12,12 +12,12 @@ import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.util.TSLog -import java.util.UUID import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import libtailscale.Libtailscale +import java.util.UUID open class IPNService : VpnService(), libtailscale.IPNService { private val TAG = "IPNService" @@ -46,6 +46,13 @@ open class IPNService : VpnService(), libtailscale.IPNService { close() START_NOT_STICKY } + ACTION_RESTART_VPN -> { + app.setWantRunning(false){ + close() + app.startVPN() + } + START_NOT_STICKY + } ACTION_START_VPN -> { scope.launch { showForegroundNotification() } app.setWantRunning(true) @@ -82,7 +89,6 @@ open class IPNService : VpnService(), libtailscale.IPNService { } override fun close() { - app.setWantRunning(false) {} Notifier.setState(Ipn.State.Stopping) disconnectVPN() Libtailscale.serviceDisconnect(this) @@ -180,5 +186,6 @@ open class IPNService : VpnService(), libtailscale.IPNService { companion object { const val ACTION_START_VPN = "com.tailscale.ipn.START_VPN" const val ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN" + const val ACTION_RESTART_VPN = "com.tailscale.ipn.RESTART_VPN" } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt index d00efb6..7611f05 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt @@ -4,14 +4,18 @@ package com.tailscale.ipn.ui.viewModel import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.tailscale.ipn.App import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.SettingState import com.tailscale.ipn.ui.util.InstalledApp import com.tailscale.ipn.ui.util.InstalledAppsManager import com.tailscale.ipn.ui.util.set +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch class SplitTunnelAppPickerViewModel : ViewModel() { val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager) @@ -20,6 +24,8 @@ class SplitTunnelAppPickerViewModel : ViewModel() { val mdmExcludedPackages: StateFlow> = MDMSettings.excludedPackages.flow val mdmIncludedPackages: StateFlow> = MDMSettings.includedPackages.flow + private var saveJob: Job? = null + init { installedApps.set(installedAppsManager.fetchInstalledApps()) excludedPackageNames.set( @@ -30,15 +36,22 @@ class SplitTunnelAppPickerViewModel : ViewModel() { } fun exclude(packageName: String) { - if (excludedPackageNames.value.contains(packageName)) { - return - } + if (excludedPackageNames.value.contains(packageName)) return excludedPackageNames.set(excludedPackageNames.value + packageName) - App.get().addUserDisallowedPackageName(packageName) + debounceSave() } fun unexclude(packageName: String) { excludedPackageNames.set(excludedPackageNames.value - packageName) - App.get().removeUserDisallowedPackageName(packageName) + debounceSave() + } + + private fun debounceSave() { + saveJob?.cancel() + saveJob = + viewModelScope.launch { + delay(500) // Wait to batch multiple rapid updates + App.get().updateUserDisallowedPackageNames(excludedPackageNames.value) + } } } From d5988faf9acbb0902b4fb83bebb5d149efc2dd52 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Tue, 20 May 2025 10:42:53 -0700 Subject: [PATCH 08/49] android: add IME action to trigger custom CustomLogin (#649) Updates tailscale/tailscale#14864 Signed-off-by: kari-ts --- .../main/java/com/tailscale/ipn/ui/view/CustomLogin.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/CustomLogin.kt b/android/src/main/java/com/tailscale/ipn/ui/view/CustomLogin.kt index 991ec39..83ae0b8 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/CustomLogin.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/CustomLogin.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.ListItem @@ -26,6 +27,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp import com.tailscale.ipn.R @@ -140,7 +142,11 @@ fun LoginView( placeholder = { Text(strings.placeholder, style = MaterialTheme.typography.bodySmall) }, - keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None)) + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, imeAction = ImeAction.Go), + keyboardActions = + KeyboardActions(onGo = { onSubmitAction(textVal) })) }) ListItem( From f01fb7062b731e62cc8246f6e4d71f853c602b8d Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Tue, 20 May 2025 14:02:34 -0400 Subject: [PATCH 09/49] android: bump OSS (#652) OSS and Version updated to 1.83.237-tc4fb380f3-g7f56d0c0f Signed-off-by: Jonathan Nobels --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 2e80ca7..1c94d33 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.24.0 require ( github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab - tailscale.com v1.83.0-pre.0.20250515212619-336b3b7df0ab + tailscale.com v1.83.0-pre.0.20250520103045-c4fb380f3c5f ) require ( diff --git a/go.sum b/go.sum index 1bc03ac..936537d 100644 --- a/go.sum +++ b/go.sum @@ -243,5 +243,5 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.83.0-pre.0.20250515212619-336b3b7df0ab h1:jYAOBs7APf5DopDjTZWTXbUJBp7izN9oBb3fZ4hhC4o= -tailscale.com v1.83.0-pre.0.20250515212619-336b3b7df0ab/go.mod h1:6/S63NMAhmncYT/1zIPDJkvCuZwMw+JnUuOfSPNazpo= +tailscale.com v1.83.0-pre.0.20250520103045-c4fb380f3c5f h1:C5jwV0h09WVzAVBEAYCR848PqVZJQIqB84ZiS2NmCZQ= +tailscale.com v1.83.0-pre.0.20250520103045-c4fb380f3c5f/go.mod h1:6/S63NMAhmncYT/1zIPDJkvCuZwMw+JnUuOfSPNazpo= From eb0a124ba66a5b9938fa1bfddcaa36483b206638 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Tue, 20 May 2025 15:45:42 -0700 Subject: [PATCH 10/49] android: bump OSS (#653) OSS and Version updated to 1.83.240-t5a8b99e97-gd3f34c579 Signed-off-by: kari-ts Signed-off-by: kari-ts <135075563+kari-ts@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 1c94d33..dc2d8eb 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.24.0 require ( github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab - tailscale.com v1.83.0-pre.0.20250520103045-c4fb380f3c5f + tailscale.com v1.83.0-pre.0.20250520223019-5a8b99e977e3 ) require ( diff --git a/go.sum b/go.sum index 936537d..81d769e 100644 --- a/go.sum +++ b/go.sum @@ -243,5 +243,5 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.83.0-pre.0.20250520103045-c4fb380f3c5f h1:C5jwV0h09WVzAVBEAYCR848PqVZJQIqB84ZiS2NmCZQ= -tailscale.com v1.83.0-pre.0.20250520103045-c4fb380f3c5f/go.mod h1:6/S63NMAhmncYT/1zIPDJkvCuZwMw+JnUuOfSPNazpo= +tailscale.com v1.83.0-pre.0.20250520223019-5a8b99e977e3 h1:ygaYcwsF63nxRHuu3sry7xNY7dmmnq1r/ORSry/lHAk= +tailscale.com v1.83.0-pre.0.20250520223019-5a8b99e977e3/go.mod h1:6/S63NMAhmncYT/1zIPDJkvCuZwMw+JnUuOfSPNazpo= From bd5191363c2167116cc749460fb3a48adbdcf88f Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Tue, 20 May 2025 16:05:13 -0700 Subject: [PATCH 11/49] android: use SAF for storing Taildropped files (#632) Use Android Storage Access Framework for receiving Taildropped files. -Add a picker to allow users to select where Taildropped files go -If no directory is selected, internal app storage is used -Provide SAF API for Go to use when writing and renaming files -Provide Android FileOps implementation Updates tailscale/tailscale#15263 Signed-off-by: kari-ts --- .../src/main/java/com/tailscale/ipn/App.kt | 63 ++++---- .../java/com/tailscale/ipn/MainActivity.kt | 84 ++++++++++- .../ipn/ui/util/OutputStreamAdapter.kt | 26 ++++ .../com/tailscale/ipn/ui/view/MainView.kt | 14 +- .../ipn/ui/viewModel/MainViewModel.kt | 31 ++++ .../com/tailscale/ipn/util/ShareFileHelper.kt | 134 ++++++++++++++++++ libtailscale/backend.go | 54 +++++-- libtailscale/callbacks.go | 6 + libtailscale/fileops.go | 38 +++++ libtailscale/interfaces.go | 39 +++++ libtailscale/tailscale.go | 9 +- 11 files changed, 441 insertions(+), 57 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt create mode 100644 android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt create mode 100644 libtailscale/fileops.go diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index 3e834cb..249a42d 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -13,8 +13,8 @@ import android.content.IntentFilter import android.content.SharedPreferences import android.content.pm.PackageManager import android.net.ConnectivityManager +import android.net.Uri import android.os.Build -import android.os.Environment import android.util.Log import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat @@ -35,6 +35,7 @@ import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory import com.tailscale.ipn.util.FeatureFlags +import com.tailscale.ipn.util.ShareFileHelper import com.tailscale.ipn.util.TSLog import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -58,6 +59,8 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { companion object { private const val FILE_CHANNEL_ID = "tailscale-files" + // Key to store the SAF URI in EncryptedSharedPreferences. + private val PREF_KEY_SAF_URI = "saf_directory_uri" private const val TAG = "App" private lateinit var appInstance: App @@ -149,17 +152,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { } private fun initializeApp() { - val dataDir = this.filesDir.absolutePath - - // Set this to enable direct mode for taildrop whereby downloads will be saved directly - // to the given folder. We will preferentially use /Downloads and fallback to - // an app local directory "Taildrop" if we cannot create that. This mode does not support - // user notifications for incoming files. - val directFileDir = this.prepareDownloadsFolder() - app = Libtailscale.start(dataDir, directFileDir.absolutePath, this) - Request.setApp(app) - Notifier.setApp(app) - Notifier.start(applicationScope) + // Check if a directory URI has already been stored. + val storedUri = getStoredDirectoryUri() + if (storedUri != null && storedUri.toString().startsWith("content://")) { + startLibtailscale(storedUri.toString()) + } else { + startLibtailscale(this.getFilesDir().absolutePath) + } healthNotifier = HealthNotifier(Notifier.health, Notifier.state, applicationScope) connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns) @@ -204,6 +203,18 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { FeatureFlags.initialize(mapOf("enable_new_search" to true)) } + /** + * Called when a SAF directory URI is available (either already stored or chosen). We must restart + * Tailscale because directFileRoot must be set before LocalBackend starts being used. + */ + fun startLibtailscale(directFileRoot: String) { + ShareFileHelper.init(this, directFileRoot) + app = Libtailscale.start(this.filesDir.absolutePath, directFileRoot, this) + Request.setApp(app) + Notifier.setApp(app) + Notifier.start(applicationScope) + } + private fun initViewModels() { vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java) } @@ -246,6 +257,11 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM) } + fun getStoredDirectoryUri(): Uri? { + val uriString = getEncryptedPrefs().getString(PREF_KEY_SAF_URI, null) + return uriString?.let { Uri.parse(it) } + } + /* * 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 @@ -309,29 +325,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { return sb.toString() } - private fun prepareDownloadsFolder(): File { - var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) - - try { - if (!downloads.exists()) { - downloads.mkdirs() - } - } catch (e: Exception) { - TSLog.e(TAG, "Failed to create downloads folder: $e") - downloads = File(this.filesDir, "Taildrop") - try { - if (!downloads.exists()) { - downloads.mkdirs() - } - } catch (e: Exception) { - TSLog.e(TAG, "Failed to create Taildrop folder: $e") - downloads = File("") - } - } - - return downloads - } - @Throws( IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) override fun getSyspolicyBooleanValue(key: String): Boolean { diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 0b3b762..98f591e 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -10,17 +10,21 @@ import android.content.Context import android.content.Intent import android.content.RestrictionsManager import android.content.pm.ActivityInfo +import android.content.pm.PackageManager import android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK import android.net.ConnectivityManager import android.net.NetworkCapabilities +import android.net.Uri import android.os.Build import android.os.Bundle +import android.os.Process import android.provider.Settings import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.animation.core.LinearOutSlowInEasing @@ -88,8 +92,13 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import libtailscale.Libtailscale +import java.io.IOException +import java.security.GeneralSecurityException class MainActivity : ComponentActivity() { + // Key to store the SAF URI in EncryptedSharedPreferences. + val PREF_KEY_SAF_URI = "saf_directory_uri" private lateinit var navController: NavHostController private lateinit var vpnPermissionLauncher: ActivityResultLauncher private val viewModel: MainViewModel by lazy { @@ -149,6 +158,49 @@ class MainActivity : ComponentActivity() { } viewModel.setVpnPermissionLauncher(vpnPermissionLauncher) + val directoryPickerLauncher = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? -> + if (uri != null) { + try { + // Try to take persistable permissions for both read and write. + contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + } catch (e: SecurityException) { + TSLog.e("MainActivity", "Failed to persist permissions: $e") + } + + // Check if write permission is actually granted. + val writePermission = + this.checkUriPermission( + uri, Process.myPid(), Process.myUid(), Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + if (writePermission == PackageManager.PERMISSION_GRANTED) { + TSLog.d("MainActivity", "Write permission granted for $uri") + + lifecycleScope.launch(Dispatchers.IO) { + try { + Libtailscale.setDirectFileRoot(uri.toString()) + saveFileDirectory(uri) + } catch (e: Exception) { + TSLog.e("MainActivity", "Failed to set Taildrop root: $e") + } + } + } else { + TSLog.d( + "MainActivity", + "Write access not granted for $uri. Falling back to internal storage.") + // Don't save directory URI and fall back to internal storage. + } + } else { + TSLog.d( + "MainActivity", "Taildrop directory not saved. Will fall back to internal storage.") + + // Fall back to internal storage. + } + } + + viewModel.setDirectoryPickerLauncher(directoryPickerLauncher) + setContent { navController = rememberNavController() @@ -366,19 +418,37 @@ class MainActivity : ComponentActivity() { if (this::navController.isInitialized) { val previousEntry = navController.previousBackStackEntry TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry") + if (this::navController.isInitialized) { + val previousEntry = navController.previousBackStackEntry + TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry") - if (previousEntry != null) { - navController.popBackStack(route = "main", inclusive = false) - } else { - TSLog.e( - "MainActivity", - "onNewIntent: No previous back stack entry, navigating directly to 'main'") - navController.navigate("main") { popUpTo("main") { inclusive = true } } + if (previousEntry != null) { + navController.popBackStack(route = "main", inclusive = false) + } else { + TSLog.e( + "MainActivity", + "onNewIntent: No previous back stack entry, navigating directly to 'main'") + navController.navigate("main") { popUpTo("main") { inclusive = true } } + } } } } } + @Throws(IOException::class, GeneralSecurityException::class) + fun saveFileDirectory(directoryUri: Uri) { + val prefs = App.get().getEncryptedPrefs() + prefs.edit().putString(PREF_KEY_SAF_URI, directoryUri.toString()).apply() + try { + // Must restart Tailscale because a new LocalBackend with the new directory must be created. + App.get().startLibtailscale(directoryUri.toString()) + } catch (e: Exception) { + TSLog.d( + "MainActivity", + "saveFileDirectory: Failed to restart Libtailscale with the new directory: $e") + } + } + 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. diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt b/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt new file mode 100644 index 0000000..9e73a42 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt @@ -0,0 +1,26 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.util + +import com.tailscale.ipn.util.TSLog +import java.io.OutputStream + +// This class adapts a Java OutputStream to the libtailscale.OutputStream interface. +class OutputStreamAdapter(private val outputStream: OutputStream) : libtailscale.OutputStream { + // writes data to the outputStream in its entirety. Returns -1 on error. + override fun write(data: ByteArray): Long { + return try { + outputStream.write(data) + outputStream.flush() + data.size.toLong() + } catch (e: Exception) { + TSLog.d("OutputStreamAdapter", "write exception: $e") + -1L + } + } + + override fun close() { + outputStream.close() + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index fdb16bb..96b0491 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -97,6 +97,7 @@ import com.tailscale.ipn.ui.theme.short import com.tailscale.ipn.ui.theme.surfaceContainerListItem import com.tailscale.ipn.ui.theme.warningButton import com.tailscale.ipn.ui.theme.warningListItem +import com.tailscale.ipn.ui.util.AndroidTVUtil import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV import com.tailscale.ipn.ui.util.AutoResizingText import com.tailscale.ipn.ui.util.Lists @@ -212,6 +213,11 @@ fun MainView( PromptPermissionsIfNecessary() viewModel.maybeRequestVpnPermission() LaunchVpnPermissionIfNeeded(viewModel) + LaunchedEffect(state) { + if (state == Ipn.State.Running && !AndroidTVUtil.isAndroidTV()) { + viewModel.showDirectoryPickerLauncher() + } + } if (showKeyExpiry) { ExpiryNotification(netmap = netmap, action = { viewModel.login() }) @@ -242,7 +248,9 @@ fun MainView( { viewModel.login() }, loginAtUrl, netmap?.SelfNode, - { viewModel.showVPNPermissionLauncherIfUnauthorized() }) + { + viewModel.showVPNPermissionLauncherIfUnauthorized() + }) } } } @@ -433,11 +441,11 @@ fun ConnectView( loginAction: () -> Unit, loginAtUrlAction: (String) -> Unit, selfNode: Tailcfg.Node?, - showVPNPermissionLauncherIfUnauthorized: () -> Unit + showVPNPermissionLauncher: () -> Unit ) { LaunchedEffect(isPrepared) { if (!isPrepared && shouldStartAutomatically) { - showVPNPermissionLauncherIfUnauthorized() + showVPNPermissionLauncher() } } Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index c0e205a..7190764 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -4,6 +4,7 @@ package com.tailscale.ipn.ui.viewModel import android.content.Intent +import android.net.Uri import android.net.VpnService import androidx.activity.result.ActivityResultLauncher import androidx.compose.runtime.getValue @@ -11,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.text.AnnotatedString +import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -25,6 +27,7 @@ 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 com.tailscale.ipn.util.TSLog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -63,6 +66,9 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { private val _requestVpnPermission = MutableStateFlow(false) val requestVpnPermission: StateFlow = _requestVpnPermission + // Select Taildrop directory + private var directoryPickerLauncher: ActivityResultLauncher? = null + // The list of peers private val _peers = MutableStateFlow>(emptyList()) val peers: StateFlow> = _peers @@ -204,6 +210,26 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { _requestVpnPermission.value = false // reset } + fun showDirectoryPickerLauncher() { + val app = App.get() + val storedUri = app.getStoredDirectoryUri() + if (storedUri == null) { + // No stored URI, so launch the directory picker. + directoryPickerLauncher?.launch(null) + return + } + + val documentFile = DocumentFile.fromTreeUri(app, storedUri) + if (documentFile == null || !documentFile.exists() || !documentFile.canWrite()) { + TSLog.d( + "MainViewModel", + "Stored directory URI is invalid or inaccessible; launching directory picker.") + directoryPickerLauncher?.launch(null) + } else { + TSLog.d("MainViewModel", "Using stored directory URI: $storedUri") + } + } + fun toggleVpn(desiredState: Boolean) { if (isToggleInProgress.value) { // Prevent toggling while a previous toggle is in progress @@ -211,6 +237,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } viewModelScope.launch { + showDirectoryPickerLauncher() isToggleInProgress.value = true try { val currentState = Notifier.state.value @@ -250,6 +277,10 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { // No intent means we're already authorized vpnPermissionLauncher = launcher } + + fun setDirectoryPickerLauncher(launcher: ActivityResultLauncher) { + directoryPickerLauncher = launcher + } } private fun userStringRes(currentState: State?, previousState: State?, vpnActive: Boolean): Int { diff --git a/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt new file mode 100644 index 0000000..fed568d --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt @@ -0,0 +1,134 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.util + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import com.tailscale.ipn.ui.util.OutputStreamAdapter +import libtailscale.Libtailscale +import java.io.IOException +import java.io.OutputStream +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +data class SafFile(val fd: Int, val uri: String) + +object ShareFileHelper : libtailscale.ShareFileHelper { + private var appContext: Context? = null + private var savedUri: String? = null + + @JvmStatic + fun init(context: Context, uri: String) { + appContext = context.applicationContext + savedUri = uri + Libtailscale.setShareFileHelper(this) + } + + // A simple data class that holds a SAF OutputStream along with its URI. + data class SafStream(val uri: String, val stream: OutputStream) + + // Cache for streams; keyed by file name and savedUri. + private val streamCache = ConcurrentHashMap() + + // A helper function that creates (or reuses) a SafStream for a given file. + private fun createStreamCached(fileName: String): SafStream { + val key = "$fileName|$savedUri" + return streamCache.getOrPut(key) { + val context: Context = + appContext + ?: run { + TSLog.e("ShareFileHelper", "appContext is null, cannot create file: $fileName") + return SafStream("", OutputStream.nullOutputStream()) + } + val directoryUriString = + savedUri + ?: run { + TSLog.e("ShareFileHelper", "savedUri is null, cannot create file: $fileName") + return SafStream("", OutputStream.nullOutputStream()) + } + val dirUri = Uri.parse(directoryUriString) + val pickedDir: DocumentFile = + DocumentFile.fromTreeUri(context, dirUri) + ?: run { + TSLog.e("ShareFileHelper", "Could not access directory for URI: $dirUri") + return SafStream("", OutputStream.nullOutputStream()) + } + val newFile: DocumentFile = + pickedDir.createFile("application/octet-stream", fileName) + ?: run { + TSLog.e("ShareFileHelper", "Failed to create file: $fileName in directory: $dirUri") + return SafStream("", OutputStream.nullOutputStream()) + } + // Attempt to open an OutputStream for writing. + val os: OutputStream? = context.contentResolver.openOutputStream(newFile.uri) + if (os == null) { + TSLog.e("ShareFileHelper", "openOutputStream returned null for URI: ${newFile.uri}") + SafStream(newFile.uri.toString(), OutputStream.nullOutputStream()) + } else { + TSLog.d("ShareFileHelper", "Opened OutputStream for file: $fileName") + SafStream(newFile.uri.toString(), os) + } + } + } + + // This method returns a SafStream containing the SAF URI and its corresponding OutputStream. + override fun openFileWriter(fileName: String): libtailscale.OutputStream { + val stream = createStreamCached(fileName) + return OutputStreamAdapter(stream.stream) + } + + override fun openFileURI(fileName: String): String { + val safFile = createStreamCached(fileName) + return safFile.uri + } + + override fun renamePartialFile( + partialUri: String, + targetDirUri: String, + targetName: String + ): String { + try { + val context = appContext ?: throw IllegalStateException("appContext is null") + val partialUriObj = Uri.parse(partialUri) + val targetDirUriObj = Uri.parse(targetDirUri) + val targetDir = + DocumentFile.fromTreeUri(context, targetDirUriObj) + ?: throw IllegalStateException( + "Unable to get target directory from URI: $targetDirUri") + var finalTargetName = targetName + + var destFile = targetDir.findFile(finalTargetName) + if (destFile != null) { + finalTargetName = generateNewFilename(finalTargetName) + } + + destFile = + targetDir.createFile("application/octet-stream", finalTargetName) + ?: throw IOException("Failed to create new file with name: $finalTargetName") + + context.contentResolver.openInputStream(partialUriObj)?.use { input -> + context.contentResolver.openOutputStream(destFile.uri)?.use { output -> + input.copyTo(output) + } ?: throw IOException("Unable to open output stream for URI: ${destFile.uri}") + } ?: throw IOException("Unable to open input stream for URI: $partialUri") + + DocumentFile.fromSingleUri(context, partialUriObj)?.delete() + return destFile.uri.toString() + } catch (e: Exception) { + throw IOException( + "Failed to rename partial file from URI $partialUri to final file in $targetDirUri with name $targetName: ${e.message}", + e) + } + } + + fun generateNewFilename(filename: String): String { + val dotIndex = filename.lastIndexOf('.') + val baseName = if (dotIndex != -1) filename.substring(0, dotIndex) else filename + val extension = if (dotIndex != -1) filename.substring(dotIndex) else "" + + val uuid = UUID.randomUUID() + return "$baseName-$uuid$extension" + } +} diff --git a/libtailscale/backend.go b/libtailscale/backend.go index b95d343..e8dbc4c 100644 --- a/libtailscale/backend.go +++ b/libtailscale/backend.go @@ -43,8 +43,9 @@ import ( type App struct { dataDir string - // enables direct file mode for the taildrop manager - directFileRoot string + // passes along SAF file information for the taildrop manager + directFileRoot string + shareFileHelper ShareFileHelper // appCtx is a global reference to the com.tailscale.ipn.App instance. appCtx AppContext @@ -56,6 +57,9 @@ type App struct { localAPIHandler http.Handler backend *ipnlocal.LocalBackend ready sync.WaitGroup + backendMu sync.Mutex + + backendRestartCh chan struct{} } func start(dataDir, directFileRoot string, appCtx AppContext) Application { @@ -110,6 +114,23 @@ type backend struct { type settingsFunc func(*router.Config, *dns.OSConfig) error func (a *App) runBackend(ctx context.Context) error { + for { + err := a.runBackendOnce(ctx) + if err != nil { + log.Printf("runBackendOnce error: %v", err) + } + + // Wait for a restart trigger + <-a.backendRestartCh + } +} + +func (a *App) runBackendOnce(ctx context.Context) error { + select { + case <-a.backendRestartCh: + default: + } + paths.AppSharedDir.Store(a.dataDir) hostinfo.SetOSVersion(a.osVersion()) hostinfo.SetPackage(a.appCtx.GetInstallSource()) @@ -125,7 +146,7 @@ func (a *App) runBackend(ctx context.Context) error { } configs := make(chan configPair) configErrs := make(chan error) - b, err := a.newBackend(a.dataDir, a.directFileRoot, a.appCtx, a.store, func(rcfg *router.Config, dcfg *dns.OSConfig) error { + b, err := a.newBackend(a.dataDir, a.appCtx, a.store, func(rcfg *router.Config, dcfg *dns.OSConfig) error { if rcfg == nil { return nil } @@ -242,7 +263,7 @@ func (a *App) runBackend(ctx context.Context) error { } } -func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, store *stateStore, +func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore, settings settingsFunc) (*backend, error) { sys := new(tsd.System) @@ -314,15 +335,15 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor w.Start() } lb, err := ipnlocal.NewLocalBackend(logf, logID.Public(), sys, 0) + if ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb); ok { + ext.SetFileOps(NewAndroidFileOps(a.shareFileHelper)) + ext.SetDirectFileRoot(a.directFileRoot) + } + if err != nil { engine.Close() return nil, fmt.Errorf("runBackend: NewLocalBackend: %v", err) } - - if ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb); ok { - ext.SetDirectFileRoot(directFileRoot) - } - if err := ns.Start(lb); err != nil { return nil, fmt.Errorf("startNetstack: %w", err) } @@ -343,6 +364,21 @@ func (a *App) newBackend(dataDir, directFileRoot string, appCtx AppContext, stor return b, nil } +func (a *App) watchFileOpsChanges() { + for { + select { + case newPath := <-onFilePath: + log.Printf("Got new directFileRoot") + a.directFileRoot = newPath + a.backendRestartCh <- struct{}{} + case helper := <-onShareFileHelper: + log.Printf("Got shareFIleHelper") + a.shareFileHelper = helper + a.backendRestartCh <- struct{}{} + } + } +} + func (b *backend) isConfigNonNilAndDifferent(rcfg *router.Config, dcfg *dns.OSConfig) bool { if reflect.DeepEqual(rcfg, b.lastCfg) && reflect.DeepEqual(dcfg, b.lastDNSCfg) { b.logger.Logf("isConfigNonNilAndDifferent: no change to Routes or DNS, ignore") diff --git a/libtailscale/callbacks.go b/libtailscale/callbacks.go index 2ee022a..3e1a88f 100644 --- a/libtailscale/callbacks.go +++ b/libtailscale/callbacks.go @@ -23,6 +23,12 @@ var ( // onLog receives Android logs to be sent to the logger onLog = make(chan string, 10) + + // onShareFileHelper receives ShareFileHelper references when the app is initialized so that files can be received via Storage Access Framework + onShareFileHelper = make(chan ShareFileHelper, 1) + + // onFilePath receives the SAF path used for Taildrop + onFilePath = make(chan string) ) // ifname is the interface name retrieved from LinkProperties on network change. An empty string is used if there is no network available. diff --git a/libtailscale/fileops.go b/libtailscale/fileops.go new file mode 100644 index 0000000..241097c --- /dev/null +++ b/libtailscale/fileops.go @@ -0,0 +1,38 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause +package libtailscale + +import ( + "fmt" + "io" +) + +// AndroidFileOps implements the ShareFileHelper interface using the Android helper. +type AndroidFileOps struct { + helper ShareFileHelper +} + +func NewAndroidFileOps(helper ShareFileHelper) *AndroidFileOps { + return &AndroidFileOps{helper: helper} +} + +func (ops *AndroidFileOps) OpenFileURI(filename string) string { + return ops.helper.OpenFileURI(filename) +} + +func (ops *AndroidFileOps) OpenFileWriter(filename string) (io.WriteCloser, string, error) { + uri := ops.helper.OpenFileURI(filename) + outputStream := ops.helper.OpenFileWriter(filename) + if outputStream == nil { + return nil, uri, fmt.Errorf("failed to open SAF output stream for %s", filename) + } + return outputStream, uri, nil +} + +func (ops *AndroidFileOps) RenamePartialFile(partialUri, targetDirUri, targetName string) (string, error) { + newURI := ops.helper.RenamePartialFile(partialUri, targetDirUri, targetName) + if newURI == "" { + return "", fmt.Errorf("failed to rename partial file via SAF") + } + return newURI, nil +} diff --git a/libtailscale/interfaces.go b/libtailscale/interfaces.go index 6460c9f..5663698 100644 --- a/libtailscale/interfaces.go +++ b/libtailscale/interfaces.go @@ -162,6 +162,25 @@ type InputStream interface { Close() error } +// OutputStream provides an adapter between Java's OutputStream and Go's +// io.WriteCloser. +type OutputStream interface { + Write([]byte) (int, error) + Close() error +} + +// ShareFileHelper corresponds to the Kotlin ShareFileHelper class +type ShareFileHelper interface { + OpenFileWriter(fileName string) OutputStream + + // OpenFileURI opens the file and returns its SAF URI. + OpenFileURI(filename string) string + + // RenamePartialFile takes SAF URIs and a target file name, + // and returns the new SAF URI and an error. + RenamePartialFile(partialUri string, targetDirUri string, targetName string) string +} + // The below are global callbacks that allow the Java application to notify Go // of various state changes. @@ -182,3 +201,23 @@ func SendLog(logstr []byte) { log.Printf("Log %v not sent", logstr) // missing argument in original code } } + +func SetShareFileHelper(fileHelper ShareFileHelper) { + // Drain the channel if there's an old value. + select { + case <-onShareFileHelper: + default: + // Channel was already empty. + } + select { + case onShareFileHelper <- fileHelper: + default: + // In the unlikely case the channel is still full, drain it and try again. + <-onShareFileHelper + onShareFileHelper <- fileHelper + } +} + +func SetDirectFileRoot(filePath string) { + onFilePath <- filePath +} diff --git a/libtailscale/tailscale.go b/libtailscale/tailscale.go index 6ae9131..3a785fa 100644 --- a/libtailscale/tailscale.go +++ b/libtailscale/tailscale.go @@ -32,9 +32,10 @@ const ( func newApp(dataDir, directFileRoot string, appCtx AppContext) Application { a := &App{ - directFileRoot: directFileRoot, - dataDir: dataDir, - appCtx: appCtx, + directFileRoot: directFileRoot, + dataDir: dataDir, + appCtx: appCtx, + backendRestartCh: make(chan struct{}, 1), } a.ready.Add(2) @@ -42,6 +43,8 @@ func newApp(dataDir, directFileRoot string, appCtx AppContext) Application { a.policyStore = &syspolicyHandler{a: a} netmon.RegisterInterfaceGetter(a.getInterfaces) syspolicy.RegisterHandler(a.policyStore) + go a.watchFileOpsChanges() + go func() { defer func() { if p := recover(); p != nil { From 38f2662ecb59fd581d4672145da1d15a0ac3cfa4 Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Mon, 26 May 2025 12:13:49 -0400 Subject: [PATCH 12/49] android: bump OSS (#655) OSS and Version updated to 1.85.8-t09582bdc0-gbd5191363 Signed-off-by: Jonathan Nobels --- go.mod | 4 +--- go.sum | 10 ++-------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index dc2d8eb..b6ddb86 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.24.0 require ( github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab - tailscale.com v1.83.0-pre.0.20250520223019-5a8b99e977e3 + tailscale.com v1.85.0-pre.0.20250524221629-09582bdc009f ) require ( @@ -42,8 +42,6 @@ require ( github.com/google/go-tpm v0.9.4 // indirect github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/csrf v1.7.3 // indirect - github.com/gorilla/securecookie v1.1.2 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect github.com/illarion/gonotify/v3 v3.0.2 // indirect github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect diff --git a/go.sum b/go.sum index 81d769e..cd21a9d 100644 --- a/go.sum +++ b/go.sum @@ -87,16 +87,10 @@ github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I= github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0= -github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= -github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= -github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk= @@ -243,5 +237,5 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.83.0-pre.0.20250520223019-5a8b99e977e3 h1:ygaYcwsF63nxRHuu3sry7xNY7dmmnq1r/ORSry/lHAk= -tailscale.com v1.83.0-pre.0.20250520223019-5a8b99e977e3/go.mod h1:6/S63NMAhmncYT/1zIPDJkvCuZwMw+JnUuOfSPNazpo= +tailscale.com v1.85.0-pre.0.20250524221629-09582bdc009f h1:ZXXlCnDLlDA/Hkt4TZ3QpCsOKiuZzhrd0htEOnn89rM= +tailscale.com v1.85.0-pre.0.20250524221629-09582bdc009f/go.mod h1:6/S63NMAhmncYT/1zIPDJkvCuZwMw+JnUuOfSPNazpo= From 88a5d3c140b829d3a832fd002887477317f9f6a1 Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Wed, 28 May 2025 10:11:59 -0400 Subject: [PATCH 13/49] android: modify mullvad exit node detection logic (#656) updates tailscale/corp#29045 We ran into an issue where the current detection logic was not sufficient to filter out mullvad nodes. This modifies the logic so we scan both the Name and ComputedName for the mullvad domain and also treat all nodes with location info as mullvad nodes. While all of these conditions *should* be true for any mullvad node, in practice it's possible that they aren't so we or them together for some redundancy and define a mullvad exit node to be any node where any of these conditions is true. Signed-off-by: Jonathan Nobels --- .../src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt index a51579e..2e9be75 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt @@ -15,9 +15,9 @@ import com.tailscale.ipn.ui.util.DisplayAddress import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.ui.util.flag import com.tailscale.ipn.ui.viewModel.PeerSettingInfo -import java.util.Date import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement +import java.util.Date class Tailcfg { @Serializable @@ -107,8 +107,13 @@ class Tailcfg { val isExitNode: Boolean = (AllowedIPs?.contains("0.0.0.0/0") ?: false) && (AllowedIPs?.contains("::/0") ?: false) + // mullvad nodes are exit nodes with a mullvad.ts.net domain *or* Location Info. + // These checks are intentionally redundant to avoid false negatives. val isMullvadNode: Boolean - get() = Name.endsWith(".mullvad.ts.net.") + get() = + Name.endsWith(".mullvad.ts.net") || + ComputedName?.endsWith(".mullvad.ts.net") == true || + Hostinfo.Location != null val displayName: String get() = ComputedName ?: Name From a14d4c7184c312c2ae53e0be0cab3ffee312fe77 Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Wed, 28 May 2025 15:45:44 -0400 Subject: [PATCH 14/49] android: add explanatory dialog for taildrop directory selection (#657) fixes tailscale/corp#29067 Adds an interstitial explaining that the user needs to select/create a taildrop target directory on startup. Signed-off-by: Jonathan Nobels --- .../com/tailscale/ipn/ui/view/MainView.kt | 58 +++++++++++++++---- .../ipn/ui/viewModel/MainViewModel.kt | 13 ++++- android/src/main/res/values/strings.xml | 6 ++ 3 files changed, 63 insertions(+), 14 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index 96b0491..71f87f5 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -32,6 +32,7 @@ import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -61,12 +62,14 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -78,11 +81,13 @@ import com.tailscale.ipn.App import com.tailscale.ipn.R import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.ShowHide +import com.tailscale.ipn.ui.Links 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.AppTheme import com.tailscale.ipn.ui.theme.customErrorContainer import com.tailscale.ipn.ui.theme.disabled import com.tailscale.ipn.ui.theme.errorButton @@ -97,7 +102,6 @@ import com.tailscale.ipn.ui.theme.short import com.tailscale.ipn.ui.theme.surfaceContainerListItem import com.tailscale.ipn.ui.theme.warningButton import com.tailscale.ipn.ui.theme.warningListItem -import com.tailscale.ipn.ui.util.AndroidTVUtil import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV import com.tailscale.ipn.ui.util.AutoResizingText import com.tailscale.ipn.ui.util.Lists @@ -124,7 +128,7 @@ data class MainViewNavigation( fun MainView( loginAtUrl: (String) -> Unit, navigation: MainViewNavigation, - viewModel: MainViewModel + viewModel: MainViewModel, ) { val currentPingDevice by viewModel.pingViewModel.peer.collectAsState() val healthIcon by viewModel.healthIcon.collectAsState() @@ -147,6 +151,8 @@ fun MainView( val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState() val disableToggle by MDMSettings.forceEnabled.flow.collectAsState() val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false) + val showDirectoryPickerInterstitial by + viewModel.showDirectoryPickerInterstitial.collectAsState() // Hide the header only on Android TV when the user needs to login val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin) @@ -214,8 +220,8 @@ fun MainView( viewModel.maybeRequestVpnPermission() LaunchVpnPermissionIfNeeded(viewModel) LaunchedEffect(state) { - if (state == Ipn.State.Running && !AndroidTVUtil.isAndroidTV()) { - viewModel.showDirectoryPickerLauncher() + if (state == Ipn.State.Running && !isAndroidTV()) { + viewModel.checkIfTaildropDirectorySelected() } } @@ -248,13 +254,29 @@ fun MainView( { viewModel.login() }, loginAtUrl, netmap?.SelfNode, - { - viewModel.showVPNPermissionLauncherIfUnauthorized() - }) + { viewModel.showVPNPermissionLauncherIfUnauthorized() }) } } - } + showDirectoryPickerInterstitial.let { show -> + if (show) { + AppTheme { + AlertDialog( + onDismissRequest = { viewModel.showDirectoryPickerLauncher() }, + title = { + Text(text = stringResource(id = R.string.taildrop_directory_picker_title)) + }, + text = { TaildropDirectoryPickerPrompt() }, + confirmButton = { + PrimaryActionButton(onClick = { viewModel.showDirectoryPickerLauncher() }) { + Text( + text = stringResource(id = R.string.taildrop_directory_picker_button)) + } + }) + } + } + } + } currentPingDevice?.let { _ -> ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) { PingView(model = viewModel.pingViewModel) @@ -264,6 +286,20 @@ fun MainView( } } +@Composable +fun TaildropDirectoryPickerPrompt() { + val uriHandler = LocalUriHandler.current + + Column(verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.Start) { + Text(text = stringResource(id = R.string.taildrop_directory_picker_body)) + Text( + text = stringResource(id = R.string.taildrop_directory_picker_info), + modifier = Modifier.clickable { uriHandler.openUri(Links.TAILDROP_KB_URL) }, + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline) + } +} + @Composable fun LaunchVpnPermissionIfNeeded(viewModel: MainViewModel) { val lifecycleOwner = LocalLifecycleOwner.current @@ -441,7 +477,7 @@ fun ConnectView( loginAction: () -> Unit, loginAtUrlAction: (String) -> Unit, selfNode: Tailcfg.Node?, - showVPNPermissionLauncher: () -> Unit + showVPNPermissionLauncher: () -> Unit, ) { LaunchedEffect(isPrepared) { if (!isPrepared && shouldStartAutomatically) { @@ -553,7 +589,7 @@ fun PeerList( viewModel: MainViewModel, onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, onSearchBarClick: () -> Unit, - onSearch: (String) -> Unit + onSearch: (String) -> Unit, ) { val peerList by viewModel.peers.collectAsState(initial = emptyList()) val searchTermStr by viewModel.searchTerm.collectAsState(initial = "") @@ -774,7 +810,7 @@ fun PromptPermissionsIfNecessary() { @Composable fun Search( onSearchBarClick: () -> Unit, // Callback for navigating to SearchView - backgroundColor: Color = MaterialTheme.colorScheme.background // Default background color + backgroundColor: Color = MaterialTheme.colorScheme.background, // Default background color ) { // Prevent multiple taps var isNavigating by remember { mutableStateOf(false) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index 7190764..332c77e 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -68,6 +68,8 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { // Select Taildrop directory private var directoryPickerLauncher: ActivityResultLauncher? = null + private val _showDirectoryPickerInterstitial = MutableStateFlow(false) + val showDirectoryPickerInterstitial: StateFlow = _showDirectoryPickerInterstitial // The list of peers private val _peers = MutableStateFlow>(emptyList()) @@ -211,11 +213,16 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } fun showDirectoryPickerLauncher() { + _showDirectoryPickerInterstitial.set(false) + directoryPickerLauncher?.launch(null) + } + + fun checkIfTaildropDirectorySelected() { val app = App.get() val storedUri = app.getStoredDirectoryUri() if (storedUri == null) { // No stored URI, so launch the directory picker. - directoryPickerLauncher?.launch(null) + _showDirectoryPickerInterstitial.set(true) return } @@ -224,7 +231,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { TSLog.d( "MainViewModel", "Stored directory URI is invalid or inaccessible; launching directory picker.") - directoryPickerLauncher?.launch(null) + _showDirectoryPickerInterstitial.set(true) } else { TSLog.d("MainViewModel", "Using stored directory URI: $storedUri") } @@ -237,7 +244,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } viewModelScope.launch { - showDirectoryPickerLauncher() + checkIfTaildropDirectorySelected() isToggleInProgress.value = true try { val currentState = Notifier.state.value diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 5626e58..d376490 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -324,4 +324,10 @@ Hostname Failed to save + + Taildrop Directory + You have not selected a directory for incoming taildrop transfers. Please select or create a target directory. + What is taildrop? + Open Directory Picker + From 87f0e9754b512f7b337f68596390ee20bcc47617 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Fri, 30 May 2025 15:52:52 -0700 Subject: [PATCH 15/49] android: allow users to update taildrop directory (#658) -Modify Permissions view to navigate to Taildrop dir view and Notifications view, and to reflect state -Add Taildrop dir view which navigates to directory selector -Add Notifications view which navigates to Taildrop notifications setting Updates tailscale/tailscale#15263 Signed-off-by: kari-ts --- .../java/com/tailscale/ipn/MainActivity.kt | 35 +++---- .../tailscale/ipn/TaildropDirectoryStore.kt | 43 +++++++++ .../ipn/ui/util/PermissionsDisplayUtil.kt | 21 +++++ .../ipn/ui/view/NotificationsView.kt | 93 +++++++++++++++++++ .../tailscale/ipn/ui/view/PermissionsView.kt | 52 ++++++++--- .../tailscale/ipn/ui/view/TaildropDirView.kt | 79 ++++++++++++++++ .../ipn/ui/viewModel/PermissionsViewModel.kt | 39 ++++++++ .../baseline_drive_folder_upload_24.xml | 20 ++++ .../res/drawable/baseline_folder_open_24.xml | 5 + .../baseline_notifications_none_24.xml | 5 + android/src/main/res/values/strings.xml | 10 +- 11 files changed, 368 insertions(+), 34 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/util/PermissionsDisplayUtil.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/NotificationsView.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/TaildropDirView.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/viewModel/PermissionsViewModel.kt create mode 100644 android/src/main/res/drawable/baseline_drive_folder_upload_24.xml create mode 100644 android/src/main/res/drawable/baseline_folder_open_24.xml create mode 100644 android/src/main/res/drawable/baseline_notifications_none_24.xml diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 98f591e..c7c8d60 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -70,6 +70,7 @@ import com.tailscale.ipn.ui.view.ManagedByView import com.tailscale.ipn.ui.view.MullvadExitNodePicker import com.tailscale.ipn.ui.view.MullvadExitNodePickerList import com.tailscale.ipn.ui.view.MullvadInfoView +import com.tailscale.ipn.ui.view.NotificationsView import com.tailscale.ipn.ui.view.PeerDetails import com.tailscale.ipn.ui.view.PermissionsView import com.tailscale.ipn.ui.view.RunExitNodeView @@ -77,6 +78,7 @@ import com.tailscale.ipn.ui.view.SearchView import com.tailscale.ipn.ui.view.SettingsView import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView import com.tailscale.ipn.ui.view.SubnetRoutingView +import com.tailscale.ipn.ui.view.TaildropDirView import com.tailscale.ipn.ui.view.TailnetLockSetupView import com.tailscale.ipn.ui.view.UserSwitcherNav import com.tailscale.ipn.ui.view.UserSwitcherView @@ -93,12 +95,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import libtailscale.Libtailscale -import java.io.IOException -import java.security.GeneralSecurityException class MainActivity : ComponentActivity() { - // Key to store the SAF URI in EncryptedSharedPreferences. - val PREF_KEY_SAF_URI = "saf_directory_uri" private lateinit var navController: NavHostController private lateinit var vpnPermissionLauncher: ActivityResultLauncher private val viewModel: MainViewModel by lazy { @@ -180,7 +178,7 @@ class MainActivity : ComponentActivity() { lifecycleScope.launch(Dispatchers.IO) { try { Libtailscale.setDirectFileRoot(uri.toString()) - saveFileDirectory(uri) + TaildropDirectoryStore.saveFileDirectory(uri) } catch (e: Exception) { TSLog.e("MainActivity", "Failed to set Taildrop root: $e") } @@ -190,7 +188,7 @@ class MainActivity : ComponentActivity() { "MainActivity", "Write access not granted for $uri. Falling back to internal storage.") // Don't save directory URI and fall back to internal storage. - } + } } else { TSLog.d( "MainActivity", "Taildrop directory not saved. Will fall back to internal storage.") @@ -329,7 +327,16 @@ class MainActivity : ComponentActivity() { composable("managedBy") { ManagedByView(backTo("settings")) } composable("userSwitcher") { UserSwitcherView(userSwitcherNav) } composable("permissions") { - PermissionsView(backTo("settings"), ::openApplicationSettings) + PermissionsView( + backTo("settings"), + { navController.navigate("taildropDir") }, + { navController.navigate("notifications") }) + } + composable("taildropDir") { + TaildropDirView(backTo("permissions"), directoryPickerLauncher) + } + composable("notifications") { + NotificationsView(backTo("permissions"), ::openApplicationSettings) } composable("intro", exitTransition = { fadeOut(animationSpec = tween(150)) }) { IntroView(backTo("main")) @@ -435,20 +442,6 @@ class MainActivity : ComponentActivity() { } } - @Throws(IOException::class, GeneralSecurityException::class) - fun saveFileDirectory(directoryUri: Uri) { - val prefs = App.get().getEncryptedPrefs() - prefs.edit().putString(PREF_KEY_SAF_URI, directoryUri.toString()).apply() - try { - // Must restart Tailscale because a new LocalBackend with the new directory must be created. - App.get().startLibtailscale(directoryUri.toString()) - } catch (e: Exception) { - TSLog.d( - "MainActivity", - "saveFileDirectory: Failed to restart Libtailscale with the new directory: $e") - } - } - 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. diff --git a/android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt b/android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt new file mode 100644 index 0000000..02bbaee --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt @@ -0,0 +1,43 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn + +import android.net.Uri +import com.tailscale.ipn.util.TSLog +import java.io.IOException +import java.security.GeneralSecurityException + +object TaildropDirectoryStore { + // Key to store the SAF URI in EncryptedSharedPreferences. + val PREF_KEY_SAF_URI = "saf_directory_uri" + + @Throws(IOException::class, GeneralSecurityException::class) + fun saveFileDirectory(directoryUri: Uri) { + val prefs = App.get().getEncryptedPrefs() + prefs.edit().putString(PREF_KEY_SAF_URI, directoryUri.toString()).apply() + try { + // Must restart Tailscale because a new LocalBackend with the new directory must be created. + App.get().startLibtailscale(directoryUri.toString()) + } catch (e: Exception) { + TSLog.d( + "TaildropDirectoryStore", + "saveFileDirectory: Failed to restart Libtailscale with the new directory: $e") + } + } + + @Throws(IOException::class, GeneralSecurityException::class) + fun loadSavedDir(): Uri? { + val prefs = App.get().getEncryptedPrefs() + val uriString = prefs.getString(PREF_KEY_SAF_URI, null) ?: return null + + return try { + Uri.parse(uriString) + } catch (e: Exception) { + // Malformed URI in prefs ‑‑ log and wipe the bad value + TSLog.w("MainActivity", "loadSavedDir: invalid URI in prefs: $uriString; clearing") + prefs.edit().remove(PREF_KEY_SAF_URI).apply() + null + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/PermissionsDisplayUtil.kt b/android/src/main/java/com/tailscale/ipn/ui/util/PermissionsDisplayUtil.kt new file mode 100644 index 0000000..7ba877f --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/PermissionsDisplayUtil.kt @@ -0,0 +1,21 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.util + +import android.net.Uri + +/** Converts a SAF URI string to a more human-friendly folder display name. */ +fun friendlyDirName(uriStr: String): String { + val uri = Uri.parse(uriStr) + val segment = uri.lastPathSegment ?: return uriStr + + return when { + segment.startsWith("primary:") -> "Internal storage › " + segment.removePrefix("primary:") + segment.contains(":") -> { + val folder = segment.substringAfter(":") + "SD card › $folder" + } + else -> segment + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/NotificationsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/NotificationsView.kt new file mode 100644 index 0000000..ee78959 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/NotificationsView.kt @@ -0,0 +1,93 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.view + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.tailscale.ipn.R +import com.tailscale.ipn.ui.model.Permissions +import com.tailscale.ipn.ui.theme.exitNodeToggleButton + +@Composable +fun NotificationsView(backToPermissionsView: BackNavigation, openApplicationSettings: () -> Unit) { + val permissions = Permissions.withGrantedStatus + + // Find the notification permission + val notificationPermission = + permissions.find { (permission, _) -> + permission.title == R.string.permission_post_notifications + } + val granted = notificationPermission?.second ?: false + val permission = notificationPermission?.first + + Scaffold( + topBar = { + Header(titleRes = R.string.permission_post_notifications, onBack = backToPermissionsView) + }) { innerPadding -> + LazyColumn(modifier = Modifier.padding(innerPadding)) { + item { + if (permission != null) { + ListItem( + headlineContent = { + Text( + stringResource(permission.title), + style = MaterialTheme.typography.titleMedium) + }, + supportingContent = { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(permission.description), + style = MaterialTheme.typography.bodyMedium) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.notification_settings_explanation), + style = MaterialTheme.typography.bodyMedium) + } + }) + } + } + + item("spacer") { + Spacer(modifier = Modifier.height(16.dp)) // soft break instead of divider + } + + item { + ListItem( + headlineContent = { + Text( + text = stringResource(R.string.permission_post_notifications), + style = MaterialTheme.typography.titleMedium) + }, + supportingContent = { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = + if (granted) stringResource(R.string.on) + else stringResource(R.string.off), + style = MaterialTheme.typography.bodyMedium) + Button( + colors = MaterialTheme.colorScheme.exitNodeToggleButton, + onClick = openApplicationSettings, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) { + Text(stringResource(R.string.open_notification_settings)) + } + } + }) + } + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt index b033f24..6c19939 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt @@ -17,29 +17,33 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.google.accompanist.permissions.ExperimentalPermissionsApi +import androidx.lifecycle.viewmodel.compose.viewModel import com.tailscale.ipn.R import com.tailscale.ipn.ui.model.Permissions -import com.tailscale.ipn.ui.theme.success +import com.tailscale.ipn.ui.util.friendlyDirName import com.tailscale.ipn.ui.util.itemsWithDividers +import com.tailscale.ipn.ui.viewModel.PermissionsViewModel -@OptIn(ExperimentalPermissionsApi::class) @Composable -fun PermissionsView(backToSettings: BackNavigation, openApplicationSettings: () -> Unit) { +fun PermissionsView( + backToSettings: BackNavigation, + navToTaildropDirView: () -> Unit, + navToNotificationsView: () -> Unit, + permissionsViewModel: PermissionsViewModel = viewModel() +) { val permissions = Permissions.withGrantedStatus + Scaffold(topBar = { Header(titleRes = R.string.permissions, onBack = backToSettings) }) { innerPadding -> LazyColumn(modifier = Modifier.padding(innerPadding)) { + // Existing Android runtime permissions itemsWithDividers(permissions) { (permission, granted) -> ListItem( - modifier = Modifier.clickable { openApplicationSettings() }, + modifier = Modifier.clickable { navToNotificationsView() }, leadingContent = { Icon( - if (granted) painterResource(R.drawable.check_circle) - else painterResource(R.drawable.xmark_circle), - tint = - if (granted) MaterialTheme.colorScheme.success - else MaterialTheme.colorScheme.onSurfaceVariant, + painterResource(R.drawable.baseline_notifications_none_24), + tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(24.dp), contentDescription = stringResource(if (granted) R.string.ok else R.string.warning)) @@ -47,8 +51,32 @@ fun PermissionsView(backToSettings: BackNavigation, openApplicationSettings: () headlineContent = { Text(stringResource(permission.title), style = MaterialTheme.typography.titleMedium) }, - supportingContent = { Text(stringResource(permission.description)) }, - ) + supportingContent = { + if (granted) Text(stringResource(R.string.on)) else Text(stringResource(R.string.off)) + }) + } + + item { + ListItem( + modifier = Modifier.clickable { navToTaildropDirView() }, + leadingContent = { + Icon( + painterResource(R.drawable.baseline_drive_folder_upload_24), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + contentDescription = stringResource(R.string.taildrop_dir)) + }, + headlineContent = { + Text( + stringResource(R.string.taildrop_dir_access), + style = MaterialTheme.typography.titleMedium) + }, + supportingContent = { + val displayPath = + permissionsViewModel.currentDir.value?.let { friendlyDirName(it) } ?: "No access" + + Text(displayPath) + }) } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/TaildropDirView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/TaildropDirView.kt new file mode 100644 index 0000000..4996b66 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/TaildropDirView.kt @@ -0,0 +1,79 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.view + +import android.net.Uri +import androidx.activity.result.ActivityResultLauncher +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.tailscale.ipn.R +import com.tailscale.ipn.ui.theme.exitNodeToggleButton +import com.tailscale.ipn.ui.util.Lists +import com.tailscale.ipn.ui.util.friendlyDirName +import com.tailscale.ipn.ui.viewModel.PermissionsViewModel + +@Composable +fun TaildropDirView( + backToPermissionsView: BackNavigation, + openDirectoryLauncher: ActivityResultLauncher, + permissionsViewModel: PermissionsViewModel = viewModel() +) { + Scaffold( + topBar = { + Header(titleRes = R.string.taildrop_dir_access, onBack = backToPermissionsView) + }) { innerPadding -> + LazyColumn(modifier = Modifier.padding(innerPadding)) { + item { + ListItem( + headlineContent = { + Text( + stringResource(R.string.taildrop_dir_access), + style = MaterialTheme.typography.titleMedium) + }, + supportingContent = { + Text( + text = stringResource(R.string.permission_taildrop_dir), + style = MaterialTheme.typography.bodyMedium) + }) + } + + item("divider0") { Lists.SectionDivider() } + + item { + val currentDir = permissionsViewModel.currentDir.value + val displayPath = currentDir?.let { friendlyDirName(it) } ?: "No access" + + ListItem( + headlineContent = { + Text( + text = stringResource(R.string.dir_access), + style = MaterialTheme.typography.titleMedium) + }, + supportingContent = { + Column(modifier = Modifier.fillMaxWidth()) { + Text(text = displayPath, style = MaterialTheme.typography.bodyMedium) + Button( + colors = MaterialTheme.colorScheme.exitNodeToggleButton, + onClick = { openDirectoryLauncher.launch(null) }, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) { + Text(stringResource(R.string.pick_dir)) + } + } + }) + } + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PermissionsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PermissionsViewModel.kt new file mode 100644 index 0000000..46f2ea9 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PermissionsViewModel.kt @@ -0,0 +1,39 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.viewModel + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.tailscale.ipn.TaildropDirectoryStore +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import libtailscale.Libtailscale + +class PermissionsViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() { + + private val _currentDir = + MutableStateFlow(TaildropDirectoryStore.loadSavedDir().toString()) + val currentDir: StateFlow = _currentDir + + fun onDirectoryPicked(uri: Uri?, context: Context) { + if (uri == null) return + + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + val cr = context.contentResolver + + // Revoke previous grant so you don’t leak one + _currentDir.value?.let { old -> + runCatching { cr.releasePersistableUriPermission(Uri.parse(old), flags) } + } + + cr.takePersistableUriPermission(uri, flags) // may throw SecurityException + Libtailscale.setDirectFileRoot(uri.toString()) + TaildropDirectoryStore.saveFileDirectory(uri) + + _currentDir.value = uri.toString() + } +} diff --git a/android/src/main/res/drawable/baseline_drive_folder_upload_24.xml b/android/src/main/res/drawable/baseline_drive_folder_upload_24.xml new file mode 100644 index 0000000..2582c88 --- /dev/null +++ b/android/src/main/res/drawable/baseline_drive_folder_upload_24.xml @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/android/src/main/res/drawable/baseline_folder_open_24.xml b/android/src/main/res/drawable/baseline_folder_open_24.xml new file mode 100644 index 0000000..5601372 --- /dev/null +++ b/android/src/main/res/drawable/baseline_folder_open_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/src/main/res/drawable/baseline_notifications_none_24.xml b/android/src/main/res/drawable/baseline_notifications_none_24.xml new file mode 100644 index 0000000..1fb5684 --- /dev/null +++ b/android/src/main/res/drawable/baseline_notifications_none_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index d376490..c04baa8 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -24,6 +24,8 @@ No results Back Clear search + Off + On Tailscale @@ -221,7 +223,13 @@ We use storage in order to receive files with Taildrop. Notifications 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. - + Go to notification settings + Persistent status notifications are off by default and can be enabled in system settings. + Taildrop directory + Taildrop directory access + Give Tailscale access to a folder in order to be able to download incoming files sent to you via Taildrop. + Directory access + Pick a different directory Send with Taildrop From 1ec621c382307cd87cca7b277b9d79fd7120d44b Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Mon, 2 Jun 2025 13:55:08 -0700 Subject: [PATCH 16/49] android: make currentDir reactive (#661) -The composables were reading the currentDir value once and not observing it. This fixes it so that we recompose when the StateFlow changes. -Use commit() instead of apply() when writing to EncryptedSharedPreferences since we are reading from it immediately and need the writes to happen synchronously -Remove unused function in PermissionsViewModel Fixes tailscale/corp#29283 Signed-off-by: kari-ts --- .../java/com/tailscale/ipn/MainActivity.kt | 7 ++++- .../tailscale/ipn/TaildropDirectoryStore.kt | 2 +- .../tailscale/ipn/ui/view/PermissionsView.kt | 5 ++- .../tailscale/ipn/ui/view/TaildropDirView.kt | 9 ++++-- .../ipn/ui/viewModel/PermissionsViewModel.kt | 31 +++++-------------- 5 files changed, 24 insertions(+), 30 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index c7c8d60..8f72dcf 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -25,6 +25,7 @@ import androidx.activity.compose.setContent import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels import androidx.annotation.RequiresApi import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.animation.core.LinearOutSlowInEasing @@ -85,6 +86,7 @@ import com.tailscale.ipn.ui.view.UserSwitcherView import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModelFactory +import com.tailscale.ipn.ui.viewModel.PermissionsViewModel import com.tailscale.ipn.ui.viewModel.PingViewModel import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.VpnViewModel @@ -105,6 +107,7 @@ class MainActivity : ComponentActivity() { ViewModelProvider(this, MainViewModelFactory(vpnViewModel)).get(MainViewModel::class.java) } private lateinit var vpnViewModel: VpnViewModel + val permissionsViewModel: PermissionsViewModel by viewModels() companion object { private const val TAG = "Main Activity" @@ -179,6 +182,7 @@ class MainActivity : ComponentActivity() { try { Libtailscale.setDirectFileRoot(uri.toString()) TaildropDirectoryStore.saveFileDirectory(uri) + permissionsViewModel.refreshCurrentDir() } catch (e: Exception) { TSLog.e("MainActivity", "Failed to set Taildrop root: $e") } @@ -333,7 +337,8 @@ class MainActivity : ComponentActivity() { { navController.navigate("notifications") }) } composable("taildropDir") { - TaildropDirView(backTo("permissions"), directoryPickerLauncher) + TaildropDirView( + backTo("permissions"), directoryPickerLauncher, permissionsViewModel) } composable("notifications") { NotificationsView(backTo("permissions"), ::openApplicationSettings) diff --git a/android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt b/android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt index 02bbaee..be02c95 100644 --- a/android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt +++ b/android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt @@ -15,7 +15,7 @@ object TaildropDirectoryStore { @Throws(IOException::class, GeneralSecurityException::class) fun saveFileDirectory(directoryUri: Uri) { val prefs = App.get().getEncryptedPrefs() - prefs.edit().putString(PREF_KEY_SAF_URI, directoryUri.toString()).apply() + prefs.edit().putString(PREF_KEY_SAF_URI, directoryUri.toString()).commit() try { // Must restart Tailscale because a new LocalBackend with the new directory must be created. App.get().startLibtailscale(directoryUri.toString()) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt index 6c19939..3a5a83d 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/PermissionsView.kt @@ -13,6 +13,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -73,7 +74,9 @@ fun PermissionsView( }, supportingContent = { val displayPath = - permissionsViewModel.currentDir.value?.let { friendlyDirName(it) } ?: "No access" + permissionsViewModel.currentDir.collectAsState().value?.let { + friendlyDirName(it) + } ?: "No access" Text(displayPath) }) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/TaildropDirView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/TaildropDirView.kt index 4996b66..ba9510e 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/TaildropDirView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/TaildropDirView.kt @@ -15,21 +15,23 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import com.tailscale.ipn.R import com.tailscale.ipn.ui.theme.exitNodeToggleButton import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.friendlyDirName import com.tailscale.ipn.ui.viewModel.PermissionsViewModel +import com.tailscale.ipn.util.TSLog @Composable fun TaildropDirView( backToPermissionsView: BackNavigation, openDirectoryLauncher: ActivityResultLauncher, - permissionsViewModel: PermissionsViewModel = viewModel() + permissionsViewModel: PermissionsViewModel ) { Scaffold( topBar = { @@ -53,7 +55,8 @@ fun TaildropDirView( item("divider0") { Lists.SectionDivider() } item { - val currentDir = permissionsViewModel.currentDir.value + val currentDir by permissionsViewModel.currentDir.collectAsState() + TSLog.d("TaildropDirView", "currentDir in UI: $currentDir") val displayPath = currentDir?.let { friendlyDirName(it) } ?: "No access" ListItem( diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PermissionsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PermissionsViewModel.kt index 46f2ea9..507ccc5 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PermissionsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PermissionsViewModel.kt @@ -3,37 +3,20 @@ package com.tailscale.ipn.ui.viewModel -import android.content.Context -import android.content.Intent -import android.net.Uri -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import com.tailscale.ipn.TaildropDirectoryStore +import com.tailscale.ipn.util.TSLog import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import libtailscale.Libtailscale - -class PermissionsViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() { +class PermissionsViewModel : ViewModel() { private val _currentDir = - MutableStateFlow(TaildropDirectoryStore.loadSavedDir().toString()) + MutableStateFlow(TaildropDirectoryStore.loadSavedDir()?.toString()) val currentDir: StateFlow = _currentDir - fun onDirectoryPicked(uri: Uri?, context: Context) { - if (uri == null) return - - val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - val cr = context.contentResolver - - // Revoke previous grant so you don’t leak one - _currentDir.value?.let { old -> - runCatching { cr.releasePersistableUriPermission(Uri.parse(old), flags) } - } - - cr.takePersistableUriPermission(uri, flags) // may throw SecurityException - Libtailscale.setDirectFileRoot(uri.toString()) - TaildropDirectoryStore.saveFileDirectory(uri) - - _currentDir.value = uri.toString() + fun refreshCurrentDir() { + val newUri = TaildropDirectoryStore.loadSavedDir()?.toString() + TSLog.d("PermissionsViewModel", "refreshCurrentDir: $newUri") + _currentDir.value = newUri } } From a5a5cbb2d5bc4e0e19f4843fcc50db295952a90c Mon Sep 17 00:00:00 2001 From: Anton Tolchanov Date: Fri, 30 May 2025 15:57:47 +0100 Subject: [PATCH 17/49] android: add definitions for the DeviceSerialNumber MDM key Updates tailscale/tailscale#16010 Signed-off-by: Anton Tolchanov --- android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt | 4 ++++ android/src/main/res/values/strings.xml | 2 ++ android/src/main/res/xml/app_restrictions.xml | 6 ++++++ 3 files changed, 12 insertions(+) diff --git a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt index 3364ceb..c843d90 100644 --- a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt +++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt @@ -59,6 +59,10 @@ object MDMSettings { val postureChecking = AlwaysNeverUserDecidesMDMSetting("PostureChecking", "Enable Posture Checking") + // Handled on the backend + val deviceSerialNumber = + StringMDMSetting("DeviceSerialNumber", "Serial number of the device that is running Tailscale") + val useTailscaleDNSSettings = AlwaysNeverUserDecidesMDMSetting("UseTailscaleDNSSettings", "Use Tailscale DNS Settings") diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index c04baa8..723c4ba 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -205,6 +205,8 @@ Hides the specified categories of network devices from the devices list in the client. Allow LAN access when using an exit node Enable posture checking + Serial number of the device that is running Tailscale + Allows administrators to pass the serial number of the device to Tailscale client using MDM. Use Tailscale DNS settings Use Tailscale subnets Allow incoming connections diff --git a/android/src/main/res/xml/app_restrictions.xml b/android/src/main/res/xml/app_restrictions.xml index 7f5549f..363bfc5 100644 --- a/android/src/main/res/xml/app_restrictions.xml +++ b/android/src/main/res/xml/app_restrictions.xml @@ -66,6 +66,12 @@ android:restrictionType="choice" android:title="@string/enable_posture_checking" /> + + Date: Wed, 4 Jun 2025 13:40:42 -0400 Subject: [PATCH 18/49] android: detect amazon fire stick as a AndroidTV (#664) fixes tailscale/tailscale#16164 We weren't detecting fire stick devices as TV devices. Signed-off-by: Jonathan Nobels --- .../src/main/java/com/tailscale/ipn/ui/util/AndroidTVUtil.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/AndroidTVUtil.kt b/android/src/main/java/com/tailscale/ipn/ui/util/AndroidTVUtil.kt index b4265d2..4329d0f 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/AndroidTVUtil.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/AndroidTVUtil.kt @@ -13,10 +13,13 @@ import com.tailscale.ipn.UninitializedApp import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV object AndroidTVUtil { + private val FEATURE_FIRETV = "amazon.hardware.fire_tv" + fun isAndroidTV(): Boolean { val pm = UninitializedApp.get().packageManager return (pm.hasSystemFeature(@Suppress("deprecation") PackageManager.FEATURE_TELEVISION) || - pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK)) + pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK) || + pm.hasSystemFeature(FEATURE_FIRETV)) } } From 28084cbd27765ac5138838b5c278581003e1122c Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Fri, 6 Jun 2025 09:29:20 -0700 Subject: [PATCH 19/49] =?UTF-8?q?android:=20do=20not=20stop=20running=20on?= =?UTF-8?q?=20login,=20and=20edit=20prefs=20after=20startLogi=E2=80=A6=20(?= =?UTF-8?q?#659)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit android: do not stop running on login, and edit prefs after startLoginInteractive Previously: start, edit prefs with wantRunning=false, then startLoginInteractive Now: 1. editPrefs() with WantRunning=true, LoggedOut=false if AuthKey != null 2. start() -> boots tailscaled 3. startLoginInteractive() Do not call wantRunning=false; the route clearing issue requiring that is resolved. This also: -add deepCopy function which copies MaskedPrefs. Note that .copy() does not copy the non-constructor parameters -removes InternalExitNodePriorSet in MaskedPrefs, since this can't be set on the client Updates tailscale/corp#24002 Signed-off-by: kari-ts --- .../java/com/tailscale/ipn/ui/model/Ipn.kt | 33 ++++++-- .../ipn/ui/viewModel/IpnViewModel.kt | 77 ++++++++++--------- 2 files changed, 65 insertions(+), 45 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt index 338b7a9..a0b5c1b 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt @@ -4,9 +4,9 @@ package com.tailscale.ipn.ui.model import android.net.Uri -import java.util.UUID import kotlinx.serialization.Serializable import kotlinx.serialization.Transient +import java.util.UUID class Ipn { @@ -95,11 +95,11 @@ class Ipn { var ExitNodeIDSet: Boolean? = null, var ExitNodeAllowLANAccessSet: Boolean? = null, var WantRunningSet: Boolean? = null, + var LoggedOutSet: Boolean? = null, var ShieldsUpSet: Boolean? = null, var AdvertiseRoutesSet: Boolean? = null, var ForceDaemonSet: Boolean? = null, var HostnameSet: Boolean? = null, - var InternalExitNodePriorSet: Boolean? = null, ) { var ControlURL: String? = null @@ -126,12 +126,6 @@ class Ipn { ExitNodeIDSet = true } - var InternalExitNodePrior: String? = null - set(value) { - field = value - InternalExitNodePriorSet = true - } - var ExitNodeAllowLANAccess: Boolean? = null set(value) { field = value @@ -144,6 +138,12 @@ class Ipn { WantRunningSet = true } + var LoggedOut: Boolean? = null + set(value) { + field = value + LoggedOutSet = true + } + var ShieldsUp: Boolean? = null set(value) { field = value @@ -238,3 +238,20 @@ class Persist { var Provider: String = "", ) } + +fun Ipn.MaskedPrefs.deepCopy(): Ipn.MaskedPrefs { + return Ipn.MaskedPrefs().also { + if (this.ControlURLSet == true) it.ControlURL = this.ControlURL + if (this.RouteAllSet == true) it.RouteAll = this.RouteAll + if (this.CorpDNSSet == true) it.CorpDNS = this.CorpDNS + if (this.ExitNodeIDSet == true) it.ExitNodeID = this.ExitNodeID + if (this.ExitNodeAllowLANAccessSet == true) + it.ExitNodeAllowLANAccess = this.ExitNodeAllowLANAccess + if (this.WantRunningSet == true) it.WantRunning = this.WantRunning + if (this.LoggedOutSet == true) it.LoggedOut = this.LoggedOut + if (this.ShieldsUpSet == true) it.ShieldsUp = this.ShieldsUp + if (this.AdvertiseRoutesSet == true) it.AdvertiseRoutes = this.AdvertiseRoutes + if (this.ForceDaemonSet == true) it.ForceDaemon = this.ForceDaemon + if (this.HostnameSet == true) it.Hostname = this.Hostname + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt index 20230f6..39bf27f 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt @@ -11,6 +11,7 @@ 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.model.deepCopy import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.util.AdvertisedRoutesHelper import com.tailscale.ipn.ui.util.LoadingIndicator @@ -144,52 +145,54 @@ open class IpnViewModel : ViewModel() { // Login/Logout + /** + * Order of operations: + * 1. editPrefs() with maskedPrefs (to allow ControlURL override), WantRunning=true, LoggedOut=false if AuthKey != null + * 2. start() starts the LocalBackend state machine + * 3. startLoginInteractive() is currently required for bother interactive and non-interactive (using auth key) login + * + * Any failure short‑circuits the chain and invokes completionHandler once. + */ fun login( maskedPrefs: Ipn.MaskedPrefs? = null, authKey: String? = null, completionHandler: (Result) -> Unit = {} ) { + val client = Client(viewModelScope) - val loginAction = { - Client(viewModelScope).startLoginInteractive { result -> - result - .onSuccess { TSLog.d(TAG, "Login started: $it") } - .onFailure { TSLog.e(TAG, "Error starting login: ${it.message}") } - completionHandler(result) - } - } - - // Need to stop running before logging in to clear routes: - // https://linear.app/tailscale/issue/ENG-3441/routesdns-is-not-cleared-when-switching-profiles-or-reauthenticating - val stopThenLogin = { - Client(viewModelScope).editPrefs(Ipn.MaskedPrefs().apply { WantRunning = false }) { result -> - result - .onSuccess { loginAction() } - .onFailure { TSLog.e(TAG, "Error setting wantRunning to false: ${it.message}") } - } - } - - val startAction = { - Client(viewModelScope).start(Ipn.Options(AuthKey = authKey)) { start -> - start.onFailure { completionHandler(Result.failure(it)) }.onSuccess { stopThenLogin() } - } + val finalMaskedPrefs = maskedPrefs?.deepCopy() ?: Ipn.MaskedPrefs() + finalMaskedPrefs.WantRunning = true + if (authKey != null) { + finalMaskedPrefs.LoggedOut = false } - // If an MDM control URL is set, we will always use that in lieu of anything the user sets. - var prefs = maskedPrefs - val mdmControlURL = MDMSettings.loginURL.flow.value.value - - if (mdmControlURL != null) { - prefs = prefs ?: Ipn.MaskedPrefs() - prefs.ControlURL = mdmControlURL - TSLog.d(TAG, "Overriding control URL with MDM value: $mdmControlURL") + client.editPrefs(finalMaskedPrefs) { editResult -> + editResult + .onFailure { + TSLog.e(TAG, "editPrefs() failed: ${it.message}") + completionHandler(Result.failure(it)) + } + .onSuccess { + val opts = Ipn.Options(UpdatePrefs = editResult.getOrThrow(), AuthKey = authKey) + client.start(opts) { startResult -> + startResult + .onFailure { + TSLog.e(TAG, "start() failed: ${it.message}") + completionHandler(Result.failure(it)) + } + .onSuccess { + client.startLoginInteractive { loginResult -> + loginResult + .onFailure { + TSLog.e(TAG, "startLoginInteractive() failed: ${it.message}") + completionHandler(Result.failure(it)) + } + .onSuccess { completionHandler(Result.success(Unit)) } + } + } + } + } } - - prefs?.let { - Client(viewModelScope).editPrefs(it) { result -> - result.onFailure { completionHandler(Result.failure(it)) }.onSuccess { startAction() } - } - } ?: run { startAction() } } fun loginWithAuthKey(authKey: String, completionHandler: (Result) -> Unit = {}) { From 211eb45535011f02286768f5ae8b593ad7489986 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Fri, 6 Jun 2025 11:29:24 -0700 Subject: [PATCH 20/49] android: add fallback VPN permission (#662) Some heavily customized OEMS may auto-deny VPN requests without exposing the setting. Show a fallback dialog for devices with no visible VPN panel. Updates tailscale/tailscale#14095 Signed-off-by: kari-ts --- android/src/main/java/com/tailscale/ipn/MainActivity.kt | 9 +++++++++ .../java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt | 1 + android/src/main/res/values/strings.xml | 7 ++++++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 8f72dcf..04b4724 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -154,6 +154,15 @@ class MainActivity : ComponentActivity() { } else { TSLog.d("VpnPermission", "Permission was denied by the user") vpnViewModel.setVpnPrepared(false) + + AlertDialog.Builder(this) + .setTitle(R.string.vpn_permission_needed) + .setMessage(R.string.vpn_explainer) + .setPositiveButton(R.string.try_again) { _, _ -> + viewModel.showVPNPermissionLauncherIfUnauthorized() + } + .setNegativeButton(R.string.cancel, null) + .show() } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index 332c77e..9a6f3e7 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -203,6 +203,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { fun showVPNPermissionLauncherIfUnauthorized() { val vpnIntent = VpnService.prepare(App.get()) + TSLog.d("VpnPermissions", "vpnIntent=$vpnIntent") if (vpnIntent != null) { vpnPermissionLauncher?.launch(vpnIntent) } else { diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 723c4ba..25ec4f1 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -26,6 +26,8 @@ Clear search Off On + Try again + Cancel Tailscale @@ -314,7 +316,6 @@ VPN permission denied Only one VPN can be active, and it appears another is already running. Before starting Tailscale, disable the other VPN. Go to Settings - Cancel Subnet routes Advertise routes to machines that are not running Tailscale to make them available in your tailnet. Routes must be approved in the admin console. Open KB Article @@ -334,6 +335,10 @@ Hostname Failed to save + + VPN permission needed + Tailscale needs VPN access, but it looks like your device may not show VPN settings. If you are using another VPN app or have work policies, disable them first, then try again. + Taildrop Directory You have not selected a directory for incoming taildrop transfers. Please select or create a target directory. From 5f59a367e3a5573b0ce12ec42cd07fa50a98a785 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Fri, 6 Jun 2025 11:39:24 -0700 Subject: [PATCH 21/49] android: bump OSS (#666) OSS and Version updated to 1.85.36-t66ae8737f-g28084cbd2 Signed-off-by: kari-ts --- go.mod | 22 ++++++++++----------- go.sum | 50 +++++++++++++++++++++++------------------------- go.toolchain.rev | 2 +- 3 files changed, 36 insertions(+), 38 deletions(-) diff --git a/go.mod b/go.mod index b6ddb86..2ae165f 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,9 @@ module github.com/tailscale/tailscale-android go 1.24.0 require ( - github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 + github.com/tailscale/wireguard-go v0.0.0-20250530210235-65cd6eed7d7f golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab - tailscale.com v1.85.0-pre.0.20250524221629-09582bdc009f + tailscale.com v1.85.0-pre.0.20250606164629-66ae8737f40b ) require ( @@ -72,16 +72,16 @@ require ( github.com/x448/float16 v0.8.4 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect - golang.org/x/crypto v0.37.0 // indirect + golang.org/x/crypto v0.38.0 // indirect golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect - golang.org/x/mod v0.23.0 // indirect - golang.org/x/net v0.36.0 // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/term v0.31.0 // indirect - golang.org/x/text v0.24.0 // indirect - golang.org/x/time v0.10.0 // indirect - golang.org/x/tools v0.30.0 // indirect + golang.org/x/mod v0.24.0 // indirect + golang.org/x/net v0.40.0 // indirect + golang.org/x/sync v0.14.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/term v0.32.0 // indirect + golang.org/x/text v0.25.0 // indirect + golang.org/x/time v0.11.0 // indirect + golang.org/x/tools v0.33.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 // indirect diff --git a/go.sum b/go.sum index cd21a9d..6851184 100644 --- a/go.sum +++ b/go.sum @@ -59,8 +59,6 @@ github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yez github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= -github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI= -github.com/dsnet/try v0.0.3/go.mod h1:WBM8tRpUmnXXhY1U6/S8dt6UWdHTQ7y8A5YSkRCkq40= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= @@ -165,8 +163,8 @@ github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:U github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= -github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251 h1:h/41LFTrwMxB9Xvvug0kRdQCU5TlV1+pAMQw0ZtDE3U= -github.com/tailscale/wireguard-go v0.0.0-20250304000100-91a0587fb251/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= +github.com/tailscale/wireguard-go v0.0.0-20250530210235-65cd6eed7d7f h1:vg3PmQdq1BbB2V81iC1VBICQtfwbVGZ/4A/p7QKXTK0= +github.com/tailscale/wireguard-go v0.0.0-20250530210235-65cd6eed7d7f/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= @@ -186,39 +184,39 @@ go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4 go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= -golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= -golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= +golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= +golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab h1:KONOFF8Uy3b60HEzOsGnNghORNhY4ImyOx0PGm73K9k= golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab/go.mod h1:udWezQGYjqrCxz5nV321pXQTx5oGbZx+khZvFjZNOPM= -golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM= -golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= -golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= -golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= -golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY= -golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= @@ -237,5 +235,5 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.85.0-pre.0.20250524221629-09582bdc009f h1:ZXXlCnDLlDA/Hkt4TZ3QpCsOKiuZzhrd0htEOnn89rM= -tailscale.com v1.85.0-pre.0.20250524221629-09582bdc009f/go.mod h1:6/S63NMAhmncYT/1zIPDJkvCuZwMw+JnUuOfSPNazpo= +tailscale.com v1.85.0-pre.0.20250606164629-66ae8737f40b h1:G3vrbhsL3mqQBMsLwdBreRYPEbPetcOHERHcAP2wwGs= +tailscale.com v1.85.0-pre.0.20250606164629-66ae8737f40b/go.mod h1:gTdfakZDvwxCjWcPACr9GiwD+B69jplAcAt2mbES0Ss= diff --git a/go.toolchain.rev b/go.toolchain.rev index e8ede33..a5d7392 100644 --- a/go.toolchain.rev +++ b/go.toolchain.rev @@ -1 +1 @@ -982da8f24fa0504f2214f24b0d68b2febd5983f8 +98e8c99c256a5aeaa13725d2e43fdd7f465ba200 From b9993097fc46cf7a2374bffdd935de603cabff5f Mon Sep 17 00:00:00 2001 From: Zach Buchheit Date: Mon, 9 Jun 2025 12:20:31 -0700 Subject: [PATCH 22/49] mdm: define OnboardingFlow syspolicy on Android (#648) Adds an MDM setting `OnboardingFlow` which allows for the intro screen to be skipped when set to true. Adds MDM Setting update to the top of MainActivity onCreate to ensure the latest MDMSettings are accurate. When attempting to do this while relying on MDMSettings being update during onResume it created a race condition where occasionally OnboardingFlow was being evaluated to the default value `show` when in reality it should be set to `hide`. updates tailscale/corp#29482 Signed-off-by: zbuchheit --- .../main/java/com/tailscale/ipn/MainActivity.kt | 15 ++++++++++----- .../java/com/tailscale/ipn/mdm/MDMSettings.kt | 3 +++ android/src/main/res/values/strings.xml | 2 ++ android/src/main/res/xml/app_restrictions.xml | 8 ++++++++ 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 04b4724..a8549ce 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -49,6 +49,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.tailscale.ipn.mdm.MDMSettings +import com.tailscale.ipn.mdm.ShowHide import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.theme.AppTheme @@ -133,6 +134,9 @@ class MainActivity : ComponentActivity() { App.get() vpnViewModel = ViewModelProvider(App.get()).get(VpnViewModel::class.java) + val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager + MDMSettings.update(App.get(), rm) + // (jonathan) TODO: Force the app to be portrait on small screens until we have // proper landscape layout support if (!isLandscapeCapable()) { @@ -363,9 +367,7 @@ class MainActivity : ComponentActivity() { onNavigateHome = backTo("main"), backTo("userSwitcher")) } } - - // Show the intro screen one time - if (!introScreenViewed()) { + if (shouldDisplayOnboarding()) { navController.navigate("intro") setIntroScreenViewed(true) } @@ -523,8 +525,11 @@ class MainActivity : ComponentActivity() { startActivity(intent) } - private fun introScreenViewed(): Boolean { - return getSharedPreferences("introScreen", Context.MODE_PRIVATE).getBoolean("seen", false) + private fun shouldDisplayOnboarding(): Boolean { + val onboardingFlowShowHide = MDMSettings.onboardingFlow.flow.value.value + val introSeen = + getSharedPreferences("introScreen", Context.MODE_PRIVATE).getBoolean("seen", false) + return (onboardingFlowShowHide == ShowHide.Show && !introSeen) } private fun setIntroScreenViewed(seen: Boolean) { diff --git a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt index c843d90..d8df61d 100644 --- a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt +++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt @@ -101,6 +101,9 @@ object MDMSettings { // Overrides the value provided by os.Hostname() in Go val hostname = StringMDMSetting("Hostname", "Device Hostname") + // Allows admins to skip the get started intro screen + val onboardingFlow = ShowHideMDMSetting("OnboardingFlow", "Suppress the intro screen") + val allSettings by lazy { MDMSettings::class .declaredMemberProperties diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 25ec4f1..ed1fa16 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -220,6 +220,8 @@ Run as exit node visibility Defines an auth key that will be used for login. Auth Key + Skips the intro page shown to users that open the app for the first time + Skip the Onboarding Flow Permissions diff --git a/android/src/main/res/xml/app_restrictions.xml b/android/src/main/res/xml/app_restrictions.xml index 363bfc5..b47cc58 100644 --- a/android/src/main/res/xml/app_restrictions.xml +++ b/android/src/main/res/xml/app_restrictions.xml @@ -140,4 +140,12 @@ android:key="Hostname" android:restrictionType="string" android:title="@string/hostname" /> + + \ No newline at end of file From 28f1931531e633fca068e48da3d3f359c2678ea5 Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Wed, 11 Jun 2025 15:51:42 -0400 Subject: [PATCH 23/49] android: touchless auth key login (#667) updates tailscale/corp#29482 If an authKey is detected in the mdm payload, we will now skip the onboarding flows and several of the other non-mandatory permission prompts. Signed-off-by: Jonathan Nobels --- android/src/main/java/com/tailscale/ipn/App.kt | 7 +++++-- .../java/com/tailscale/ipn/MainActivity.kt | 18 ++++++++---------- .../java/com/tailscale/ipn/ui/view/MainView.kt | 18 ++++++++++++------ .../ipn/ui/viewModel/MainViewModel.kt | 11 +++++++++++ 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index 249a42d..fdbd295 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -10,6 +10,7 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.RestrictionsManager import android.content.SharedPreferences import android.content.pm.PackageManager import android.net.ConnectivityManager @@ -48,7 +49,6 @@ import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import libtailscale.Libtailscale -import java.io.File import java.io.IOException import java.net.NetworkInterface import java.security.GeneralSecurityException @@ -157,13 +157,16 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { if (storedUri != null && storedUri.toString().startsWith("content://")) { startLibtailscale(storedUri.toString()) } else { - startLibtailscale(this.getFilesDir().absolutePath) + startLibtailscale(this.filesDir.absolutePath) } healthNotifier = HealthNotifier(Notifier.health, Notifier.state, applicationScope) connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns) initViewModels() applicationScope.launch { + val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager + MDMSettings.update(get(), rm) + Notifier.state.collect { _ -> combine(Notifier.state, MDMSettings.forceEnabled.flow, Notifier.prefs, Notifier.netmap) { state, diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index a8549ce..dbcbc3f 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -137,6 +137,11 @@ class MainActivity : ComponentActivity() { val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager MDMSettings.update(App.get(), rm) + if (MDMSettings.onboardingFlow.flow.value.value == ShowHide.Hide || + MDMSettings.authKey.flow.value.value != null) { + setIntroScreenViewed(true) + } + // (jonathan) TODO: Force the app to be portrait on small screens until we have // proper landscape layout support if (!isLandscapeCapable()) { @@ -367,7 +372,7 @@ class MainActivity : ComponentActivity() { onNavigateHome = backTo("main"), backTo("userSwitcher")) } } - if (shouldDisplayOnboarding()) { + if (isIntroScreenViewedSet()) { navController.navigate("intro") setIntroScreenViewed(true) } @@ -505,10 +510,6 @@ class MainActivity : ComponentActivity() { lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) } } - override fun onStart() { - super.onStart() - } - override fun onStop() { super.onStop() val restrictionsManager = @@ -525,11 +526,8 @@ class MainActivity : ComponentActivity() { startActivity(intent) } - private fun shouldDisplayOnboarding(): Boolean { - val onboardingFlowShowHide = MDMSettings.onboardingFlow.flow.value.value - val introSeen = - getSharedPreferences("introScreen", Context.MODE_PRIVATE).getBoolean("seen", false) - return (onboardingFlowShowHide == ShowHide.Show && !introSeen) + private fun isIntroScreenViewedSet(): Boolean { + return !getSharedPreferences("introScreen", Context.MODE_PRIVATE).getBoolean("seen", false) } private fun setIntroScreenViewed(seen: Boolean) { diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index 71f87f5..0a848a4 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -215,13 +215,15 @@ fun MainView( when (state) { Ipn.State.Running -> { - - PromptPermissionsIfNecessary() viewModel.maybeRequestVpnPermission() LaunchVpnPermissionIfNeeded(viewModel) - LaunchedEffect(state) { - if (state == Ipn.State.Running && !isAndroidTV()) { - viewModel.checkIfTaildropDirectorySelected() + PromptForMissingPermissions(viewModel) + + if (!viewModel.skipPromptsForAuthKeyLogin()) { + LaunchedEffect(state) { + if (state == Ipn.State.Running && !isAndroidTV()) { + viewModel.checkIfTaildropDirectorySelected() + } } } @@ -795,7 +797,11 @@ fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) { @OptIn(ExperimentalPermissionsApi::class) @Composable -fun PromptPermissionsIfNecessary() { +fun PromptForMissingPermissions(viewModel: MainViewModel) { + if (viewModel.skipPromptsForAuthKeyLogin()) { + return + } + Permissions.prompt.forEach { (permission, state) -> ErrorDialog( title = permission.title, diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index 9a6f3e7..9634a86 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -126,6 +126,13 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { this.pingViewModel.handleDismissal() } + // Returns true if we should skip all of the user-interactive permissions prompts + // (with the exception of the VPN permission prompt) + fun skipPromptsForAuthKeyLogin(): Boolean { + val v = MDMSettings.authKey.flow.value.value + return v != null && v != "" + } + private val peerCategorizer = PeerCategorizer() init { @@ -219,6 +226,10 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } fun checkIfTaildropDirectorySelected() { + if (skipPromptsForAuthKeyLogin()) { + return + } + val app = App.get() val storedUri = app.getStoredDirectoryUri() if (storedUri == null) { From 14b0bd8b1933005270544511211383669624398b Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Fri, 13 Jun 2025 12:40:19 -0400 Subject: [PATCH 24/49] android: bump OSS (#668) OSS and Version updated to 1.85.55-t3ed76ceed-g28f193153 Signed-off-by: Jonathan Nobels --- go.mod | 2 +- go.sum | 4 ++-- go.toolchain.rev | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 2ae165f..451b0ce 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.24.0 require ( github.com/tailscale/wireguard-go v0.0.0-20250530210235-65cd6eed7d7f golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab - tailscale.com v1.85.0-pre.0.20250606164629-66ae8737f40b + tailscale.com v1.85.0-pre.0.20250612165745-3ed76ceed34e ) require ( diff --git a/go.sum b/go.sum index 6851184..7d36c4c 100644 --- a/go.sum +++ b/go.sum @@ -235,5 +235,5 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.85.0-pre.0.20250606164629-66ae8737f40b h1:G3vrbhsL3mqQBMsLwdBreRYPEbPetcOHERHcAP2wwGs= -tailscale.com v1.85.0-pre.0.20250606164629-66ae8737f40b/go.mod h1:gTdfakZDvwxCjWcPACr9GiwD+B69jplAcAt2mbES0Ss= +tailscale.com v1.85.0-pre.0.20250612165745-3ed76ceed34e h1:Do+xwPphBI+w6S+jivKyam63iEYFYvwwA1ZKD3vhqmo= +tailscale.com v1.85.0-pre.0.20250612165745-3ed76ceed34e/go.mod h1:gTdfakZDvwxCjWcPACr9GiwD+B69jplAcAt2mbES0Ss= diff --git a/go.toolchain.rev b/go.toolchain.rev index a5d7392..33aa564 100644 --- a/go.toolchain.rev +++ b/go.toolchain.rev @@ -1 +1 @@ -98e8c99c256a5aeaa13725d2e43fdd7f465ba200 +1cd3bf1a6eaf559aa8c00e749289559c884cef09 From 014f591a664c5abce456e40ce25c18c1ffd3a848 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Mon, 16 Jun 2025 11:39:32 -0700 Subject: [PATCH 25/49] android: use new System with pre-populated event bus (#670) Updates tailscale/tailscale#15160 Signed-off-by: kari-ts --- libtailscale/backend.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libtailscale/backend.go b/libtailscale/backend.go index e8dbc4c..27fc37f 100644 --- a/libtailscale/backend.go +++ b/libtailscale/backend.go @@ -266,7 +266,7 @@ func (a *App) runBackendOnce(ctx context.Context) error { func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore, settings settingsFunc) (*backend, error) { - sys := new(tsd.System) + sys := tsd.NewSystem() sys.Set(store) logf := logger.RusagePrefixLog(log.Printf) From f392619036201c7de442206ad2f9c20923a3e60d Mon Sep 17 00:00:00 2001 From: Nick Khyl Date: Tue, 24 Jun 2025 16:10:28 -0500 Subject: [PATCH 26/49] libtailscale: set EventBus in wgengine.Config Updates tailscale/tailscale#16369 Signed-off-by: Nick Khyl --- libtailscale/backend.go | 1 + 1 file changed, 1 insertion(+) diff --git a/libtailscale/backend.go b/libtailscale/backend.go index 27fc37f..c8d5f4a 100644 --- a/libtailscale/backend.go +++ b/libtailscale/backend.go @@ -317,6 +317,7 @@ func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore, HealthTracker: sys.HealthTracker(), Metrics: sys.UserMetricsRegistry(), DriveForLocal: driveimpl.NewFileSystemForLocal(logf), + EventBus: sys.Bus.Get(), }) if err != nil { return nil, fmt.Errorf("runBackend: NewUserspaceEngine: %v", err) From 460736a1515b436498413a877188df838b6ea3c9 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Fri, 27 Jun 2025 14:05:07 -0700 Subject: [PATCH 27/49] android: add All() to state store implementation (#673) Android has its own SharedPreferences-backed implementation of ipn.StateStore. Due to https://github.com/golang/go/issues/13445, we bundle the key list into a single primitive and unpack it in Go in our All() implementation. This also adds a compile-time check to prevent drift the interface. Updates tailscale/tailscale#15830 Signed-off-by: kari-ts --- .../src/main/java/com/tailscale/ipn/App.kt | 10 ++++++++ libtailscale/interfaces.go | 4 ++++ libtailscale/store.go | 24 +++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index fdbd295..67a0a62 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -248,6 +248,16 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { return getEncryptedPrefs().getString(prefKey, null) } + override fun getStateStoreKeysJSON(): String { + val prefix = "statestore-" + val keys = getEncryptedPrefs() + .getAll() + .keys + .filter { it.startsWith(prefix) } + .map { it.removePrefix(prefix) } + return org.json.JSONArray(keys).toString() + } + @Throws(IOException::class, GeneralSecurityException::class) fun getEncryptedPrefs(): SharedPreferences { val key = MasterKey.Builder(this).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() diff --git a/libtailscale/interfaces.go b/libtailscale/interfaces.go index 5663698..44b9616 100644 --- a/libtailscale/interfaces.go +++ b/libtailscale/interfaces.go @@ -29,6 +29,10 @@ type AppContext interface { // at the given key, or returns empty string if unset. DecryptFromPref(key string) (string, error) + // GetStateStoreKeysJson retrieves all keys stored in the encrypted SharedPreferences, + // strips off the "statestore-" prefix, and returns them as a JSON array. + GetStateStoreKeysJSON() string + // GetOSVersion gets the Android version. GetOSVersion() (string, error) diff --git a/libtailscale/store.go b/libtailscale/store.go index 3496b5d..4cc2960 100644 --- a/libtailscale/store.go +++ b/libtailscale/store.go @@ -5,6 +5,8 @@ package libtailscale import ( "encoding/base64" + "encoding/json" + "iter" "tailscale.com/ipn" ) @@ -23,6 +25,28 @@ func newStateStore(appCtx AppContext) *stateStore { } } +func (s *stateStore) All() iter.Seq2[ipn.StateKey, []byte] { + rawJSON := s.appCtx.GetStateStoreKeysJSON() + var keys []string + if err := json.Unmarshal([]byte(rawJSON), &keys); err != nil { + return func(yield func(ipn.StateKey, []byte) bool) {} + } + return func(yield func(ipn.StateKey, []byte) bool) { + for _, k := range keys { + blob, err := s.ReadState(ipn.StateKey(k)) + if err != nil { + continue + } + if !yield(ipn.StateKey(k), blob) { + return + } + } + } +} + +// compile-time assertion that store must implement ipn.StateStore to give immediate feedback on interface drift. +var _ ipn.StateStore = (*stateStore)(nil) + func prefKeyFor(id ipn.StateKey) string { return "statestore-" + string(id) } From e5a704f7850b7476c87964e87d04e38291a96b08 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Fri, 27 Jun 2025 15:50:21 -0700 Subject: [PATCH 28/49] android: bump OSS (#674) OSS and Version updated to 1.85.128-t0a64e86a0-g460736a15 Signed-off-by: kari-ts --- go.mod | 4 ++-- go.sum | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 451b0ce..73ff6e6 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module github.com/tailscale/tailscale-android -go 1.24.0 +go 1.24.4 require ( github.com/tailscale/wireguard-go v0.0.0-20250530210235-65cd6eed7d7f golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab - tailscale.com v1.85.0-pre.0.20250612165745-3ed76ceed34e + tailscale.com v1.85.0-pre.0.20250627205655-0a64e86a0df8 ) require ( diff --git a/go.sum b/go.sum index 7d36c4c..868652f 100644 --- a/go.sum +++ b/go.sum @@ -235,5 +235,5 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.85.0-pre.0.20250612165745-3ed76ceed34e h1:Do+xwPphBI+w6S+jivKyam63iEYFYvwwA1ZKD3vhqmo= -tailscale.com v1.85.0-pre.0.20250612165745-3ed76ceed34e/go.mod h1:gTdfakZDvwxCjWcPACr9GiwD+B69jplAcAt2mbES0Ss= +tailscale.com v1.85.0-pre.0.20250627205655-0a64e86a0df8 h1:gR3XF35IWpV4WhON27gR2vd8ypXbnjnrj5WreLWFxWk= +tailscale.com v1.85.0-pre.0.20250627205655-0a64e86a0df8/go.mod h1:zrtwlwmFfEWbUz77UN58gaLADx4rXSecFhGO+XW0JbU= From 05f3b58e10e6dea7c25d8d509ecb38c029c2fe28 Mon Sep 17 00:00:00 2001 From: Nick O'Neill Date: Mon, 21 Jul 2025 16:36:07 -0700 Subject: [PATCH 29/49] android: bump OSS (#678) OSS and Version updated to 1.85.235-t8453170aa-ge5a704f78 Signed-off-by: Nick O'Neill --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 73ff6e6..53f7650 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,9 @@ module github.com/tailscale/tailscale-android go 1.24.4 require ( - github.com/tailscale/wireguard-go v0.0.0-20250530210235-65cd6eed7d7f + github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab - tailscale.com v1.85.0-pre.0.20250627205655-0a64e86a0df8 + tailscale.com v1.85.0-pre.0.20250721193616-8453170aa120 ) require ( diff --git a/go.sum b/go.sum index 868652f..009fb76 100644 --- a/go.sum +++ b/go.sum @@ -163,8 +163,8 @@ github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:U github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= -github.com/tailscale/wireguard-go v0.0.0-20250530210235-65cd6eed7d7f h1:vg3PmQdq1BbB2V81iC1VBICQtfwbVGZ/4A/p7QKXTK0= -github.com/tailscale/wireguard-go v0.0.0-20250530210235-65cd6eed7d7f/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= +github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw= +github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= @@ -235,5 +235,5 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.85.0-pre.0.20250627205655-0a64e86a0df8 h1:gR3XF35IWpV4WhON27gR2vd8ypXbnjnrj5WreLWFxWk= -tailscale.com v1.85.0-pre.0.20250627205655-0a64e86a0df8/go.mod h1:zrtwlwmFfEWbUz77UN58gaLADx4rXSecFhGO+XW0JbU= +tailscale.com v1.85.0-pre.0.20250721193616-8453170aa120 h1:i9i9tJL/rxOIpNSfjY1AsWR4HYk9lQIAntAEdzcusS0= +tailscale.com v1.85.0-pre.0.20250721193616-8453170aa120/go.mod h1:Lm8dnzU2i/Emw15r6sl3FRNp/liSQ/nYw6ZSQvIdZ1M= From 483a949eb2e20703597b4a9d69edb7672661ddb5 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Tue, 22 Jul 2025 16:51:31 -0700 Subject: [PATCH 30/49] android: don't show Taildrop picker on TV (#679) Updates tailscale/tailscale#16164 Signed-off-by: kari-ts --- .../main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index 9634a86..edb41eb 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -23,6 +23,7 @@ import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn.State import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.notifier.Notifier +import com.tailscale.ipn.ui.util.AndroidTVUtil import com.tailscale.ipn.ui.util.PeerCategorizer import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.TimeUtil @@ -226,7 +227,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } fun checkIfTaildropDirectorySelected() { - if (skipPromptsForAuthKeyLogin()) { + if (skipPromptsForAuthKeyLogin() || AndroidTVUtil.isAndroidTV()) { return } From b3626fc342581bba3a09204a5a1115ee98d94908 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Tue, 22 Jul 2025 16:59:00 -0700 Subject: [PATCH 31/49] android: bump OSS (#680) OSS and Version updated to 1.85.241-t729d6532f-ge5a704f78 Signed-off-by: kari-ts Signed-off-by: kari-ts <135075563+kari-ts@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 53f7650..0d9c935 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.24.4 require ( github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab - tailscale.com v1.85.0-pre.0.20250721193616-8453170aa120 + tailscale.com v1.85.0-pre.0.20250722205428-729d6532ff35 ) require ( diff --git a/go.sum b/go.sum index 009fb76..1e56587 100644 --- a/go.sum +++ b/go.sum @@ -235,5 +235,5 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.85.0-pre.0.20250721193616-8453170aa120 h1:i9i9tJL/rxOIpNSfjY1AsWR4HYk9lQIAntAEdzcusS0= -tailscale.com v1.85.0-pre.0.20250721193616-8453170aa120/go.mod h1:Lm8dnzU2i/Emw15r6sl3FRNp/liSQ/nYw6ZSQvIdZ1M= +tailscale.com v1.85.0-pre.0.20250722205428-729d6532ff35 h1:RaZ9EcaONTkfAerz5hbjpFbtok9uqB46I34Q9T7VGQg= +tailscale.com v1.85.0-pre.0.20250722205428-729d6532ff35/go.mod h1:Lm8dnzU2i/Emw15r6sl3FRNp/liSQ/nYw6ZSQvIdZ1M= From 7aab785be0e826eb341554f83971e843e5d470f2 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Tue, 5 Aug 2025 07:19:03 -0700 Subject: [PATCH 32/49] android: add tailnet deletion dialog (#682) Add dialog for deleting tailnet in user switcher view. Fixes tailscale/corp#31024 Signed-off-by: kari-ts --- .../java/com/tailscale/ipn/ui/model/NetMap.kt | 7 +- .../tailscale/ipn/ui/view/UserSwitcherView.kt | 88 ++++++++++++++++++- android/src/main/res/values/strings.xml | 16 +++- 3 files changed, 108 insertions(+), 3 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt b/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt index 77322cb..f7a7a92 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt @@ -15,7 +15,8 @@ class Netmap { var Domain: String, var UserProfiles: Map, var TKAEnabled: Boolean, - var DNS: Tailcfg.DNSConfig? = null + var DNS: Tailcfg.DNSConfig? = null, + var AllCaps: List = emptyList() ) { // Keys are tailcfg.UserIDs thet get stringified // Helpers @@ -51,5 +52,9 @@ class Netmap { UserProfiles == other.UserProfiles && TKAEnabled == other.TKAEnabled } + + fun hasCap(capability: String): Boolean { + return AllCaps.contains(capability) + } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt index 6fb76a6..64d4bd4 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt @@ -3,6 +3,8 @@ package com.tailscale.ipn.ui.view +import android.content.Intent +import android.net.Uri import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -10,8 +12,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.ClickableText import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -20,13 +24,19 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton 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.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -46,10 +56,14 @@ data class UserSwitcherNav( @OptIn(ExperimentalMaterial3Api::class) @Composable fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = viewModel()) { - val users by viewModel.loginProfiles.collectAsState() val currentUser by viewModel.loggedInUser.collectAsState() val showHeaderMenu by viewModel.showHeaderMenu.collectAsState() + var showDeleteDialog by remember { mutableStateOf(false) } + val context = LocalContext.current + val netmapState by viewModel.netmap.collectAsState() + val capabilityIsOwner = "https://tailscale.com/cap/is-owner" + val isOwner = netmapState?.hasCap(capabilityIsOwner) == true Scaffold( topBar = { @@ -138,10 +152,47 @@ fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = vi } }) } + + Lists.SectionDivider() + Setting.Text(R.string.delete_tailnet, destructive = true) { + showDeleteDialog = true + } } } } } + + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text(text = stringResource(R.string.delete_tailnet)) }, + text = { + if (isOwner) { + OwnerDeleteDialogText { + val uri = Uri.parse("https://login.tailscale.com/admin/settings/general") + context.startActivity(Intent(Intent.ACTION_VIEW, uri)) + } + } else { + Text(stringResource(R.string.request_deletion_nonowner)) + } + }, + confirmButton = { + TextButton( + onClick = { + val intent = + Intent(Intent.ACTION_VIEW, Uri.parse("https://tailscale.com/contact/support")) + context.startActivity(intent) + showDeleteDialog = false + }) { + Text(text = stringResource(R.string.contact_support)) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text(text = stringResource(R.string.cancel)) + } + }) + } } @Composable @@ -171,6 +222,41 @@ fun FusMenu( } } +@Composable +fun OwnerDeleteDialogText(onSettingsClick: () -> Unit) { + val part1 = stringResource(R.string.request_deletion_owner_part1) + val part2a = stringResource(R.string.request_deletion_owner_part2a) + val part2b = stringResource(R.string.request_deletion_owner_part2b) + + val annotatedText = buildAnnotatedString { + append(part1 + " ") + + pushStringAnnotation( + tag = "settings", annotation = "https://login.tailscale.com/admin/settings/general") + withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) { + append("Settings > General") + } + pop() + + append(" $part2a\n\n") // newline after "Delete tailnet." + append(part2b) + } + + val context = LocalContext.current + ClickableText( + text = annotatedText, + style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface), + onClick = { offset -> + annotatedText + .getStringAnnotations(tag = "settings", start = offset, end = offset) + .firstOrNull() + ?.let { annotation -> + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item)) + context.startActivity(intent) + } + }) +} + @Composable fun MenuItem(text: String, onClick: () -> Unit) { DropdownMenuItem( diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index ed1fa16..5cefe14 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -13,7 +13,7 @@ Not connected %s - Selected + Selected Offline OK Continue @@ -137,6 +137,20 @@ Custom control server URL Auth key + Delete tailnet + Contact support + All requests related to the removal or deletion of data are handled by our Support team. To open a request, tap the Contact Support button below to be taken to our contact form in the browser. Complete the form, and a Customer Support Engineer will work with you directly to assist. + + As the owner of this tailnet, to remove yourself from the tailnet you can either reassign ownership and contact our Support team, or delete the whole tailnet through the admin console. To do the latter, go to + + + and look for “Delete tailnet”. + + + + All requests related to the removal or deletion of data are handled by our Support team. To open a request, tap the Contact Support button below to be taken to our contact form in the browser. Complete the form, and a Customer Support Engineer will work with you directly to assist. + + Choose exit node Mullvad exit nodes From e71641a4227e4c457c6b15318d52575640bd7d59 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Tue, 5 Aug 2025 10:39:46 -0700 Subject: [PATCH 33/49] android: expand SAF FileOps implementation (#675) * android: expand SAF FileOps implementation This expands the SAF FileOps to implement the refactored FileOps Updates tailscale/corp#29211 Signed-off-by: kari-ts * android: bump OSS OSS and Version updated to 1.87.25-t0f15e4419-gde3b6dbfd Signed-off-by: kari-ts --------- Signed-off-by: kari-ts Signed-off-by: kari-ts <135075563+kari-ts@users.noreply.github.com> --- .../ipn/ui/util/OutputStreamAdapter.kt | 1 + .../tailscale/ipn/ui/view/UserSwitcherView.kt | 2 +- .../com/tailscale/ipn/util/ShareFileHelper.kt | 308 +++++++++++++----- android/src/main/res/values/strings.xml | 1 - go.mod | 2 +- go.sum | 4 +- libtailscale/backend.go | 2 +- libtailscale/fileops.go | 98 +++++- libtailscale/interfaces.go | 38 ++- libtailscale/localapi.go | 21 -- libtailscale/streamutil.go | 36 ++ 11 files changed, 380 insertions(+), 133 deletions(-) create mode 100644 libtailscale/streamutil.go diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt b/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt index 9e73a42..2a9c2b2 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt @@ -24,3 +24,4 @@ class OutputStreamAdapter(private val outputStream: OutputStream) : libtailscale outputStream.close() } } + diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt index 64d4bd4..8abfc94 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt @@ -65,7 +65,7 @@ fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = vi val capabilityIsOwner = "https://tailscale.com/cap/is-owner" val isOwner = netmapState?.hasCap(capabilityIsOwner) == true - Scaffold( + Scaffold( topBar = { Header( R.string.accounts, diff --git a/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt index fed568d..a15636b 100644 --- a/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt +++ b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt @@ -5,9 +5,14 @@ package com.tailscale.ipn.util import android.content.Context import android.net.Uri +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract import androidx.documentfile.provider.DocumentFile +import com.tailscale.ipn.ui.util.InputStreamAdapter import com.tailscale.ipn.ui.util.OutputStreamAdapter import libtailscale.Libtailscale +import org.json.JSONObject +import java.io.FileOutputStream import java.io.IOException import java.io.OutputStream import java.util.UUID @@ -29,100 +34,169 @@ object ShareFileHelper : libtailscale.ShareFileHelper { // A simple data class that holds a SAF OutputStream along with its URI. data class SafStream(val uri: String, val stream: OutputStream) - // Cache for streams; keyed by file name and savedUri. - private val streamCache = ConcurrentHashMap() - - // A helper function that creates (or reuses) a SafStream for a given file. - private fun createStreamCached(fileName: String): SafStream { - val key = "$fileName|$savedUri" - return streamCache.getOrPut(key) { - val context: Context = - appContext - ?: run { - TSLog.e("ShareFileHelper", "appContext is null, cannot create file: $fileName") - return SafStream("", OutputStream.nullOutputStream()) - } - val directoryUriString = - savedUri - ?: run { - TSLog.e("ShareFileHelper", "savedUri is null, cannot create file: $fileName") - return SafStream("", OutputStream.nullOutputStream()) - } - val dirUri = Uri.parse(directoryUriString) - val pickedDir: DocumentFile = - DocumentFile.fromTreeUri(context, dirUri) - ?: run { - TSLog.e("ShareFileHelper", "Could not access directory for URI: $dirUri") - return SafStream("", OutputStream.nullOutputStream()) - } - val newFile: DocumentFile = - pickedDir.createFile("application/octet-stream", fileName) - ?: run { - TSLog.e("ShareFileHelper", "Failed to create file: $fileName in directory: $dirUri") - return SafStream("", OutputStream.nullOutputStream()) - } - // Attempt to open an OutputStream for writing. - val os: OutputStream? = context.contentResolver.openOutputStream(newFile.uri) - if (os == null) { - TSLog.e("ShareFileHelper", "openOutputStream returned null for URI: ${newFile.uri}") - SafStream(newFile.uri.toString(), OutputStream.nullOutputStream()) - } else { - TSLog.d("ShareFileHelper", "Opened OutputStream for file: $fileName") - SafStream(newFile.uri.toString(), os) - } - } + // A helper function that opens or creates a SafStream for a given file. + private fun openSafFileOutputStream(fileName: String): Pair { + val context = appContext ?: return "" to null + val dirUri = savedUri ?: return "" to null + val dir = DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) ?: return "" to null + + val file = + dir.findFile(fileName) + ?: dir.createFile("application/octet-stream", fileName) + ?: return "" to null + + val os = context.contentResolver.openOutputStream(file.uri, "rw") + return file.uri.toString() to os } - // This method returns a SafStream containing the SAF URI and its corresponding OutputStream. - override fun openFileWriter(fileName: String): libtailscale.OutputStream { - val stream = createStreamCached(fileName) - return OutputStreamAdapter(stream.stream) + @Throws(IOException::class) + private fun openWriterFD(fileName: String, offset: Long): Pair { + val ctx = appContext ?: throw IOException("App context not initialized") + val dirUri = savedUri ?: throw IOException("No directory URI") + val dir = + DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri)) + ?: throw IOException("Invalid tree URI: $dirUri") + val file = + dir.findFile(fileName) + ?: dir.createFile("application/octet-stream", fileName) + ?: throw IOException("Failed to create file: $fileName") + + val pfd = + ctx.contentResolver.openFileDescriptor(file.uri, "rw") + ?: throw IOException("Failed to open file descriptor for ${file.uri}") + val fos = FileOutputStream(pfd.fileDescriptor) + + if (offset != 0L) fos.channel.position(offset) else fos.channel.truncate(0) + return file.uri.toString() to SeekableOutputStream(fos, pfd) } - override fun openFileURI(fileName: String): String { - val safFile = createStreamCached(fileName) - return safFile.uri + private val currentUri = ConcurrentHashMap() + + @Throws(IOException::class) + override fun openFileWriter(fileName: String, offset: Long): libtailscale.OutputStream { + val (uri, stream) = openWriterFD(fileName, offset) + if (stream == null) { + throw IOException("Failed to open file writer for $fileName") + } + currentUri[fileName] = uri + return OutputStreamAdapter(stream) } - override fun renamePartialFile( - partialUri: String, - targetDirUri: String, - targetName: String - ): String { + @Throws(IOException::class) + override fun getFileURI(fileName: String): String { + currentUri[fileName]?.let { + return it + } + + val ctx = appContext ?: throw IOException("App context not initialized") + val dirStr = savedUri ?: throw IOException("No saved directory URI") + val dir = + DocumentFile.fromTreeUri(ctx, Uri.parse(dirStr)) + ?: throw IOException("Invalid tree URI: $dirStr") + + val file = dir.findFile(fileName) ?: throw IOException("File not found: $fileName") + val uri = file.uri.toString() + currentUri[fileName] = uri + return uri + } + + @Throws(IOException::class) + override fun renameFile(oldPath: String, targetName: String): String { + val ctx = appContext ?: throw IOException("not initialized") + val dirUri = savedUri ?: throw IOException("directory not set") + val srcUri = Uri.parse(oldPath) + val dir = + DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri)) + ?: throw IOException("cannot open dir $dirUri") + + var finalName = targetName + dir.findFile(finalName)?.let { existing -> + if (lengthOfUri(ctx, existing.uri) == 0L) { + existing.delete() + } else { + finalName = generateNewFilename(finalName) + } + } + try { - val context = appContext ?: throw IllegalStateException("appContext is null") - val partialUriObj = Uri.parse(partialUri) - val targetDirUriObj = Uri.parse(targetDirUri) - val targetDir = - DocumentFile.fromTreeUri(context, targetDirUriObj) - ?: throw IllegalStateException( - "Unable to get target directory from URI: $targetDirUri") - var finalTargetName = targetName - - var destFile = targetDir.findFile(finalTargetName) - if (destFile != null) { - finalTargetName = generateNewFilename(finalTargetName) + DocumentsContract.renameDocument(ctx.contentResolver, srcUri, finalName)?.also { newUri -> + runCatching { ctx.contentResolver.delete(srcUri, null, null) } + cleanupPartials(dir, targetName) + return newUri.toString() } + } catch (e: Exception) { + TSLog.w("renameFile", "renameDocument fallback triggered for $srcUri -> $finalName: ${e.message}") - destFile = - targetDir.createFile("application/octet-stream", finalTargetName) - ?: throw IOException("Failed to create new file with name: $finalTargetName") + } + + val dest = + dir.createFile("application/octet-stream", finalName) + ?: throw IOException("createFile failed for $finalName") + + ctx.contentResolver.openInputStream(srcUri).use { inp -> + ctx.contentResolver.openOutputStream(dest.uri, "w").use { out -> + if (inp == null || out == null) { + dest.delete() + throw IOException("Unable to open output stream for URI: ${dest.uri}") + } + inp.copyTo(out) + } + } + + ctx.contentResolver.delete(srcUri, null, null) + cleanupPartials(dir, targetName) + return dest.uri.toString() + } - context.contentResolver.openInputStream(partialUriObj)?.use { input -> - context.contentResolver.openOutputStream(destFile.uri)?.use { output -> - input.copyTo(output) - } ?: throw IOException("Unable to open output stream for URI: ${destFile.uri}") - } ?: throw IOException("Unable to open input stream for URI: $partialUri") + private fun lengthOfUri(ctx: Context, uri: Uri): Long = + ctx.contentResolver.openAssetFileDescriptor(uri, "r").use { it?.length ?: -1 } - DocumentFile.fromSingleUri(context, partialUriObj)?.delete() - return destFile.uri.toString() - } catch (e: Exception) { - throw IOException( - "Failed to rename partial file from URI $partialUri to final file in $targetDirUri with name $targetName: ${e.message}", - e) + // delete any stray “.partial” files for this base name + private fun cleanupPartials(dir: DocumentFile, base: String) { + for (child in dir.listFiles()) { + val n = child.name ?: continue + if (n.endsWith(".partial") && n.contains(base, ignoreCase = false)) { + child.delete() + } } } + @Throws(IOException::class) + override fun deleteFile(uri: String) { + val ctx = appContext ?: throw IOException("DeleteFile: not initialized") + + val uri = Uri.parse(uri) + val doc = + DocumentFile.fromSingleUri(ctx, uri) + ?: throw IOException("DeleteFile: cannot resolve URI $uri") + + if (!doc.delete()) { + throw IOException("DeleteFile: delete() returned false for $uri") + } + } + + @Throws(IOException::class) + override fun getFileInfo(fileName: String): String { + val context = appContext ?: throw IOException("app context not initialized") + val dirUri = savedUri ?: throw IOException("SAF URI not initialized") + val dir = + DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) + ?: throw IOException("could not resolve SAF root") + + val file = + dir.findFile(fileName) ?: throw IOException("file \"$fileName\" not found in SAF directory") + + val name = file.name ?: throw IOException("file name missing for $fileName") + val size = file.length() + val modTime = file.lastModified() + + return """{"name":${JSONObject.quote(name)},"size":$size,"modTime":$modTime}""" + } + + private fun jsonEscape(s: String): String { + return JSONObject.quote(s) + } + fun generateNewFilename(filename: String): String { val dotIndex = filename.lastIndexOf('.') val baseName = if (dotIndex != -1) filename.substring(0, dotIndex) else filename @@ -131,4 +205,78 @@ object ShareFileHelper : libtailscale.ShareFileHelper { val uuid = UUID.randomUUID() return "$baseName-$uuid$extension" } + + fun listPartialFiles(suffix: String): Array { + val context = appContext ?: return emptyArray() + val rootUri = savedUri ?: return emptyArray() + val dir = DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) ?: return emptyArray() + + return dir.listFiles() + .filter { it.name?.endsWith(suffix) == true } + .mapNotNull { it.name } + .toTypedArray() + } + + @Throws(IOException::class) + override fun listFilesJSON(suffix: String): String { + val list = listPartialFiles(suffix) + if (list.isEmpty()) { + throw IOException("no files found matching suffix \"$suffix\"") + } + return list.joinToString(prefix = "[\"", separator = "\",\"", postfix = "\"]") + } + + @Throws(IOException::class) + override fun openFileReader(name: String): libtailscale.InputStream { + val context = appContext ?: throw IOException("app context not initialized") + val rootUri = savedUri ?: throw IOException("SAF URI not initialized") + val dir = + DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) + ?: throw IOException("could not open SAF root") + + val suffix = name.substringAfterLast('.', ".$name") + + val file = + dir.listFiles().firstOrNull { + val fname = it.name ?: return@firstOrNull false + fname.endsWith(suffix, ignoreCase = false) + } ?: throw IOException("no file ending with \"$suffix\" in SAF directory") + + val inStream = + context.contentResolver.openInputStream(file.uri) + ?: throw IOException("openInputStream returned null for ${file.uri}") + + return InputStreamAdapter(inStream) + } + + private class SeekableOutputStream( + private val fos: FileOutputStream, + private val pfd: ParcelFileDescriptor + ) : OutputStream() { + + private var closed = false + + override fun write(b: Int) = fos.write(b) + + override fun write(b: ByteArray) = fos.write(b) + + override fun write(b: ByteArray, off: Int, len: Int) { + fos.write(b, off, len) + } + + override fun close() { + if (!closed) { + closed = true + try { + fos.flush() + fos.fd.sync() // blocks until data + metadata are durable + } finally { + fos.close() + pfd.close() + } + } + } + + override fun flush() = fos.flush() + } } diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 5cefe14..97d7edc 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -136,7 +136,6 @@ Invalid key Custom control server URL Auth key - Delete tailnet Contact support All requests related to the removal or deletion of data are handled by our Support team. To open a request, tap the Contact Support button below to be taken to our contact form in the browser. Complete the form, and a Customer Support Engineer will work with you directly to assist. diff --git a/go.mod b/go.mod index 0d9c935..05da9e2 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.24.4 require ( github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab - tailscale.com v1.85.0-pre.0.20250722205428-729d6532ff35 + tailscale.com v1.87.0-pre.0.20250801224156-0f15e4419683 ) require ( diff --git a/go.sum b/go.sum index 1e56587..11b45be 100644 --- a/go.sum +++ b/go.sum @@ -235,5 +235,5 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.85.0-pre.0.20250722205428-729d6532ff35 h1:RaZ9EcaONTkfAerz5hbjpFbtok9uqB46I34Q9T7VGQg= -tailscale.com v1.85.0-pre.0.20250722205428-729d6532ff35/go.mod h1:Lm8dnzU2i/Emw15r6sl3FRNp/liSQ/nYw6ZSQvIdZ1M= +tailscale.com v1.87.0-pre.0.20250801224156-0f15e4419683 h1:meEUX1Nsr5SaXiaeivOGG4c7gsQm/P3Jr3dzbtE0j6k= +tailscale.com v1.87.0-pre.0.20250801224156-0f15e4419683/go.mod h1:Lm8dnzU2i/Emw15r6sl3FRNp/liSQ/nYw6ZSQvIdZ1M= \ No newline at end of file diff --git a/libtailscale/backend.go b/libtailscale/backend.go index c8d5f4a..bb1704d 100644 --- a/libtailscale/backend.go +++ b/libtailscale/backend.go @@ -337,7 +337,7 @@ func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore, } lb, err := ipnlocal.NewLocalBackend(logf, logID.Public(), sys, 0) if ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb); ok { - ext.SetFileOps(NewAndroidFileOps(a.shareFileHelper)) + ext.SetFileOps(newAndroidFileOps(a.shareFileHelper)) ext.SetDirectFileRoot(a.directFileRoot) } diff --git a/libtailscale/fileops.go b/libtailscale/fileops.go index 241097c..769cd91 100644 --- a/libtailscale/fileops.go +++ b/libtailscale/fileops.go @@ -3,36 +3,98 @@ package libtailscale import ( - "fmt" + "encoding/json" "io" + "os" + "time" + + "tailscale.com/feature/taildrop" ) -// AndroidFileOps implements the ShareFileHelper interface using the Android helper. -type AndroidFileOps struct { +// androidFileOps implements [taildrop.FileOps] using the Android ShareFileHelper. +type androidFileOps struct { helper ShareFileHelper } -func NewAndroidFileOps(helper ShareFileHelper) *AndroidFileOps { - return &AndroidFileOps{helper: helper} +var _ taildrop.FileOps = (*androidFileOps)(nil) + +func newAndroidFileOps(helper ShareFileHelper) *androidFileOps { + return &androidFileOps{helper: helper} +} + +func (ops *androidFileOps) OpenWriter(name string, offset int64, _ os.FileMode) (io.WriteCloser, string, error) { + wc, err := ops.helper.OpenFileWriter(name, offset) + if err != nil { + return nil, "", err + } + uri, err := ops.helper.GetFileURI(name) + if err != nil { + wc.Close() + return nil, "", err + } + return wc, uri, nil +} + +func (ops *androidFileOps) Remove(baseName string) error { + uri, err := ops.helper.GetFileURI(baseName) + if err != nil { + return err + } + return ops.helper.DeleteFile(uri) +} + +func (ops *androidFileOps) Rename(oldPath, newName string) (string, error) { + return ops.helper.RenameFile(oldPath, newName) } -func (ops *AndroidFileOps) OpenFileURI(filename string) string { - return ops.helper.OpenFileURI(filename) +func (ops *androidFileOps) ListFiles() ([]string, error) { + namesJSON, err := ops.helper.ListFilesJSON("") + if err != nil { + return nil, err + } + var names []string + if err := json.Unmarshal([]byte(namesJSON), &names); err != nil { + return nil, err + } + return names, nil } -func (ops *AndroidFileOps) OpenFileWriter(filename string) (io.WriteCloser, string, error) { - uri := ops.helper.OpenFileURI(filename) - outputStream := ops.helper.OpenFileWriter(filename) - if outputStream == nil { - return nil, uri, fmt.Errorf("failed to open SAF output stream for %s", filename) +func (ops *androidFileOps) OpenReader(name string) (io.ReadCloser, error) { + in, err := ops.helper.OpenFileReader(name) + if err != nil { + return nil, err } - return outputStream, uri, nil + return adaptInputStream(in), nil } -func (ops *AndroidFileOps) RenamePartialFile(partialUri, targetDirUri, targetName string) (string, error) { - newURI := ops.helper.RenamePartialFile(partialUri, targetDirUri, targetName) - if newURI == "" { - return "", fmt.Errorf("failed to rename partial file via SAF") +func (ops *androidFileOps) Stat(name string) (os.FileInfo, error) { + infoJSON, err := ops.helper.GetFileInfo(name) + if err != nil { + return nil, err } - return newURI, nil + var fi androidFileInfo + if err := json.Unmarshal([]byte(infoJSON), &fi); err != nil { + return nil, err + } + return &fi, nil +} + +type androidFileInfoJSON struct { + Name string `json:"name"` + Size int64 `json:"size"` + ModTime int64 `json:"modTime"` +} + +type androidFileInfo struct { + data androidFileInfoJSON } + +// compile-time check +var _ os.FileInfo = (*androidFileInfo)(nil) + +func (fi *androidFileInfo) Name() string { return fi.data.Name } +func (fi *androidFileInfo) Size() int64 { return fi.data.Size } +func (fi *androidFileInfo) Mode() os.FileMode { return 0o600 } +func (fi *androidFileInfo) ModTime() time.Time { return time.UnixMilli(fi.data.ModTime) } +func (fi *androidFileInfo) IsDir() bool { return false } +func (fi *androidFileInfo) Sys() any { return nil } diff --git a/libtailscale/interfaces.go b/libtailscale/interfaces.go index 44b9616..67a108c 100644 --- a/libtailscale/interfaces.go +++ b/libtailscale/interfaces.go @@ -175,14 +175,36 @@ type OutputStream interface { // ShareFileHelper corresponds to the Kotlin ShareFileHelper class type ShareFileHelper interface { - OpenFileWriter(fileName string) OutputStream - - // OpenFileURI opens the file and returns its SAF URI. - OpenFileURI(filename string) string - - // RenamePartialFile takes SAF URIs and a target file name, - // and returns the new SAF URI and an error. - RenamePartialFile(partialUri string, targetDirUri string, targetName string) string + // OpenFileWriter creates or truncates a file named fileName at a given offset, + // returning an OutputStream for writing. Returns an error if the file cannot be opened. + OpenFileWriter(fileName string, offset int64) (stream OutputStream, err error) + + // GetFileURI returns the SAF URI string for the file named fileName, + // or an error if the file cannot be resolved. + GetFileURI(fileName string) (uri string, err error) + + // RenameFile renames the file at oldPath (a SAF URI) into the Taildrop directory, + // giving it the new targetName. Returns the SAF URI of the renamed file, or an error. + RenameFile(oldPath string, targetName string) (newURI string, err error) + + // ListFilesJSON returns a JSON-encoded list of filenames in the Taildrop directory + // that end with the specified suffix. If the suffix is empty, it returns all files. + // Returns an error if no matching files are found or the directory cannot be accessed. + ListFilesJSON(suffix string) (json string, err error) + + // OpenFileReader opens the file with the given name (typically a .partial file) + // and returns an InputStream for reading its contents. + // Returns an error if the file cannot be opened. + OpenFileReader(name string) (stream InputStream, err error) + + // DeleteFile deletes the file identified by the given SAF URI string. + // Returns an error if the file could not be deleted. + DeleteFile(uri string) error + + // GetFileInfo returns a JSON-encoded string containing metadata for fileName, + // matching the fields of androidFileInfo (name, size, modTime). + // Returns an error if the file does not exist or cannot be accessed. + GetFileInfo(fileName string) (json string, err error) } // The below are global callbacks that allow the Java application to notify Go diff --git a/libtailscale/localapi.go b/libtailscale/localapi.go index 678d44c..d25312b 100644 --- a/libtailscale/localapi.go +++ b/libtailscale/localapi.go @@ -230,27 +230,6 @@ func (r *Response) Flush() { }) } -func adaptInputStream(in InputStream) io.ReadCloser { - if in == nil { - return nil - } - r, w := io.Pipe() - go func() { - defer w.Close() - for { - b, err := in.Read() - if err != nil { - log.Printf("error reading from inputstream: %s", err) - } - if b == nil { - return - } - w.Write(b) - } - }() - return r -} - // Below taken from Go stdlib var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") diff --git a/libtailscale/streamutil.go b/libtailscale/streamutil.go new file mode 100644 index 0000000..a656923 --- /dev/null +++ b/libtailscale/streamutil.go @@ -0,0 +1,36 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package libtailscale + +import ( + "io" + "log" +) + +// adaptInputStream wraps an [InputStream] into an [io.ReadCloser]. +// It launches a goroutine to stream reads into a pipe. +func adaptInputStream(in InputStream) io.ReadCloser { + if in == nil { + return nil + } + r, w := io.Pipe() + go func() { + defer w.Close() + for { + b, err := in.Read() + if err != nil { + log.Printf("error reading from inputstream: %v", err) + return + } + if b == nil { + return + } + if _, err := w.Write(b); err != nil { + log.Printf("error writing to pipe: %v", err) + return + } + } + }() + return r +} From 66aae86d4025618ed0d27964d197fcaebc271b6d Mon Sep 17 00:00:00 2001 From: James Tucker Date: Mon, 4 Aug 2025 17:27:23 -0700 Subject: [PATCH 34/49] Makefile: move NDK_ROOT below ANDROID_HOME detection If ANDROID_HOME is being detected by the code that finds a valid home with an empty host environment, then NDK_ROOT should be able to use that, but it was out of order in the evaluation. Updates #cleanup Signed-off-by: James Tucker --- Makefile | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/Makefile b/Makefile index 342e0bc..ea93133 100644 --- a/Makefile +++ b/Makefile @@ -13,19 +13,6 @@ DOCKER_IMAGE := tailscale-android-build-amd64-041425-1 export TS_USE_TOOLCHAIN=1 -# Auto-select an NDK from ANDROID_HOME (choose highest version available) -NDK_ROOT ?= $(shell ls -1d $(ANDROID_HOME)/ndk/* 2>/dev/null | sort -V | tail -n 1) - -HOST_OS := $(shell uname | tr A-Z a-z) -ifeq ($(HOST_OS),linux) - STRIP_TOOL := $(NDK_ROOT)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-objcopy -else ifeq ($(HOST_OS),darwin) - STRIP_TOOL := $(NDK_ROOT)/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-objcopy -endif - -$(info Using NDK_ROOT: $(NDK_ROOT)) -$(info Using STRIP_TOOL: $(STRIP_TOOL)) - DEBUG_APK := tailscale-debug.apk RELEASE_AAB := tailscale-release.aab RELEASE_TV_AAB := tailscale-tv-release.aab @@ -64,6 +51,21 @@ ifeq ($(ANDROID_SDK_ROOT),) endif export ANDROID_HOME ?= $(ANDROID_SDK_ROOT) +# Auto-select an NDK from ANDROID_HOME (choose highest version available) +NDK_ROOT ?= $(shell ls -1d $(ANDROID_HOME)/ndk/* 2>/dev/null | sort -V | tail -n 1) + +HOST_OS := $(shell uname | tr A-Z a-z) +ifeq ($(HOST_OS),linux) + STRIP_TOOL := $(NDK_ROOT)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-objcopy +else ifeq ($(HOST_OS),darwin) + STRIP_TOOL := $(NDK_ROOT)/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-objcopy +endif + +$(info Using ANDROID_HOME: $(ANDROID_HOME)) +$(info Using NDK_ROOT: $(NDK_ROOT)) +$(info Using STRIP_TOOL: $(STRIP_TOOL)) + + # Attempt to find Android Studio for Linux configuration, which does not have a # predetermined location. ANDROID_STUDIO_ROOT ?= $(shell find ~/android-studio /usr/local/android-studio /opt/android-studio /Applications/Android\ Studio.app $(PROGRAMFILES)/Android/Android\ Studio -type d -maxdepth 1 2>/dev/null | head -n 1) @@ -312,7 +314,7 @@ checkandroidsdk: ## Check that Android SDK is installed test: gradle-dependencies ## Run the Android tests (cd android && ./gradlew test) -.PHONY: emulator +.PHONY: emulator emulator: ## Start an android emulator instance @echo "Checking installed SDK packages..." @if ! $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --list_installed | grep -q "$(AVD_IMAGE)"; then \ @@ -327,7 +329,7 @@ emulator: ## Start an android emulator instance @echo "Starting emulator..." @$(ANDROID_HOME)/emulator/emulator -avd "$(AVD)" -logcat-output /dev/stdout -netdelay none -netspeed full -.PHONY: install +.PHONY: install install: $(DEBUG_APK) ## Install the debug APK on a connected device adb install -r $< @@ -335,7 +337,7 @@ install: $(DEBUG_APK) ## Install the debug APK on a connected device run: install ## Run the debug APK on a connected device adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.MainActivity -.PHONY: docker-build-image +.PHONY: docker-build-image docker-build-image: ## Builds the docker image for the android build environment if it does not exist @echo "Checking if docker image $(DOCKER_IMAGE) already exists..." @if ! docker images $(DOCKER_IMAGE) -q | grep -q . ; then \ From e68e64014ea0f9ee4ccc11d042119e0413accf5b Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Wed, 6 Aug 2025 10:44:22 -0700 Subject: [PATCH 35/49] android: defer taildrop selector until first taildrop attempt (#684) Move Taildrop directory selector out of onboarding -Listen for Taildrop, and show selector if a directory has not been set Remove LocalBackend re-initialization -This is no longer necessary since the directory is set in FileOps Updates tailscale/corp#29211 Signed-off-by: kari-ts --- .../src/main/java/com/tailscale/ipn/App.kt | 95 +++----------- .../java/com/tailscale/ipn/MainActivity.kt | 92 +++++++++----- .../tailscale/ipn/TaildropDirectoryStore.kt | 8 -- .../com/tailscale/ipn/ui/view/MainView.kt | 70 ++--------- .../com/tailscale/ipn/ui/view/SettingsView.kt | 6 +- .../ipn/ui/viewModel/AppViewModel.kt | 119 ++++++++++++++++++ .../ipn/ui/viewModel/MainViewModel.kt | 87 ++----------- .../com/tailscale/ipn/util/ShareFileHelper.kt | 116 ++++++++++------- libtailscale/backend.go | 27 +--- libtailscale/callbacks.go | 3 - libtailscale/interfaces.go | 4 - libtailscale/tailscale.go | 7 +- 12 files changed, 284 insertions(+), 350 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/viewModel/AppViewModel.kt diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index 67a0a62..7e4e514 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -1,7 +1,6 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause package com.tailscale.ipn - import android.Manifest import android.app.Application import android.app.Notification @@ -33,8 +32,8 @@ import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.notifier.HealthNotifier import com.tailscale.ipn.ui.notifier.Notifier -import com.tailscale.ipn.ui.viewModel.VpnViewModel -import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory +import com.tailscale.ipn.ui.viewModel.AppViewModel +import com.tailscale.ipn.ui.viewModel.AppViewModelFactory import com.tailscale.ipn.util.FeatureFlags import com.tailscale.ipn.util.ShareFileHelper import com.tailscale.ipn.util.TSLog @@ -53,17 +52,14 @@ import java.io.IOException import java.net.NetworkInterface import java.security.GeneralSecurityException import java.util.Locale - class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - companion object { private const val FILE_CHANNEL_ID = "tailscale-files" // Key to store the SAF URI in EncryptedSharedPreferences. private val PREF_KEY_SAF_URI = "saf_directory_uri" private const val TAG = "App" private lateinit var appInstance: App - /** * Initializes the app (if necessary) and returns the singleton app instance. Always use this * function to obtain an App reference to make sure the app initializes. @@ -74,45 +70,33 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { return appInstance } } - val dns = DnsConfig() private lateinit var connectivityManager: ConnectivityManager private lateinit var mdmChangeReceiver: MDMSettingsChangedReceiver private lateinit var app: libtailscale.Application - override val viewModelStore: ViewModelStore get() = appViewModelStore - private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() } - var healthNotifier: HealthNotifier? = null - override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString - override fun getInstallSource(): String = AppSourceChecker.getInstallSource(this) - override fun shouldUseGoogleDNSFallback(): Boolean = BuildConfig.USE_GOOGLE_DNS_FALLBACK - override fun log(s: String, s1: String) { Log.d(s, s1) } - fun getLibtailscaleApp(): libtailscale.Application { if (!isInitialized) { initOnce() // Calls the synchronized initialization logic } return app } - override fun onCreate() { super.onCreate() appInstance = this setUnprotectedInstance(this) - mdmChangeReceiver = MDMSettingsChangedReceiver() val filter = IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED) registerReceiver(mdmChangeReceiver, filter) - createNotificationChannel( STATUS_CHANNEL_ID, getString(R.string.vpn_status), @@ -129,7 +113,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { getString(R.string.health_channel_description), NotificationManagerCompat.IMPORTANCE_HIGH) } - override fun onTerminate() { super.onTerminate() Notifier.stop() @@ -138,19 +121,15 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { viewModelStore.clear() unregisterReceiver(mdmChangeReceiver) } - @Volatile private var isInitialized = false - @Synchronized private fun initOnce() { if (isInitialized) { return } - initializeApp() isInitialized = true } - private fun initializeApp() { // Check if a directory URI has already been stored. val storedUri = getStoredDirectoryUri() @@ -166,7 +145,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { applicationScope.launch { val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager MDMSettings.update(get(), rm) - Notifier.state.collect { _ -> combine(Notifier.state, MDMSettings.forceEnabled.flow, Notifier.prefs, Notifier.netmap) { state, @@ -184,11 +162,9 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { if (state == Ipn.State.Stopped) { notifyStatus(vpnRunning = false, hideDisconnectAction = hideDisconnectAction.value) } - val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running updateConnStatus(ableToStartVPN) QuickToggleService.setVPNRunning(vpnRunning) - // Update notification status when VPN is running if (vpnRunning) { notifyStatus( @@ -205,21 +181,22 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { TSLog.init(this) FeatureFlags.initialize(mapOf("enable_new_search" to true)) } - /** * Called when a SAF directory URI is available (either already stored or chosen). We must restart * Tailscale because directFileRoot must be set before LocalBackend starts being used. */ fun startLibtailscale(directFileRoot: String) { - ShareFileHelper.init(this, directFileRoot) app = Libtailscale.start(this.filesDir.absolutePath, directFileRoot, this) + ShareFileHelper.init(this, app, directFileRoot, applicationScope) Request.setApp(app) Notifier.setApp(app) Notifier.start(applicationScope) } private fun initViewModels() { - vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java) + appViewModel = + ViewModelProvider(this, AppViewModelFactory(this, ShareFileHelper.observeTaildropPrompt())) + .get(AppViewModel::class.java) } fun setWantRunning(wantRunning: Boolean, onSuccess: (() -> Unit)? = null) { @@ -233,14 +210,12 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { Client(applicationScope) .editPrefs(Ipn.MaskedPrefs().apply { WantRunning = wantRunning }, callback) } - // encryptToPref a byte array of data using the Jetpack Security // library and writes it to a global encrypted preference store. @Throws(IOException::class, GeneralSecurityException::class) override fun encryptToPref(prefKey: String?, plaintext: String?) { getEncryptedPrefs().edit().putString(prefKey, plaintext).commit() } - // decryptFromPref decrypts a encrypted preference using the Jetpack Security // library and returns the plaintext. @Throws(IOException::class, GeneralSecurityException::class) @@ -250,18 +225,18 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { override fun getStateStoreKeysJSON(): String { val prefix = "statestore-" - val keys = getEncryptedPrefs() - .getAll() - .keys - .filter { it.startsWith(prefix) } - .map { it.removePrefix(prefix) } + val keys = + getEncryptedPrefs() + .getAll() + .keys + .filter { it.startsWith(prefix) } + .map { it.removePrefix(prefix) } return org.json.JSONArray(keys).toString() } @Throws(IOException::class, GeneralSecurityException::class) fun getEncryptedPrefs(): SharedPreferences { val key = MasterKey.Builder(this).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() - return EncryptedSharedPreferences.create( this, "secret_shared_prefs", @@ -269,12 +244,10 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM) } - fun getStoredDirectoryUri(): Uri? { val uriString = getEncryptedPrefs().getString(PREF_KEY_SAF_URI, null) return uriString?.let { Uri.parse(it) } } - /* * 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 @@ -285,7 +258,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { QuickToggleService.updateTile() TSLog.d("App", "Set Tile Ready: $ableToStartVPN") } - override fun getModelName(): String { val manu = Build.MANUFACTURER var model = Build.MODEL @@ -296,17 +268,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { } return "$manu $model" } - override fun getOSVersion(): String = Build.VERSION.RELEASE - override fun isChromeOS(): Boolean { return packageManager.hasSystemFeature("android.hardware.type.pc") } - override fun getInterfacesAsString(): String { val interfaces: ArrayList = java.util.Collections.list(NetworkInterface.getNetworkInterfaces()) - val sb = StringBuilder() for (nif in interfaces) { try { @@ -322,7 +290,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { nif.isLoopback, nif.isPointToPoint, nif.supportsMulticast())) - for (ia in nif.interfaceAddresses) { val parts = ia.toString().split("/", limit = 0) if (parts.size > 1) { @@ -334,16 +301,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { } sb.append("\n") } - return sb.toString() } - @Throws( IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) override fun getSyspolicyBooleanValue(key: String): Boolean { return getSyspolicyStringValue(key) == "true" } - @Throws( IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) override fun getSyspolicyStringValue(key: String): String { @@ -353,7 +317,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { } return setting.value?.toString() ?: "" } - @Throws( IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) override fun getSyspolicyStringArrayJSONValue(key: String): String { @@ -369,12 +332,10 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { throw MDMSettings.NoSuchKeyException() } } - fun notifyPolicyChanged() { app.notifyPolicyChanged() } } - /** * UninitializedApp contains all of the methods of App that can be used without having to initialize * the Go backend. This is useful when you want to access functions on the App without creating side @@ -383,30 +344,24 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { open class UninitializedApp : Application() { companion object { const val TAG = "UninitializedApp" - const val STATUS_NOTIFICATION_ID = 1 const val STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID = 2 const val STATUS_CHANNEL_ID = "tailscale-status" - // Key for shared preference that tracks whether or not we're able to start // the VPN (i.e. we're logged in and machine is authorized). private const val ABLE_TO_START_VPN_KEY = "ableToStartVPN" - private const val DISALLOWED_APPS_KEY = "disallowedApps" - // File for shared preferences that are not encrypted. private const val UNENCRYPTED_PREFERENCES = "unencrypted" - private lateinit var appInstance: UninitializedApp lateinit var notificationManager: NotificationManagerCompat - lateinit var vpnViewModel: VpnViewModel + lateinit var appViewModel: AppViewModel @JvmStatic fun get(): UninitializedApp { return appInstance } - /** * Return the name of the active (but not the selected/prior one) exit node based on the * provided [Ipn.Prefs] and [Netmap.NetworkMap]. @@ -419,24 +374,19 @@ open class UninitializedApp : Application() { } } } - protected fun setUnprotectedInstance(instance: UninitializedApp) { appInstance = instance } - protected fun setAbleToStartVPN(rdy: Boolean) { getUnencryptedPrefs().edit().putBoolean(ABLE_TO_START_VPN_KEY, rdy).apply() } - /** This function can be called without initializing the App. */ fun isAbleToStartVPN(): Boolean { return getUnencryptedPrefs().getBoolean(ABLE_TO_START_VPN_KEY, false) } - private fun getUnencryptedPrefs(): SharedPreferences { return getSharedPreferences(UNENCRYPTED_PREFERENCES, MODE_PRIVATE) } - fun startVPN() { val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_START_VPN } // FLAG_UPDATE_CURRENT ensures that if the intent is already pending, the existing intent will @@ -449,7 +399,6 @@ open class UninitializedApp : Application() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE // FLAG_IMMUTABLE for Android 12+ ) - try { pendingIntent.send() } catch (foregroundServiceStartException: IllegalStateException) { @@ -462,7 +411,6 @@ open class UninitializedApp : Application() { TSLog.e(TAG, "startVPN hit exception: $e") } } - fun stopVPN() { val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_STOP_VPN } try { @@ -473,7 +421,6 @@ open class UninitializedApp : Application() { TSLog.e(TAG, "stopVPN hit exception in startService(): $e") } } - fun restartVPN() { val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_RESTART_VPN } @@ -485,14 +432,12 @@ open class UninitializedApp : Application() { TSLog.e(TAG, "restartVPN hit exception in startService(): $e") } } - fun createNotificationChannel(id: String, name: String, description: String, importance: Int) { val channel = NotificationChannel(id, name, importance) channel.description = description notificationManager = NotificationManagerCompat.from(this) notificationManager.createNotificationChannel(channel) } - fun notifyStatus( vpnRunning: Boolean, hideDisconnectAction: Boolean, @@ -500,7 +445,6 @@ open class UninitializedApp : Application() { ) { notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction, exitNodeName)) } - fun notifyStatus(notification: Notification) { if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { @@ -515,7 +459,6 @@ open class UninitializedApp : Application() { } notificationManager.notify(STATUS_NOTIFICATION_ID, notification) } - fun buildStatusNotification( vpnRunning: Boolean, hideDisconnectAction: Boolean, @@ -537,7 +480,6 @@ open class UninitializedApp : Application() { 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 @@ -545,7 +487,6 @@ open class UninitializedApp : Application() { val pendingIntent: PendingIntent = PendingIntent.getActivity( this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - val builder = NotificationCompat.Builder(this, STATUS_CHANNEL_ID) .setSmallIcon(icon) @@ -563,18 +504,14 @@ open class UninitializedApp : Application() { } return builder.build() } - fun updateUserDisallowedPackageNames(packageNames: List) { if (packageNames.any { it.isEmpty() }) { TSLog.e(TAG, "updateUserDisallowedPackageNames called with empty packageName(s)") return } - getUnencryptedPrefs().edit().putStringSet(DISALLOWED_APPS_KEY, packageNames.toSet()).apply() - this.restartVPN() } - fun disallowedPackageNames(): List { val mdmDisallowed = MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() @@ -587,8 +524,8 @@ open class UninitializedApp : Application() { return builtInDisallowedPackageNames + userDisallowed } - fun getAppScopedViewModel(): VpnViewModel { - return vpnViewModel + fun getAppScopedViewModel(): AppViewModel { + return appViewModel } val builtInDisallowedPackageNames: List = @@ -616,4 +553,4 @@ open class UninitializedApp : Application() { // Android Connectivity Service https://github.com/tailscale/tailscale/issues/14128 "com.google.android.apps.scone", ) -} +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index dbcbc3f..28ed413 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -34,10 +34,18 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally +import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.LaunchedEffect 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.res.stringResource import androidx.core.net.toUri import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.ViewModelProvider @@ -75,39 +83,38 @@ import com.tailscale.ipn.ui.view.MullvadInfoView import com.tailscale.ipn.ui.view.NotificationsView import com.tailscale.ipn.ui.view.PeerDetails import com.tailscale.ipn.ui.view.PermissionsView +import com.tailscale.ipn.ui.view.PrimaryActionButton import com.tailscale.ipn.ui.view.RunExitNodeView import com.tailscale.ipn.ui.view.SearchView import com.tailscale.ipn.ui.view.SettingsView import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView import com.tailscale.ipn.ui.view.SubnetRoutingView import com.tailscale.ipn.ui.view.TaildropDirView +import com.tailscale.ipn.ui.view.TaildropDirectoryPickerPrompt 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.AppViewModel import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModelFactory import com.tailscale.ipn.ui.viewModel.PermissionsViewModel import com.tailscale.ipn.ui.viewModel.PingViewModel import com.tailscale.ipn.ui.viewModel.SettingsNav -import com.tailscale.ipn.ui.viewModel.VpnViewModel +import com.tailscale.ipn.util.ShareFileHelper import com.tailscale.ipn.util.TSLog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import libtailscale.Libtailscale class MainActivity : ComponentActivity() { private lateinit var navController: NavHostController private lateinit var vpnPermissionLauncher: ActivityResultLauncher - private val viewModel: MainViewModel by lazy { - val app = App.get() - vpnViewModel = app.getAppScopedViewModel() - ViewModelProvider(this, MainViewModelFactory(vpnViewModel)).get(MainViewModel::class.java) - } - private lateinit var vpnViewModel: VpnViewModel + private lateinit var appViewModel: AppViewModel + private lateinit var viewModel: MainViewModel + val permissionsViewModel: PermissionsViewModel by viewModels() companion object { @@ -119,7 +126,6 @@ class MainActivity : ComponentActivity() { return (resources.configuration.screenLayout and SCREENLAYOUT_SIZE_MASK) >= SCREENLAYOUT_SIZE_LARGE } - // The loginQRCode is used to track whether or not we should be rendering a QR code // to the user. This is used only on TV platforms with no browser in lieu of // simply opening the URL. This should be consumed once it has been handled. @@ -132,29 +138,27 @@ class MainActivity : ComponentActivity() { // grab app to make sure it initializes App.get() - vpnViewModel = ViewModelProvider(App.get()).get(VpnViewModel::class.java) + appViewModel = (application as App).getAppScopedViewModel() + viewModel = + ViewModelProvider(this, MainViewModelFactory(appViewModel)).get(MainViewModel::class.java) val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager MDMSettings.update(App.get(), rm) - if (MDMSettings.onboardingFlow.flow.value.value == ShowHide.Hide || MDMSettings.authKey.flow.value.value != null) { setIntroScreenViewed(true) } - // (jonathan) TODO: Force the app to be portrait on small screens until we have // proper landscape layout support if (!isLandscapeCapable()) { requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT } - installSplashScreen() - vpnPermissionLauncher = registerForActivityResult(VpnPermissionContract()) { granted -> if (granted) { TSLog.d("VpnPermission", "VPN permission granted") - vpnViewModel.setVpnPrepared(true) + appViewModel.setVpnPrepared(true) App.get().startVPN() } else { if (isAnotherVpnActive(this)) { @@ -162,7 +166,7 @@ class MainActivity : ComponentActivity() { showOtherVPNConflictDialog() } else { TSLog.d("VpnPermission", "Permission was denied by the user") - vpnViewModel.setVpnPrepared(false) + appViewModel.setVpnPrepared(false) AlertDialog.Builder(this) .setTitle(R.string.vpn_permission_needed) @@ -176,7 +180,6 @@ class MainActivity : ComponentActivity() { } } viewModel.setVpnPermissionLauncher(vpnPermissionLauncher) - val directoryPickerLauncher = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? -> if (uri != null) { @@ -188,7 +191,6 @@ class MainActivity : ComponentActivity() { } catch (e: SecurityException) { TSLog.e("MainActivity", "Failed to persist permissions: $e") } - // Check if write permission is actually granted. val writePermission = this.checkUriPermission( @@ -198,9 +200,10 @@ class MainActivity : ComponentActivity() { lifecycleScope.launch(Dispatchers.IO) { try { - Libtailscale.setDirectFileRoot(uri.toString()) TaildropDirectoryStore.saveFileDirectory(uri) permissionsViewModel.refreshCurrentDir() + ShareFileHelper.notifyDirectoryReady() + ShareFileHelper.setUri(uri.toString()) } catch (e: Exception) { TSLog.e("MainActivity", "Failed to set Taildrop root: $e") } @@ -214,14 +217,40 @@ class MainActivity : ComponentActivity() { } else { TSLog.d( "MainActivity", "Taildrop directory not saved. Will fall back to internal storage.") - // Fall back to internal storage. } } - viewModel.setDirectoryPickerLauncher(directoryPickerLauncher) + appViewModel.directoryPickerLauncher = directoryPickerLauncher setContent { + var showDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { appViewModel.triggerDirectoryPicker.collect { showDialog = true } } + + if (showDialog) { + AppTheme { + AlertDialog( + onDismissRequest = { + showDialog = false + appViewModel.directoryPickerLauncher?.launch(null) + }, + title = { + Text(text = stringResource(id = R.string.taildrop_directory_picker_title)) + }, + text = { TaildropDirectoryPickerPrompt() }, + confirmButton = { + PrimaryActionButton( + onClick = { + showDialog = false + appViewModel.directoryPickerLauncher?.launch(null) + }) { + Text(text = stringResource(id = R.string.taildrop_directory_picker_button)) + } + }) + } + } + navController = rememberNavController() AppTheme { @@ -257,7 +286,6 @@ class MainActivity : ComponentActivity() { fun backTo(route: String): () -> Unit = { navController.popBackStack(route = route, inclusive = false) } - val mainViewNav = MainViewNavigation( onNavigateToSettings = { navController.navigate("settings") }, @@ -270,7 +298,6 @@ class MainActivity : ComponentActivity() { viewModel.enableSearchAutoFocus() navController.navigate("search") }) - val settingsNav = SettingsNav( onNavigateToBugReport = { navController.navigate("bugReport") }, @@ -285,7 +312,6 @@ class MainActivity : ComponentActivity() { onNavigateToPermissions = { navController.navigate("permissions") }, onBackToSettings = backTo("settings"), onNavigateBackHome = backTo("main")) - val exitNodePickerNav = ExitNodePickerNav( onNavigateBackHome = { @@ -297,7 +323,6 @@ class MainActivity : ComponentActivity() { onNavigateBackToMullvad = backTo("mullvad"), onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") }, onNavigateToRunAsExitNode = { navController.navigate("runExitNode") }) - val userSwitcherNav = UserSwitcherNav( backToSettings = backTo("settings"), @@ -308,7 +333,11 @@ class MainActivity : ComponentActivity() { onNavigateToAuthKey = { navController.navigate("loginWithAuthKey") }) composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) { - MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel) + MainView( + loginAtUrl = ::login, + navigation = mainViewNav, + viewModel = viewModel, + appViewModel = appViewModel) } composable("search") { val autoFocus = viewModel.autoFocusSearch @@ -318,7 +347,9 @@ class MainActivity : ComponentActivity() { onNavigateBack = { navController.popBackStack() }, autoFocus = autoFocus) } - composable("settings") { SettingsView(settingsNav) } + composable("settings") { + SettingsView(settingsNav = settingsNav, appViewModel = appViewModel) + } composable("exitNodes") { ExitNodePicker(exitNodePickerNav) } composable("health") { HealthView(backTo("main")) } composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) } @@ -378,7 +409,6 @@ class MainActivity : ComponentActivity() { } } } - // Login actions are app wide. If we are told about a browse-to-url, we should render it // over whatever screen we happen to be on. loginQRCode.collectAsState().value?.let { @@ -401,7 +431,6 @@ class MainActivity : ComponentActivity() { } } } - // Once we see a loginFinished event, clear the QR code which will dismiss the QR dialog. lifecycleScope.launch { Notifier.loginFinished.collect { _ -> loginQRCode.set(null) } } } @@ -422,7 +451,6 @@ class MainActivity : ComponentActivity() { fun isAnotherVpnActive(context: Context): Boolean { val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val activeNetwork = connectivityManager.activeNetwork if (activeNetwork != null) { val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) @@ -433,7 +461,6 @@ class MainActivity : ComponentActivity() { } return false } - // Returns true if we should render a QR code instead of launching a browser // for login requests private fun useQRCodeLogin(): Boolean { @@ -449,7 +476,6 @@ class MainActivity : ComponentActivity() { if (this::navController.isInitialized) { val previousEntry = navController.previousBackStackEntry TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry") - if (previousEntry != null) { navController.popBackStack(route = "main", inclusive = false) } else { @@ -478,7 +504,6 @@ class MainActivity : ComponentActivity() { putExtra(START_AT_ROOT, true) } startActivity(intent) - // Cancel coroutine once we've logged in this@launch.cancel() } @@ -487,7 +512,6 @@ class MainActivity : ComponentActivity() { TSLog.e(TAG, "Login: failed to start MainActivity: $e") } } - val url = urlString.toUri() try { val customTabsIntent = CustomTabsIntent.Builder().build() diff --git a/android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt b/android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt index be02c95..c168d7d 100644 --- a/android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt +++ b/android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt @@ -16,14 +16,6 @@ object TaildropDirectoryStore { fun saveFileDirectory(directoryUri: Uri) { val prefs = App.get().getEncryptedPrefs() prefs.edit().putString(PREF_KEY_SAF_URI, directoryUri.toString()).commit() - try { - // Must restart Tailscale because a new LocalBackend with the new directory must be created. - App.get().startLibtailscale(directoryUri.toString()) - } catch (e: Exception) { - TSLog.d( - "TaildropDirectoryStore", - "saveFileDirectory: Failed to restart Libtailscale with the new directory: $e") - } } @Throws(IOException::class, GeneralSecurityException::class) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index 0a848a4..9b14cd9 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -1,6 +1,5 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause - package com.tailscale.ipn.ui.view import android.os.Build @@ -32,7 +31,6 @@ import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -87,7 +85,6 @@ 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.AppTheme import com.tailscale.ipn.ui.theme.customErrorContainer import com.tailscale.ipn.ui.theme.disabled import com.tailscale.ipn.ui.theme.errorButton @@ -109,10 +106,11 @@ import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.util.set +import com.tailscale.ipn.ui.viewModel.AppViewModel import com.tailscale.ipn.ui.viewModel.IpnViewModel.NodeState import com.tailscale.ipn.ui.viewModel.MainViewModel -import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.util.FeatureFlags +import kotlinx.coroutines.flow.emptyFlow // Navigation actions for the MainView data class MainViewNavigation( @@ -129,6 +127,7 @@ fun MainView( loginAtUrl: (String) -> Unit, navigation: MainViewNavigation, viewModel: MainViewModel, + appViewModel: AppViewModel ) { val currentPingDevice by viewModel.pingViewModel.peer.collectAsState() val healthIcon by viewModel.healthIcon.collectAsState() @@ -151,12 +150,9 @@ fun MainView( val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState() val disableToggle by MDMSettings.forceEnabled.flow.collectAsState() val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false) - val showDirectoryPickerInterstitial by - viewModel.showDirectoryPickerInterstitial.collectAsState() // Hide the header only on Android TV when the user needs to login val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin) - ListItem( colors = MaterialTheme.colorScheme.surfaceContainerListItem, leadingContent = { @@ -212,30 +208,19 @@ fun MainView( } } }) - when (state) { Ipn.State.Running -> { viewModel.maybeRequestVpnPermission() LaunchVpnPermissionIfNeeded(viewModel) PromptForMissingPermissions(viewModel) - if (!viewModel.skipPromptsForAuthKeyLogin()) { - LaunchedEffect(state) { - if (state == Ipn.State.Running && !isAndroidTV()) { - viewModel.checkIfTaildropDirectorySelected() - } - } - } - if (showKeyExpiry) { ExpiryNotification(netmap = netmap, action = { viewModel.login() }) } - if (showExitNodePicker.value == ShowHide.Show) { ExitNodeStatus( navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) } - PeerList( viewModel = viewModel, onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, @@ -259,25 +244,6 @@ fun MainView( { viewModel.showVPNPermissionLauncherIfUnauthorized() }) } } - - showDirectoryPickerInterstitial.let { show -> - if (show) { - AppTheme { - AlertDialog( - onDismissRequest = { viewModel.showDirectoryPickerLauncher() }, - title = { - Text(text = stringResource(id = R.string.taildrop_directory_picker_title)) - }, - text = { TaildropDirectoryPickerPrompt() }, - confirmButton = { - PrimaryActionButton(onClick = { viewModel.showDirectoryPickerLauncher() }) { - Text( - text = stringResource(id = R.string.taildrop_directory_picker_button)) - } - }) - } - } - } } currentPingDevice?.let { _ -> ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) { @@ -291,7 +257,6 @@ fun MainView( @Composable fun TaildropDirectoryPickerPrompt() { val uriHandler = LocalUriHandler.current - Column(verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.Start) { Text(text = stringResource(id = R.string.taildrop_directory_picker_body)) Text( @@ -306,10 +271,8 @@ fun TaildropDirectoryPickerPrompt() { fun LaunchVpnPermissionIfNeeded(viewModel: MainViewModel) { val lifecycleOwner = LocalLifecycleOwner.current val shouldRequest by viewModel.requestVpnPermission.collectAsState() - LaunchedEffect(shouldRequest) { if (!shouldRequest) return@LaunchedEffect - // Defer showing permission launcher until activity is resumed to avoid silent RESULT_CANCELED lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { viewModel.showVPNPermissionLauncherIfUnauthorized() @@ -322,19 +285,14 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { val nodeState by viewModel.nodeState.collectAsState() val maybePrefs by viewModel.prefs.collectAsState() val netmap by viewModel.netmap.collectAsState() - // There's nothing to render if we haven't loaded the prefs yet val prefs = maybePrefs ?: return - // The activeExitNode is the source of truth. The selectedExitNode is only relevant if we // don't have an active node. val chosenExitNodeId = prefs.activeExitNodeID ?: prefs.selectedExitNodeID - val exitNodePeer = chosenExitNodeId?.let { id -> netmap?.Peers?.find { it.StableID == id } } val name = exitNodePeer?.exitNodeName - val managedByOrganization by viewModel.managedByOrganization.collectAsState() - Box( modifier = Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surfaceContainer)) { @@ -359,7 +317,6 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { } } } - Box( modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp) @@ -597,7 +554,6 @@ fun PeerList( val searchTermStr by viewModel.searchTerm.collectAsState(initial = "") val showNoResults = remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value - val netmap = viewModel.netmap.collectAsState() val focusManager = LocalFocusManager.current var isSearchFocussed by remember { mutableStateOf(false) } @@ -606,7 +562,6 @@ fun PeerList( val localClipboardManager = LocalClipboardManager.current // Restrict search to devices running API 33+ (see https://github.com/tailscale/corp/issues/27375) val enableSearch = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU - Column(modifier = Modifier.fillMaxSize()) { if (enableSearch && FeatureFlags.isEnabled("enable_new_search")) { Search(onSearchBarClick) @@ -653,7 +608,6 @@ fun PeerList( } } } - // Peers display LazyColumn( modifier = @@ -661,7 +615,6 @@ fun PeerList( .weight(1f) // LazyColumn gets the remaining vertical space .onFocusChanged { isListFocussed = it.isFocused } .background(color = MaterialTheme.colorScheme.surface)) { - // Handle case when no results are found if (showNoResults) { item { @@ -677,7 +630,6 @@ fun PeerList( fontWeight = FontWeight.Light) } } - // Iterate over peer sets to display them var first = true peerList.forEach { peerSet -> @@ -685,13 +637,11 @@ fun PeerList( item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() } } first = false - if (isAndroidTV()) { item { NodesSectionHeader(peerSet = peerSet) } } else { stickyHeader { NodesSectionHeader(peerSet = peerSet) } } - itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer -> ListItem( modifier = @@ -758,7 +708,6 @@ fun PeerList( @Composable fun NodesSectionHeader(peerSet: PeerSet) { Spacer(Modifier.height(16.dp).fillMaxSize().background(color = MaterialTheme.colorScheme.surface)) - Lists.LargeTitle( peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user), bottomPadding = 8.dp, @@ -770,7 +719,6 @@ fun NodesSectionHeader(peerSet: PeerSet) { @Composable fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) { if (netmap == null) return - Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) { Box( modifier = @@ -801,7 +749,6 @@ fun PromptForMissingPermissions(viewModel: MainViewModel) { if (viewModel.skipPromptsForAuthKeyLogin()) { return } - Permissions.prompt.forEach { (permission, state) -> ErrorDialog( title = permission.title, @@ -820,7 +767,6 @@ fun Search( ) { // Prevent multiple taps var isNavigating by remember { mutableStateOf(false) } - Box( modifier = Modifier.fillMaxWidth() @@ -851,7 +797,6 @@ fun Search( Modifier.padding(start = 0.dp) // Optional start padding for alignment ) Spacer(modifier = Modifier.width(4.dp)) - // Placeholder Text Text( text = stringResource(R.string.search_ellipsis), @@ -869,9 +814,9 @@ fun Search( @Preview @Composable fun MainViewPreview() { - val vpnViewModel = VpnViewModel(App.get()) - val vm = MainViewModel(vpnViewModel) - + val fakePrompt = emptyFlow() + val appViewModel = AppViewModel(App.get(), fakePrompt) + val vm = MainViewModel(appViewModel) MainView( {}, MainViewNavigation( @@ -880,5 +825,6 @@ fun MainViewPreview() { onNavigateToExitNodes = {}, onNavigateToHealth = {}, onNavigateToSearch = {}), - vm) + vm, + appViewModel) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt index e29e988..c98f18c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt @@ -40,13 +40,13 @@ import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsViewModel -import com.tailscale.ipn.ui.viewModel.VpnViewModel +import com.tailscale.ipn.ui.viewModel.AppViewModel @Composable fun SettingsView( settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel(), - vpnViewModel: VpnViewModel = viewModel() + appViewModel: AppViewModel = viewModel() ) { val handler = LocalUriHandler.current @@ -55,7 +55,7 @@ fun SettingsView( val managedByOrganization by viewModel.managedByOrganization.collectAsState() val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState() val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState() - val isVPNPrepared by vpnViewModel.vpnPrepared.collectAsState() + val isVPNPrepared by appViewModel.vpnPrepared.collectAsState() val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState() val useTailscaleSubnets by MDMSettings.useTailscaleSubnets.flow.collectAsState() diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/AppViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/AppViewModel.kt new file mode 100644 index 0000000..685c50d --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/AppViewModel.kt @@ -0,0 +1,119 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.viewModel + +import android.app.Application +import android.net.Uri +import android.net.VpnService +import android.util.Log +import androidx.activity.result.ActivityResultLauncher +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.tailscale.ipn.App +import com.tailscale.ipn.util.ShareFileHelper +import com.tailscale.ipn.util.TSLog +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class AppViewModelFactory(val application: Application, private val taildropPrompt: Flow) : + ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(AppViewModel::class.java)) { + return AppViewModel(application, taildropPrompt) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} + +// Application context-aware ViewModel used to track app-wide VPN and Taildrop state. +// This must be application-scoped because Tailscale may be enabled, disabled, or used for +// file transfers (Taildrop) outside the activity lifecycle. +// +// Responsibilities: +// - Track VPN preparation state (e.g., whether permission has been granted) and activity state +// - Monitor incoming Taildrop file transfers +// - Coordinate prompts for Taildrop directory selection if not yet configured +class AppViewModel(application: Application, private val taildropPrompt: Flow) : + AndroidViewModel(application) { + // Whether the VPN is prepared. This is set to true if the VPN application is already prepared, or + // if the user has previously consented to the VPN application. This is used to determine whether + // a VPN permission launcher needs to be shown. + val _vpnPrepared = MutableStateFlow(false) + val vpnPrepared: StateFlow = _vpnPrepared + // Whether a VPN interface has been established. This is set by net.updateTUN upon + // VpnServiceBuilder.establish, and consumed by UI to reflect VPN state. + val _vpnActive = MutableStateFlow(false) + val vpnActive: StateFlow = _vpnActive + // Select Taildrop directory + var directoryPickerLauncher: ActivityResultLauncher? = null + private val _triggerDirectoryPicker = MutableSharedFlow(extraBufferCapacity = 1) + val triggerDirectoryPicker: SharedFlow = _triggerDirectoryPicker + val TAG = "AppViewModel" + + init { + observeIncomingTaildrop() + prepareVpn() + } + + private fun observeIncomingTaildrop() { + viewModelScope.launch { + taildropPrompt.collect { + TSLog.d(TAG, "Taildrop event received, checking directory") + checkIfTaildropDirectorySelected() + } + } + } + + fun requestDirectoryPicker() { + _triggerDirectoryPicker.tryEmit(Unit) + } + + private fun prepareVpn() { + // Check if the user has granted permission yet. + if (!vpnPrepared.value) { + val vpnIntent = VpnService.prepare(getApplication()) + if (vpnIntent != null) { + setVpnPrepared(false) + Log.d(TAG, "VpnService.prepare returned non-null intent") + } else { + setVpnPrepared(true) + Log.d(TAG, "VpnService.prepare returned null intent, VPN is already prepared") + } + } + } + + fun checkIfTaildropDirectorySelected() { + val app = App.get() + val storedUri = app.getStoredDirectoryUri() + if (ShareFileHelper.hasValidTaildropDir()) { + return + } + + val documentFile = storedUri?.let { DocumentFile.fromTreeUri(app, it) } + if (documentFile == null || !documentFile.exists() || !documentFile.canWrite()) { + TSLog.d( + "MainViewModel", + "Stored directory URI is invalid or inaccessible; launching directory picker.") + viewModelScope.launch { requestDirectoryPicker() } + } else { + TSLog.d("MainViewModel", "Using stored directory URI: $storedUri") + } + } + + fun setVpnActive(isActive: Boolean) { + _vpnActive.value = isActive + } + + fun setVpnPrepared(isPrepared: Boolean) { + _vpnPrepared.value = isPrepared + } +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index edb41eb..7becdeb 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -1,8 +1,6 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause - package com.tailscale.ipn.ui.viewModel - import android.content.Intent import android.net.Uri import android.net.VpnService @@ -39,112 +37,89 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch import java.time.Duration -class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelProvider.Factory { +class MainViewModelFactory(private val appViewModel: AppViewModel) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(MainViewModel::class.java)) { - return MainViewModel(vpnViewModel) as T + return MainViewModel(appViewModel) as T } throw IllegalArgumentException("Unknown ViewModel class") } } @OptIn(FlowPreview::class) -class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { +class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { // The user readable state of the system val stateRes: StateFlow = MutableStateFlow(userStringRes(State.NoState, State.NoState, true)) - // The expected state of the VPN toggle private val _vpnToggleState = MutableStateFlow(false) val vpnToggleState: StateFlow = _vpnToggleState - // Keeps track of whether a toggle operation is in progress. This ensures that toggleVpn cannot be // invoked until the current operation is complete. var isToggleInProgress = MutableStateFlow(false) - // Permission to prepare VPN private var vpnPermissionLauncher: ActivityResultLauncher? = null private val _requestVpnPermission = MutableStateFlow(false) val requestVpnPermission: StateFlow = _requestVpnPermission - // Select Taildrop directory private var directoryPickerLauncher: ActivityResultLauncher? = null - private val _showDirectoryPickerInterstitial = MutableStateFlow(false) - val showDirectoryPickerInterstitial: StateFlow = _showDirectoryPickerInterstitial - // The list of peers private val _peers = MutableStateFlow>(emptyList()) val peers: StateFlow> = _peers - // The list of peers private val _searchViewPeers = MutableStateFlow>(emptyList()) val searchViewPeers: StateFlow> = _searchViewPeers - // The current state of the IPN for determining view visibility val ipnState = Notifier.state - // The active search term for filtering peers private val _searchTerm = MutableStateFlow("") val searchTerm: StateFlow = _searchTerm - var autoFocusSearch by mutableStateOf(true) private set - // True if we should render the key expiry bannder val showExpiry: StateFlow = MutableStateFlow(false) - // The peer for which the dropdown menu is currently expanded. Null if no menu is expanded var expandedMenuPeer: StateFlow = MutableStateFlow(null) var pingViewModel: PingViewModel = PingViewModel() - val isVpnPrepared: StateFlow = vpnViewModel.vpnPrepared + val isVpnPrepared: StateFlow = appViewModel.vpnPrepared - val isVpnActive: StateFlow = vpnViewModel.vpnActive + val isVpnActive: StateFlow = appViewModel.vpnActive var searchJob: Job? = null // Icon displayed in the button to present the health view val healthIcon: StateFlow = MutableStateFlow(null) - fun updateSearchTerm(term: String) { _searchTerm.value = term } - fun hidePeerDropdownMenu() { expandedMenuPeer.set(null) } - fun copyIpAddress(peer: Tailcfg.Node, clipboardManager: ClipboardManager) { clipboardManager.setText(AnnotatedString(peer.primaryIPv4Address ?: "")) } - fun startPing(peer: Tailcfg.Node) { this.pingViewModel.startPing(peer) } - fun onPingDismissal() { this.pingViewModel.handleDismissal() } - // Returns true if we should skip all of the user-interactive permissions prompts // (with the exception of the VPN permission prompt) fun skipPromptsForAuthKeyLogin(): Boolean { val v = MDMSettings.authKey.flow.value.value return v != null && v != "" } - private val peerCategorizer = PeerCategorizer() - init { viewModelScope.launch { var previousState: State? = null - combine(Notifier.state, isVpnActive) { state, active -> state to active } .collect { (currentState, active) -> // Determine the correct state resource string stateRes.set(userStringRes(currentState, previousState, active)) - // Determine if the VPN toggle should be on val isOn = when { @@ -153,15 +128,12 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { previousState == State.NoState && currentState == State.Starting -> true else -> false } - // Update the VPN toggle state _vpnToggleState.value = isOn - // Update the previous state previousState = currentState } } - viewModelScope.launch { _searchTerm.debounce(250L).collect { term -> // run the search as a background task @@ -173,7 +145,6 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } } } - viewModelScope.launch { Notifier.netmap.collect { it -> it?.let { netmap -> @@ -184,7 +155,6 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { _peers.value = peerCategorizer.peerSets _searchViewPeers.value = filteredPeers } - if (netmap.SelfNode.keyDoesNotExpire) { showExpiry.set(false) return@let @@ -199,57 +169,25 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } } } - viewModelScope.launch { App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) } } } - fun maybeRequestVpnPermission() { _requestVpnPermission.value = true } - fun showVPNPermissionLauncherIfUnauthorized() { val vpnIntent = VpnService.prepare(App.get()) TSLog.d("VpnPermissions", "vpnIntent=$vpnIntent") if (vpnIntent != null) { vpnPermissionLauncher?.launch(vpnIntent) } else { - vpnViewModel.setVpnPrepared(true) + appViewModel.setVpnPrepared(true) startVPN() } _requestVpnPermission.value = false // reset } - fun showDirectoryPickerLauncher() { - _showDirectoryPickerInterstitial.set(false) - directoryPickerLauncher?.launch(null) - } - - fun checkIfTaildropDirectorySelected() { - if (skipPromptsForAuthKeyLogin() || AndroidTVUtil.isAndroidTV()) { - return - } - - val app = App.get() - val storedUri = app.getStoredDirectoryUri() - if (storedUri == null) { - // No stored URI, so launch the directory picker. - _showDirectoryPickerInterstitial.set(true) - return - } - - val documentFile = DocumentFile.fromTreeUri(app, storedUri) - if (documentFile == null || !documentFile.exists() || !documentFile.canWrite()) { - TSLog.d( - "MainViewModel", - "Stored directory URI is invalid or inaccessible; launching directory picker.") - _showDirectoryPickerInterstitial.set(true) - } else { - TSLog.d("MainViewModel", "Using stored directory URI: $storedUri") - } - } - fun toggleVpn(desiredState: Boolean) { if (isToggleInProgress.value) { // Prevent toggling while a previous toggle is in progress @@ -257,16 +195,13 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } viewModelScope.launch { - checkIfTaildropDirectorySelected() isToggleInProgress.value = true try { val currentState = Notifier.state.value - val isPrepared = vpnViewModel.vpnPrepared.value if (desiredState) { // User wants to turn ON the VPN when { - !isPrepared -> showVPNPermissionLauncherIfUnauthorized() currentState != Ipn.State.Running -> startVPN() } } else { @@ -280,27 +215,19 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } } } - fun searchPeers(searchTerm: String) { this.searchTerm.set(searchTerm) } - fun enableSearchAutoFocus() { autoFocusSearch = true } - fun disableSearchAutoFocus() { autoFocusSearch = false } - fun setVpnPermissionLauncher(launcher: ActivityResultLauncher) { // No intent means we're already authorized vpnPermissionLauncher = launcher } - - fun setDirectoryPickerLauncher(launcher: ActivityResultLauncher) { - directoryPickerLauncher = launcher - } } private fun userStringRes(currentState: State?, previousState: State?, vpnActive: Boolean): Int { @@ -316,4 +243,4 @@ private fun userStringRes(currentState: State?, previousState: State?, vpnActive currentState == State.Running -> if (vpnActive) R.string.connected else R.string.placeholder else -> R.string.placeholder } -} +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt index a15636b..e467389 100644 --- a/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt +++ b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt @@ -1,15 +1,20 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause - package com.tailscale.ipn.util - import android.content.Context import android.net.Uri import android.os.ParcelFileDescriptor import android.provider.DocumentsContract import androidx.documentfile.provider.DocumentFile +import com.tailscale.ipn.TaildropDirectoryStore import com.tailscale.ipn.ui.util.InputStreamAdapter import com.tailscale.ipn.ui.util.OutputStreamAdapter +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import libtailscale.Libtailscale import org.json.JSONObject import java.io.FileOutputStream @@ -17,38 +22,80 @@ import java.io.IOException import java.io.OutputStream import java.util.UUID import java.util.concurrent.ConcurrentHashMap - data class SafFile(val fd: Int, val uri: String) object ShareFileHelper : libtailscale.ShareFileHelper { private var appContext: Context? = null + private var app: libtailscale.Application? = null private var savedUri: String? = null + private var scope: CoroutineScope? = null @JvmStatic - fun init(context: Context, uri: String) { + fun init(context: Context, app: libtailscale.Application, uri: String, appScope: CoroutineScope) { appContext = context.applicationContext + this.app = app savedUri = uri + scope = appScope Libtailscale.setShareFileHelper(this) + TSLog.d("ShareFileHelper", "init ShareFileHelper with savedUri: $savedUri") } // A simple data class that holds a SAF OutputStream along with its URI. data class SafStream(val uri: String, val stream: OutputStream) + val taildropPrompt = MutableSharedFlow(replay = 1) + + fun observeTaildropPrompt(): Flow = taildropPrompt + + @Volatile private var directoryReady: CompletableDeferred? = null + + fun hasValidTaildropDir(): Boolean { + val uri = TaildropDirectoryStore.loadSavedDir() + if (uri == null) return false + + // Only SAF tree URIs are supported + if (uri.scheme != "content") { + TSLog.w("ShareFileHelper", "Invalid URI scheme for taildrop dir: ${uri.scheme}") + return false + } + + val context = appContext ?: return false + val docFile = DocumentFile.fromTreeUri(context, uri) + + if (docFile == null || !docFile.exists() || !docFile.canWrite()) { + TSLog.w("ShareFileHelper", "Stored taildrop URI is invalid or inaccessible: $uri") + return false + } + + return true + } + + private suspend fun waitUntilTaildropDirReady() { + if (!hasValidTaildropDir()) { + if (directoryReady?.isActive != true) { + directoryReady = CompletableDeferred() + scope?.launch { taildropPrompt.emit(Unit) } + } + directoryReady?.await() + } + } + + fun notifyDirectoryReady() { + directoryReady?.takeIf { !it.isCompleted }?.complete(Unit) + } + // A helper function that opens or creates a SafStream for a given file. private fun openSafFileOutputStream(fileName: String): Pair { val context = appContext ?: return "" to null val dirUri = savedUri ?: return "" to null val dir = DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) ?: return "" to null - val file = dir.findFile(fileName) ?: dir.createFile("application/octet-stream", fileName) ?: return "" to null - val os = context.contentResolver.openOutputStream(file.uri, "rw") return file.uri.toString() to os } - @Throws(IOException::class) private fun openWriterFD(fileName: String, offset: Long): Pair { val ctx = appContext ?: throw IOException("App context not initialized") @@ -60,20 +107,18 @@ object ShareFileHelper : libtailscale.ShareFileHelper { dir.findFile(fileName) ?: dir.createFile("application/octet-stream", fileName) ?: throw IOException("Failed to create file: $fileName") - val pfd = ctx.contentResolver.openFileDescriptor(file.uri, "rw") ?: throw IOException("Failed to open file descriptor for ${file.uri}") val fos = FileOutputStream(pfd.fileDescriptor) - if (offset != 0L) fos.channel.position(offset) else fos.channel.truncate(0) return file.uri.toString() to SeekableOutputStream(fos, pfd) } - private val currentUri = ConcurrentHashMap() @Throws(IOException::class) override fun openFileWriter(fileName: String, offset: Long): libtailscale.OutputStream { + runBlocking { waitUntilTaildropDirReady() } val (uri, stream) = openWriterFD(fileName, offset) if (stream == null) { throw IOException("Failed to open file writer for $fileName") @@ -84,22 +129,20 @@ object ShareFileHelper : libtailscale.ShareFileHelper { @Throws(IOException::class) override fun getFileURI(fileName: String): String { + runBlocking { waitUntilTaildropDirReady() } currentUri[fileName]?.let { return it } - val ctx = appContext ?: throw IOException("App context not initialized") val dirStr = savedUri ?: throw IOException("No saved directory URI") val dir = DocumentFile.fromTreeUri(ctx, Uri.parse(dirStr)) ?: throw IOException("Invalid tree URI: $dirStr") - val file = dir.findFile(fileName) ?: throw IOException("File not found: $fileName") val uri = file.uri.toString() currentUri[fileName] = uri return uri } - @Throws(IOException::class) override fun renameFile(oldPath: String, targetName: String): String { val ctx = appContext ?: throw IOException("not initialized") @@ -108,7 +151,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper { val dir = DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri)) ?: throw IOException("cannot open dir $dirUri") - + var finalName = targetName dir.findFile(finalName)?.let { existing -> if (lengthOfUri(ctx, existing.uri) == 0L) { @@ -117,7 +160,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper { finalName = generateNewFilename(finalName) } } - + try { DocumentsContract.renameDocument(ctx.contentResolver, srcUri, finalName)?.also { newUri -> runCatching { ctx.contentResolver.delete(srcUri, null, null) } @@ -125,14 +168,14 @@ object ShareFileHelper : libtailscale.ShareFileHelper { return newUri.toString() } } catch (e: Exception) { - TSLog.w("renameFile", "renameDocument fallback triggered for $srcUri -> $finalName: ${e.message}") - + TSLog.w( + "renameFile", "renameDocument fallback triggered for $srcUri -> $finalName: ${e.message}") } - + val dest = dir.createFile("application/octet-stream", finalName) ?: throw IOException("createFile failed for $finalName") - + ctx.contentResolver.openInputStream(srcUri).use { inp -> ctx.contentResolver.openOutputStream(dest.uri, "w").use { out -> if (inp == null || out == null) { @@ -142,15 +185,13 @@ object ShareFileHelper : libtailscale.ShareFileHelper { inp.copyTo(out) } } - + ctx.contentResolver.delete(srcUri, null, null) cleanupPartials(dir, targetName) return dest.uri.toString() } - private fun lengthOfUri(ctx: Context, uri: Uri): Long = ctx.contentResolver.openAssetFileDescriptor(uri, "r").use { it?.length ?: -1 } - // delete any stray “.partial” files for this base name private fun cleanupPartials(dir: DocumentFile, base: String) { for (child in dir.listFiles()) { @@ -160,21 +201,18 @@ object ShareFileHelper : libtailscale.ShareFileHelper { } } } - @Throws(IOException::class) override fun deleteFile(uri: String) { + runBlocking { waitUntilTaildropDirReady() } val ctx = appContext ?: throw IOException("DeleteFile: not initialized") - val uri = Uri.parse(uri) val doc = DocumentFile.fromSingleUri(ctx, uri) ?: throw IOException("DeleteFile: cannot resolve URI $uri") - if (!doc.delete()) { throw IOException("DeleteFile: delete() returned false for $uri") } } - @Throws(IOException::class) override fun getFileInfo(fileName: String): String { val context = appContext ?: throw IOException("app context not initialized") @@ -182,41 +220,32 @@ object ShareFileHelper : libtailscale.ShareFileHelper { val dir = DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) ?: throw IOException("could not resolve SAF root") - val file = dir.findFile(fileName) ?: throw IOException("file \"$fileName\" not found in SAF directory") - val name = file.name ?: throw IOException("file name missing for $fileName") val size = file.length() val modTime = file.lastModified() - return """{"name":${JSONObject.quote(name)},"size":$size,"modTime":$modTime}""" } - private fun jsonEscape(s: String): String { return JSONObject.quote(s) } - fun generateNewFilename(filename: String): String { val dotIndex = filename.lastIndexOf('.') val baseName = if (dotIndex != -1) filename.substring(0, dotIndex) else filename val extension = if (dotIndex != -1) filename.substring(dotIndex) else "" - val uuid = UUID.randomUUID() return "$baseName-$uuid$extension" } - fun listPartialFiles(suffix: String): Array { val context = appContext ?: return emptyArray() val rootUri = savedUri ?: return emptyArray() val dir = DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) ?: return emptyArray() - return dir.listFiles() .filter { it.name?.endsWith(suffix) == true } .mapNotNull { it.name } .toTypedArray() } - @Throws(IOException::class) override fun listFilesJSON(suffix: String): String { val list = listPartialFiles(suffix) @@ -225,7 +254,6 @@ object ShareFileHelper : libtailscale.ShareFileHelper { } return list.joinToString(prefix = "[\"", separator = "\",\"", postfix = "\"]") } - @Throws(IOException::class) override fun openFileReader(name: String): libtailscale.InputStream { val context = appContext ?: throw IOException("app context not initialized") @@ -233,37 +261,32 @@ object ShareFileHelper : libtailscale.ShareFileHelper { val dir = DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) ?: throw IOException("could not open SAF root") - val suffix = name.substringAfterLast('.', ".$name") - val file = dir.listFiles().firstOrNull { val fname = it.name ?: return@firstOrNull false fname.endsWith(suffix, ignoreCase = false) } ?: throw IOException("no file ending with \"$suffix\" in SAF directory") - val inStream = context.contentResolver.openInputStream(file.uri) ?: throw IOException("openInputStream returned null for ${file.uri}") - return InputStreamAdapter(inStream) } + fun setUri(uri: String) { + savedUri = uri + } + private class SeekableOutputStream( private val fos: FileOutputStream, private val pfd: ParcelFileDescriptor ) : OutputStream() { - private var closed = false - override fun write(b: Int) = fos.write(b) - override fun write(b: ByteArray) = fos.write(b) - override fun write(b: ByteArray, off: Int, len: Int) { fos.write(b, off, len) } - override fun close() { if (!closed) { closed = true @@ -276,7 +299,6 @@ object ShareFileHelper : libtailscale.ShareFileHelper { } } } - override fun flush() = fos.flush() } -} +} \ No newline at end of file diff --git a/libtailscale/backend.go b/libtailscale/backend.go index bb1704d..b052693 100644 --- a/libtailscale/backend.go +++ b/libtailscale/backend.go @@ -58,8 +58,6 @@ type App struct { backend *ipnlocal.LocalBackend ready sync.WaitGroup backendMu sync.Mutex - - backendRestartCh chan struct{} } func start(dataDir, directFileRoot string, appCtx AppContext) Application { @@ -114,23 +112,6 @@ type backend struct { type settingsFunc func(*router.Config, *dns.OSConfig) error func (a *App) runBackend(ctx context.Context) error { - for { - err := a.runBackendOnce(ctx) - if err != nil { - log.Printf("runBackendOnce error: %v", err) - } - - // Wait for a restart trigger - <-a.backendRestartCh - } -} - -func (a *App) runBackendOnce(ctx context.Context) error { - select { - case <-a.backendRestartCh: - default: - } - paths.AppSharedDir.Store(a.dataDir) hostinfo.SetOSVersion(a.osVersion()) hostinfo.SetPackage(a.appCtx.GetInstallSource()) @@ -338,7 +319,6 @@ func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore, lb, err := ipnlocal.NewLocalBackend(logf, logID.Public(), sys, 0) if ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb); ok { ext.SetFileOps(newAndroidFileOps(a.shareFileHelper)) - ext.SetDirectFileRoot(a.directFileRoot) } if err != nil { @@ -368,14 +348,9 @@ func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore, func (a *App) watchFileOpsChanges() { for { select { - case newPath := <-onFilePath: - log.Printf("Got new directFileRoot") - a.directFileRoot = newPath - a.backendRestartCh <- struct{}{} case helper := <-onShareFileHelper: - log.Printf("Got shareFIleHelper") + log.Printf("Got ShareFileHelper") a.shareFileHelper = helper - a.backendRestartCh <- struct{}{} } } } diff --git a/libtailscale/callbacks.go b/libtailscale/callbacks.go index 3e1a88f..9daec5c 100644 --- a/libtailscale/callbacks.go +++ b/libtailscale/callbacks.go @@ -26,9 +26,6 @@ var ( // onShareFileHelper receives ShareFileHelper references when the app is initialized so that files can be received via Storage Access Framework onShareFileHelper = make(chan ShareFileHelper, 1) - - // onFilePath receives the SAF path used for Taildrop - onFilePath = make(chan string) ) // ifname is the interface name retrieved from LinkProperties on network change. An empty string is used if there is no network available. diff --git a/libtailscale/interfaces.go b/libtailscale/interfaces.go index 67a108c..ca13070 100644 --- a/libtailscale/interfaces.go +++ b/libtailscale/interfaces.go @@ -243,7 +243,3 @@ func SetShareFileHelper(fileHelper ShareFileHelper) { onShareFileHelper <- fileHelper } } - -func SetDirectFileRoot(filePath string) { - onFilePath <- filePath -} diff --git a/libtailscale/tailscale.go b/libtailscale/tailscale.go index 3a785fa..ecbe0df 100644 --- a/libtailscale/tailscale.go +++ b/libtailscale/tailscale.go @@ -32,10 +32,9 @@ const ( func newApp(dataDir, directFileRoot string, appCtx AppContext) Application { a := &App{ - directFileRoot: directFileRoot, - dataDir: dataDir, - appCtx: appCtx, - backendRestartCh: make(chan struct{}, 1), + directFileRoot: directFileRoot, + dataDir: dataDir, + appCtx: appCtx, } a.ready.Add(2) From cc2f6386a64eb55303187de9b0362187f1bc3596 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Thu, 7 Aug 2025 14:02:23 -0700 Subject: [PATCH 36/49] android: update target API to 35 (#688) Fixes tailscale/corp#31101 Signed-off-by: kari-ts --- android/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/build.gradle b/android/build.gradle index d7b8ae5..5079097 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -36,7 +36,7 @@ android { compileSdkVersion 34 defaultConfig { minSdkVersion 26 - targetSdkVersion 34 + targetSdkVersion 35 versionCode 356 versionName getVersionProperty("VERSION_LONG") testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" From f3467251fe05aa42cb30b30f02f698c6dcf55a7b Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Tue, 12 Aug 2025 15:52:19 -0400 Subject: [PATCH 37/49] Makefile: add gomobile bind ldflags for 16Kb page support (#689) Adds ldflags to support 16kb pages sizes for NDK 23. Signed-off-by: Jonathan Nobels --- Makefile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index ea93133..685ea19 100644 --- a/Makefile +++ b/Makefile @@ -181,9 +181,11 @@ build-unstripped-aar: tailscale.version $(GOBIN)/gomobile @echo "Output file: $(ABS_UNSTRIPPED_AAR)" mkdir -p $(dir $(ABS_UNSTRIPPED_AAR)) rm -f $(ABS_UNSTRIPPED_AAR) + # The -linkmode=external -extldflags=-Wl,-z,max-page-size=16384 is specific to NDK 23 + # to support 16kb page sizes. Your mileage may vary with other NDK versions. $(GOBIN)/gomobile bind -target android -androidapi 26 \ -tags "$$(./build-tags.sh)" \ - -ldflags "$$(./version-ldflags.sh)" \ + -ldflags "-linkmode=external -extldflags=-Wl,-z,max-page-size=16384 $$(./version-ldflags.sh)" \ -o $(ABS_UNSTRIPPED_AAR) ./libtailscale || { echo "gomobile bind failed"; exit 1; } @if [ ! -f $(ABS_UNSTRIPPED_AAR) ]; then \ echo "Error: $(ABS_UNSTRIPPED_AAR) was not created"; exit 1; \ From 9b07f33d7758162faf8d51253d6ffd764998f756 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Thu, 14 Aug 2025 13:27:38 -0700 Subject: [PATCH 38/49] Makefile, go.mod: bump oss, adjust how we do so Signed-off-by: Brad Fitzpatrick --- Makefile | 4 ++-- go.mod | 6 +++--- go.sum | 8 ++++---- go.toolchain.rev | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Makefile b/Makefile index 685ea19..273f419 100644 --- a/Makefile +++ b/Makefile @@ -279,10 +279,10 @@ bump_version_code: .PHONY: update-oss ## Update the tailscale.com go module update-oss: + curl -f https://raw.githubusercontent.com/tailscale/tailscale/refs/heads/main/go.toolchain.rev > go.toolchain.rev.new + mv go.toolchain.rev.new go.toolchain.rev GOPROXY=direct ./tool/go get tailscale.com@main ./tool/go mod tidy -compat=1.24 - ./tool/go run tailscale.com/cmd/printdep --go > go.toolchain.rev.new - mv go.toolchain.rev.new go.toolchain.rev # Get the commandline tools package, this provides (among other things) the sdkmanager binary. $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager: diff --git a/go.mod b/go.mod index 05da9e2..f62b5ae 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module github.com/tailscale/tailscale-android -go 1.24.4 +go 1.24.6 require ( github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab - tailscale.com v1.87.0-pre.0.20250801224156-0f15e4419683 + tailscale.com v1.87.0-pre.0.20250814174806-c083a9b05330 ) require ( @@ -68,7 +68,7 @@ require ( github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect - github.com/vishvananda/netns v0.0.4 // indirect + github.com/vishvananda/netns v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect diff --git a/go.sum b/go.sum index 11b45be..58d4d79 100644 --- a/go.sum +++ b/go.sum @@ -174,8 +174,8 @@ github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1 github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= -github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= -github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= @@ -235,5 +235,5 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.87.0-pre.0.20250801224156-0f15e4419683 h1:meEUX1Nsr5SaXiaeivOGG4c7gsQm/P3Jr3dzbtE0j6k= -tailscale.com v1.87.0-pre.0.20250801224156-0f15e4419683/go.mod h1:Lm8dnzU2i/Emw15r6sl3FRNp/liSQ/nYw6ZSQvIdZ1M= \ No newline at end of file +tailscale.com v1.87.0-pre.0.20250814174806-c083a9b05330 h1:AKtuZB1fqLwc7gjGtTpFRTDBFyIV+H4nqm3S3saKGus= +tailscale.com v1.87.0-pre.0.20250814174806-c083a9b05330/go.mod h1:7T150+pAJhl3Yw2RTBMihVa9D2wdGJ+kxVQQSJbLHkc= diff --git a/go.toolchain.rev b/go.toolchain.rev index 33aa564..6e3bd7f 100644 --- a/go.toolchain.rev +++ b/go.toolchain.rev @@ -1 +1 @@ -1cd3bf1a6eaf559aa8c00e749289559c884cef09 +54f31cd8fc7b3d7d87c1ea455c8bb4b33372f706 From d2c005f71424f0b69409df1d6a54649a19604948 Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Fri, 29 Aug 2025 09:49:42 -0400 Subject: [PATCH 39/49] android: bump OSS (#693) OSS and Version updated to 1.87.107-t3aea0e095-g9b07f33d7 Signed-off-by: Jonathan Nobels --- go.mod | 6 +++--- go.sum | 8 ++++---- go.toolchain.rev | 2 +- libtailscale/backend.go | 9 ++++++++- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index f62b5ae..209a9f5 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module github.com/tailscale/tailscale-android -go 1.24.6 +go 1.25.0 require ( github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab - tailscale.com v1.87.0-pre.0.20250814174806-c083a9b05330 + tailscale.com v1.87.0-pre.0.20250829053524-3aea0e095a41 ) require ( @@ -33,7 +33,7 @@ require ( github.com/djherbis/times v1.6.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/gaissmai/bart v0.18.0 // indirect - github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 // indirect + github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect diff --git a/go.sum b/go.sum index 58d4d79..deb1327 100644 --- a/go.sum +++ b/go.sum @@ -67,8 +67,8 @@ github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo= github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY= github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= -github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 h1:F8d1AJ6M9UQCavhwmO6ZsrYLfG8zVFWfEfMS2MXPkSY= -github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= +github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I= +github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= @@ -235,5 +235,5 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.87.0-pre.0.20250814174806-c083a9b05330 h1:AKtuZB1fqLwc7gjGtTpFRTDBFyIV+H4nqm3S3saKGus= -tailscale.com v1.87.0-pre.0.20250814174806-c083a9b05330/go.mod h1:7T150+pAJhl3Yw2RTBMihVa9D2wdGJ+kxVQQSJbLHkc= +tailscale.com v1.87.0-pre.0.20250829053524-3aea0e095a41 h1:PP6OuLqQ/r/G7MtgKXDxL4NwQLUR+dqWPWM5y7r0SZY= +tailscale.com v1.87.0-pre.0.20250829053524-3aea0e095a41/go.mod h1:ZcA4K9Xppr41mrKznPIxR2eRjVQf8g8v9nB/YBKTe10= diff --git a/go.toolchain.rev b/go.toolchain.rev index 6e3bd7f..9c2417e 100644 --- a/go.toolchain.rev +++ b/go.toolchain.rev @@ -1 +1 @@ -54f31cd8fc7b3d7d87c1ea455c8bb4b33372f706 +f3339c88ea24212cc3cd49b64ad1045b85db23bf diff --git a/libtailscale/backend.go b/libtailscale/backend.go index b052693..e7a592a 100644 --- a/libtailscale/backend.go +++ b/libtailscale/backend.go @@ -141,7 +141,14 @@ func (a *App) runBackend(ctx context.Context) error { a.backend = b.backend defer b.CloseTUNs() - h := localapi.NewHandler(ipnauth.Self, b.backend, log.Printf, *a.logIDPublicAtomic.Load()) + hc := localapi.HandlerConfig{ + Actor: ipnauth.Self, + Backend: b.backend, + Logf: log.Printf, + LogID: *a.logIDPublicAtomic.Load(), + EventBus: b.bus, + } + h := localapi.NewHandler(hc) h.PermitRead = true h.PermitWrite = true a.localAPIHandler = h From 53b746220b7c7feee6d6ec4703253faaaeeeb1a9 Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Wed, 3 Sep 2025 13:48:17 -0400 Subject: [PATCH 40/49] android: bump OSS (#698) OSS and Version updated to 1.87.131-tc9f214e50-gd2c005f71 Signed-off-by: Jonathan Nobels --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 209a9f5..960bbcc 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.0 require ( github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab - tailscale.com v1.87.0-pre.0.20250829053524-3aea0e095a41 + tailscale.com v1.87.0-pre.0.20250903124732-c9f214e503af ) require ( diff --git a/go.sum b/go.sum index deb1327..40d4c13 100644 --- a/go.sum +++ b/go.sum @@ -235,5 +235,5 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.87.0-pre.0.20250829053524-3aea0e095a41 h1:PP6OuLqQ/r/G7MtgKXDxL4NwQLUR+dqWPWM5y7r0SZY= -tailscale.com v1.87.0-pre.0.20250829053524-3aea0e095a41/go.mod h1:ZcA4K9Xppr41mrKznPIxR2eRjVQf8g8v9nB/YBKTe10= +tailscale.com v1.87.0-pre.0.20250903124732-c9f214e503af h1:Yy+2ebGeLueaiCOZcgWX5RVJ31KmCE7p/RLRDCr5TFg= +tailscale.com v1.87.0-pre.0.20250903124732-c9f214e503af/go.mod h1:seqDfrozU4su6MCw/6RqcZ9MHXb6vltC5NIhuQhBmpc= From 981f5e8770f347d5f2431e6e9af27565e42801ab Mon Sep 17 00:00:00 2001 From: Michael Nahkies Date: Wed, 3 Sep 2025 11:00:31 -0700 Subject: [PATCH 41/49] all: add Makefile fmt and fmt-check targets, format all source code Signed-off-by: Michael Nahkies --- Makefile | 8 ++++ .../src/main/java/com/tailscale/ipn/App.kt | 46 +++++++++++++++++-- .../main/java/com/tailscale/ipn/IPNService.kt | 4 +- .../java/com/tailscale/ipn/MainActivity.kt | 2 +- .../java/com/tailscale/ipn/mdm/MDMSettings.kt | 3 +- .../com/tailscale/ipn/ui/localapi/Client.kt | 6 +-- .../java/com/tailscale/ipn/ui/model/Ipn.kt | 2 +- .../java/com/tailscale/ipn/ui/model/NetMap.kt | 2 +- .../com/tailscale/ipn/ui/model/TailCfg.kt | 2 +- .../ipn/ui/util/OutputStreamAdapter.kt | 1 - .../com/tailscale/ipn/ui/view/CustomLogin.kt | 3 +- .../com/tailscale/ipn/ui/view/SettingsView.kt | 2 +- .../tailscale/ipn/ui/view/UserSwitcherView.kt | 2 +- .../ipn/ui/viewModel/AppViewModel.kt | 2 +- .../ipn/ui/viewModel/IpnViewModel.kt | 6 ++- .../ipn/ui/viewModel/MainViewModel.kt | 20 ++++++-- .../com/tailscale/ipn/util/ShareFileHelper.kt | 30 +++++++++--- 17 files changed, 108 insertions(+), 33 deletions(-) diff --git a/Makefile b/Makefile index 273f419..39be7ad 100644 --- a/Makefile +++ b/Makefile @@ -316,6 +316,14 @@ checkandroidsdk: ## Check that Android SDK is installed test: gradle-dependencies ## Run the Android tests (cd android && ./gradlew test) +.PHONY: fmt +fmt: gradle-dependencies ## Format the Android code + (cd android && ./gradlew ktfmtFormat) + +.PHONY: fmt-check +fmt-check: gradle-dependencies ## Check the Android code is formatted + (cd android && ./gradlew ktfmtCheck) + .PHONY: emulator emulator: ## Start an android emulator instance @echo "Checking installed SDK packages..." diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index 7e4e514..f89821e 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -1,6 +1,7 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause package com.tailscale.ipn + import android.Manifest import android.app.Application import android.app.Notification @@ -37,6 +38,10 @@ import com.tailscale.ipn.ui.viewModel.AppViewModelFactory import com.tailscale.ipn.util.FeatureFlags import com.tailscale.ipn.util.ShareFileHelper import com.tailscale.ipn.util.TSLog +import java.io.IOException +import java.net.NetworkInterface +import java.security.GeneralSecurityException +import java.util.Locale import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -48,12 +53,10 @@ import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import libtailscale.Libtailscale -import java.io.IOException -import java.net.NetworkInterface -import java.security.GeneralSecurityException -import java.util.Locale + class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + companion object { private const val FILE_CHANNEL_ID = "tailscale-files" // Key to store the SAF URI in EncryptedSharedPreferences. @@ -70,26 +73,34 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { return appInstance } } + val dns = DnsConfig() private lateinit var connectivityManager: ConnectivityManager private lateinit var mdmChangeReceiver: MDMSettingsChangedReceiver private lateinit var app: libtailscale.Application override val viewModelStore: ViewModelStore get() = appViewModelStore + private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() } var healthNotifier: HealthNotifier? = null + override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString + override fun getInstallSource(): String = AppSourceChecker.getInstallSource(this) + override fun shouldUseGoogleDNSFallback(): Boolean = BuildConfig.USE_GOOGLE_DNS_FALLBACK + override fun log(s: String, s1: String) { Log.d(s, s1) } + fun getLibtailscaleApp(): libtailscale.Application { if (!isInitialized) { initOnce() // Calls the synchronized initialization logic } return app } + override fun onCreate() { super.onCreate() appInstance = this @@ -113,6 +124,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { getString(R.string.health_channel_description), NotificationManagerCompat.IMPORTANCE_HIGH) } + override fun onTerminate() { super.onTerminate() Notifier.stop() @@ -121,7 +133,9 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { viewModelStore.clear() unregisterReceiver(mdmChangeReceiver) } + @Volatile private var isInitialized = false + @Synchronized private fun initOnce() { if (isInitialized) { @@ -130,6 +144,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { initializeApp() isInitialized = true } + private fun initializeApp() { // Check if a directory URI has already been stored. val storedUri = getStoredDirectoryUri() @@ -244,6 +259,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM) } + fun getStoredDirectoryUri(): Uri? { val uriString = getEncryptedPrefs().getString(PREF_KEY_SAF_URI, null) return uriString?.let { Uri.parse(it) } @@ -258,6 +274,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { QuickToggleService.updateTile() TSLog.d("App", "Set Tile Ready: $ableToStartVPN") } + override fun getModelName(): String { val manu = Build.MANUFACTURER var model = Build.MODEL @@ -268,10 +285,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { } return "$manu $model" } + override fun getOSVersion(): String = Build.VERSION.RELEASE + override fun isChromeOS(): Boolean { return packageManager.hasSystemFeature("android.hardware.type.pc") } + override fun getInterfacesAsString(): String { val interfaces: ArrayList = java.util.Collections.list(NetworkInterface.getNetworkInterfaces()) @@ -303,11 +323,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { } return sb.toString() } + @Throws( IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) override fun getSyspolicyBooleanValue(key: String): Boolean { return getSyspolicyStringValue(key) == "true" } + @Throws( IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) override fun getSyspolicyStringValue(key: String): String { @@ -317,6 +339,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { } return setting.value?.toString() ?: "" } + @Throws( IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) override fun getSyspolicyStringArrayJSONValue(key: String): String { @@ -332,6 +355,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { throw MDMSettings.NoSuchKeyException() } } + fun notifyPolicyChanged() { app.notifyPolicyChanged() } @@ -374,9 +398,11 @@ open class UninitializedApp : Application() { } } } + protected fun setUnprotectedInstance(instance: UninitializedApp) { appInstance = instance } + protected fun setAbleToStartVPN(rdy: Boolean) { getUnencryptedPrefs().edit().putBoolean(ABLE_TO_START_VPN_KEY, rdy).apply() } @@ -384,9 +410,11 @@ open class UninitializedApp : Application() { fun isAbleToStartVPN(): Boolean { return getUnencryptedPrefs().getBoolean(ABLE_TO_START_VPN_KEY, false) } + private fun getUnencryptedPrefs(): SharedPreferences { return getSharedPreferences(UNENCRYPTED_PREFERENCES, MODE_PRIVATE) } + fun startVPN() { val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_START_VPN } // FLAG_UPDATE_CURRENT ensures that if the intent is already pending, the existing intent will @@ -411,6 +439,7 @@ open class UninitializedApp : Application() { TSLog.e(TAG, "startVPN hit exception: $e") } } + fun stopVPN() { val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_STOP_VPN } try { @@ -421,6 +450,7 @@ open class UninitializedApp : Application() { TSLog.e(TAG, "stopVPN hit exception in startService(): $e") } } + fun restartVPN() { val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_RESTART_VPN } @@ -432,12 +462,14 @@ open class UninitializedApp : Application() { TSLog.e(TAG, "restartVPN hit exception in startService(): $e") } } + fun createNotificationChannel(id: String, name: String, description: String, importance: Int) { val channel = NotificationChannel(id, name, importance) channel.description = description notificationManager = NotificationManagerCompat.from(this) notificationManager.createNotificationChannel(channel) } + fun notifyStatus( vpnRunning: Boolean, hideDisconnectAction: Boolean, @@ -445,6 +477,7 @@ open class UninitializedApp : Application() { ) { notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction, exitNodeName)) } + fun notifyStatus(notification: Notification) { if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { @@ -459,6 +492,7 @@ open class UninitializedApp : Application() { } notificationManager.notify(STATUS_NOTIFICATION_ID, notification) } + fun buildStatusNotification( vpnRunning: Boolean, hideDisconnectAction: Boolean, @@ -504,6 +538,7 @@ open class UninitializedApp : Application() { } return builder.build() } + fun updateUserDisallowedPackageNames(packageNames: List) { if (packageNames.any { it.isEmpty() }) { TSLog.e(TAG, "updateUserDisallowedPackageNames called with empty packageName(s)") @@ -512,6 +547,7 @@ open class UninitializedApp : Application() { getUnencryptedPrefs().edit().putStringSet(DISALLOWED_APPS_KEY, packageNames.toSet()).apply() this.restartVPN() } + fun disallowedPackageNames(): List { val mdmDisallowed = MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() @@ -553,4 +589,4 @@ open class UninitializedApp : Application() { // Android Connectivity Service https://github.com/tailscale/tailscale/issues/14128 "com.google.android.apps.scone", ) -} \ No newline at end of file +} diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.kt b/android/src/main/java/com/tailscale/ipn/IPNService.kt index e861d9c..e6eb995 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNService.kt +++ b/android/src/main/java/com/tailscale/ipn/IPNService.kt @@ -12,12 +12,12 @@ import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.util.TSLog +import java.util.UUID import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import libtailscale.Libtailscale -import java.util.UUID open class IPNService : VpnService(), libtailscale.IPNService { private val TAG = "IPNService" @@ -47,7 +47,7 @@ open class IPNService : VpnService(), libtailscale.IPNService { START_NOT_STICKY } ACTION_RESTART_VPN -> { - app.setWantRunning(false){ + app.setWantRunning(false) { close() app.startVPN() } diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 28ed413..2de4873 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -224,7 +224,7 @@ class MainActivity : ComponentActivity() { appViewModel.directoryPickerLauncher = directoryPickerLauncher setContent { - var showDialog by remember { mutableStateOf(false) } + var showDialog by remember { mutableStateOf(false) } LaunchedEffect(Unit) { appViewModel.triggerDirectoryPicker.collect { showDialog = true } } diff --git a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt index d8df61d..34b341f 100644 --- a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt +++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettings.kt @@ -61,7 +61,8 @@ object MDMSettings { // Handled on the backend val deviceSerialNumber = - StringMDMSetting("DeviceSerialNumber", "Serial number of the device that is running Tailscale") + StringMDMSetting( + "DeviceSerialNumber", "Serial number of the device that is running Tailscale") val useTailscaleDNSSettings = AlwaysNeverUserDecidesMDMSetting("UseTailscaleDNSSettings", "Use Tailscale DNS Settings") diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt index 2a30db4..5b38b6a 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt @@ -14,6 +14,9 @@ import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.util.InputStreamAdapter import com.tailscale.ipn.util.TSLog +import java.nio.charset.Charset +import kotlin.reflect.KType +import kotlin.reflect.typeOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -23,9 +26,6 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.serializer import libtailscale.FilePart -import java.nio.charset.Charset -import kotlin.reflect.KType -import kotlin.reflect.typeOf private object Endpoint { const val DEBUG = "debug" diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt index a0b5c1b..38acc7f 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt @@ -4,9 +4,9 @@ package com.tailscale.ipn.ui.model import android.net.Uri +import java.util.UUID import kotlinx.serialization.Serializable import kotlinx.serialization.Transient -import java.util.UUID class Ipn { diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt b/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt index f7a7a92..861e64c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt @@ -55,6 +55,6 @@ class Netmap { fun hasCap(capability: String): Boolean { return AllCaps.contains(capability) - } + } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt index 2e9be75..511011c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt @@ -15,9 +15,9 @@ import com.tailscale.ipn.ui.util.DisplayAddress import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.ui.util.flag import com.tailscale.ipn.ui.viewModel.PeerSettingInfo +import java.util.Date import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement -import java.util.Date class Tailcfg { @Serializable diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt b/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt index 2a9c2b2..9e73a42 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/OutputStreamAdapter.kt @@ -24,4 +24,3 @@ class OutputStreamAdapter(private val outputStream: OutputStream) : libtailscale outputStream.close() } } - diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/CustomLogin.kt b/android/src/main/java/com/tailscale/ipn/ui/view/CustomLogin.kt index 83ae0b8..2f8e607 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/CustomLogin.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/CustomLogin.kt @@ -145,8 +145,7 @@ fun LoginView( keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.None, imeAction = ImeAction.Go), - keyboardActions = - KeyboardActions(onGo = { onSubmitAction(textVal) })) + keyboardActions = KeyboardActions(onGo = { onSubmitAction(textVal) })) }) ListItem( diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt index c98f18c..2dc187f 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt @@ -38,9 +38,9 @@ import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV import com.tailscale.ipn.ui.util.AppVersion import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.set +import com.tailscale.ipn.ui.viewModel.AppViewModel import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsViewModel -import com.tailscale.ipn.ui.viewModel.AppViewModel @Composable fun SettingsView( diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt index 8abfc94..64d4bd4 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt @@ -65,7 +65,7 @@ fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = vi val capabilityIsOwner = "https://tailscale.com/cap/is-owner" val isOwner = netmapState?.hasCap(capabilityIsOwner) == true - Scaffold( + Scaffold( topBar = { Header( R.string.accounts, diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/AppViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/AppViewModel.kt index 685c50d..b5913a3 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/AppViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/AppViewModel.kt @@ -116,4 +116,4 @@ class AppViewModel(application: Application, private val taildropPrompt: Flow = MutableStateFlow(null) + fun updateSearchTerm(term: String) { _searchTerm.value = term } + fun hidePeerDropdownMenu() { expandedMenuPeer.set(null) } + fun copyIpAddress(peer: Tailcfg.Node, clipboardManager: ClipboardManager) { clipboardManager.setText(AnnotatedString(peer.primaryIPv4Address ?: "")) } + fun startPing(peer: Tailcfg.Node) { this.pingViewModel.startPing(peer) } + fun onPingDismissal() { this.pingViewModel.handleDismissal() } @@ -112,7 +116,9 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { val v = MDMSettings.authKey.flow.value.value return v != null && v != "" } + private val peerCategorizer = PeerCategorizer() + init { viewModelScope.launch { var previousState: State? = null @@ -173,9 +179,11 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) } } } + fun maybeRequestVpnPermission() { _requestVpnPermission.value = true } + fun showVPNPermissionLauncherIfUnauthorized() { val vpnIntent = VpnService.prepare(App.get()) TSLog.d("VpnPermissions", "vpnIntent=$vpnIntent") @@ -215,15 +223,19 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() { } } } + fun searchPeers(searchTerm: String) { this.searchTerm.set(searchTerm) } + fun enableSearchAutoFocus() { autoFocusSearch = true } + fun disableSearchAutoFocus() { autoFocusSearch = false } + fun setVpnPermissionLauncher(launcher: ActivityResultLauncher) { // No intent means we're already authorized vpnPermissionLauncher = launcher @@ -243,4 +255,4 @@ private fun userStringRes(currentState: State?, previousState: State?, vpnActive currentState == State.Running -> if (vpnActive) R.string.connected else R.string.placeholder else -> R.string.placeholder } -} \ No newline at end of file +} diff --git a/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt index e467389..d14f791 100644 --- a/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt +++ b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt @@ -1,6 +1,7 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause package com.tailscale.ipn.util + import android.content.Context import android.net.Uri import android.os.ParcelFileDescriptor @@ -9,6 +10,11 @@ import androidx.documentfile.provider.DocumentFile import com.tailscale.ipn.TaildropDirectoryStore import com.tailscale.ipn.ui.util.InputStreamAdapter import com.tailscale.ipn.ui.util.OutputStreamAdapter +import java.io.FileOutputStream +import java.io.IOException +import java.io.OutputStream +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow @@ -17,11 +23,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import libtailscale.Libtailscale import org.json.JSONObject -import java.io.FileOutputStream -import java.io.IOException -import java.io.OutputStream -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap + data class SafFile(val fd: Int, val uri: String) object ShareFileHelper : libtailscale.ShareFileHelper { @@ -96,6 +98,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper { val os = context.contentResolver.openOutputStream(file.uri, "rw") return file.uri.toString() to os } + @Throws(IOException::class) private fun openWriterFD(fileName: String, offset: Long): Pair { val ctx = appContext ?: throw IOException("App context not initialized") @@ -114,6 +117,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper { if (offset != 0L) fos.channel.position(offset) else fos.channel.truncate(0) return file.uri.toString() to SeekableOutputStream(fos, pfd) } + private val currentUri = ConcurrentHashMap() @Throws(IOException::class) @@ -143,6 +147,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper { currentUri[fileName] = uri return uri } + @Throws(IOException::class) override fun renameFile(oldPath: String, targetName: String): String { val ctx = appContext ?: throw IOException("not initialized") @@ -190,6 +195,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper { cleanupPartials(dir, targetName) return dest.uri.toString() } + private fun lengthOfUri(ctx: Context, uri: Uri): Long = ctx.contentResolver.openAssetFileDescriptor(uri, "r").use { it?.length ?: -1 } // delete any stray “.partial” files for this base name @@ -201,6 +207,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper { } } } + @Throws(IOException::class) override fun deleteFile(uri: String) { runBlocking { waitUntilTaildropDirReady() } @@ -213,6 +220,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper { throw IOException("DeleteFile: delete() returned false for $uri") } } + @Throws(IOException::class) override fun getFileInfo(fileName: String): String { val context = appContext ?: throw IOException("app context not initialized") @@ -227,9 +235,11 @@ object ShareFileHelper : libtailscale.ShareFileHelper { val modTime = file.lastModified() return """{"name":${JSONObject.quote(name)},"size":$size,"modTime":$modTime}""" } + private fun jsonEscape(s: String): String { return JSONObject.quote(s) } + fun generateNewFilename(filename: String): String { val dotIndex = filename.lastIndexOf('.') val baseName = if (dotIndex != -1) filename.substring(0, dotIndex) else filename @@ -237,6 +247,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper { val uuid = UUID.randomUUID() return "$baseName-$uuid$extension" } + fun listPartialFiles(suffix: String): Array { val context = appContext ?: return emptyArray() val rootUri = savedUri ?: return emptyArray() @@ -246,6 +257,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper { .mapNotNull { it.name } .toTypedArray() } + @Throws(IOException::class) override fun listFilesJSON(suffix: String): String { val list = listPartialFiles(suffix) @@ -254,6 +266,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper { } return list.joinToString(prefix = "[\"", separator = "\",\"", postfix = "\"]") } + @Throws(IOException::class) override fun openFileReader(name: String): libtailscale.InputStream { val context = appContext ?: throw IOException("app context not initialized") @@ -282,11 +295,15 @@ object ShareFileHelper : libtailscale.ShareFileHelper { private val pfd: ParcelFileDescriptor ) : OutputStream() { private var closed = false + override fun write(b: Int) = fos.write(b) + override fun write(b: ByteArray) = fos.write(b) + override fun write(b: ByteArray, off: Int, len: Int) { fos.write(b, off, len) } + override fun close() { if (!closed) { closed = true @@ -299,6 +316,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper { } } } + override fun flush() = fos.flush() } -} \ No newline at end of file +} From 91f82b0732064ce212e8c7cfeb80aa32b72eaebe Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Wed, 3 Sep 2025 14:58:21 -0700 Subject: [PATCH 42/49] libtailscale: use syspolicy RegisterStore rather than deprecated RegisterHandler Updates tailscale/tailscale#17022 Signed-off-by: Brad Fitzpatrick --- libtailscale/backend.go | 2 +- libtailscale/syspolicy_handler.go | 23 ++++++++++++----------- libtailscale/tailscale.go | 7 ++++--- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/libtailscale/backend.go b/libtailscale/backend.go index e7a592a..8503b77 100644 --- a/libtailscale/backend.go +++ b/libtailscale/backend.go @@ -51,7 +51,7 @@ type App struct { appCtx AppContext store *stateStore - policyStore *syspolicyHandler + policyStore *syspolicyStore logIDPublicAtomic atomic.Pointer[logid.PublicID] localAPIHandler http.Handler diff --git a/libtailscale/syspolicy_handler.go b/libtailscale/syspolicy_handler.go index c7fc68c..086258a 100644 --- a/libtailscale/syspolicy_handler.go +++ b/libtailscale/syspolicy_handler.go @@ -10,33 +10,34 @@ import ( "tailscale.com/util/set" "tailscale.com/util/syspolicy" + "tailscale.com/util/syspolicy/pkey" ) -// syspolicyHandler is a syspolicy handler for the Android version of the Tailscale client, +// syspolicyStore is a syspolicy Store for the Android version of the Tailscale client, // which lets the main networking code read values set via the Android RestrictionsManager. -type syspolicyHandler struct { +type syspolicyStore struct { a *App mu sync.RWMutex cbs set.HandleSet[func()] } -func (h *syspolicyHandler) ReadString(key string) (string, error) { +func (h *syspolicyStore) ReadString(key pkey.Key) (string, error) { if key == "" { return "", syspolicy.ErrNoSuchKey } - retVal, err := h.a.appCtx.GetSyspolicyStringValue(key) + retVal, err := h.a.appCtx.GetSyspolicyStringValue(string(key)) return retVal, translateHandlerError(err) } -func (h *syspolicyHandler) ReadBoolean(key string) (bool, error) { +func (h *syspolicyStore) ReadBoolean(key pkey.Key) (bool, error) { if key == "" { return false, syspolicy.ErrNoSuchKey } - retVal, err := h.a.appCtx.GetSyspolicyBooleanValue(key) + retVal, err := h.a.appCtx.GetSyspolicyBooleanValue(string(key)) return retVal, translateHandlerError(err) } -func (h *syspolicyHandler) ReadUInt64(key string) (uint64, error) { +func (h *syspolicyStore) ReadUInt64(key pkey.Key) (uint64, error) { if key == "" { return 0, syspolicy.ErrNoSuchKey } @@ -44,11 +45,11 @@ func (h *syspolicyHandler) ReadUInt64(key string) (uint64, error) { return 0, errors.New("ReadUInt64 is not implemented on Android") } -func (h *syspolicyHandler) ReadStringArray(key string) ([]string, error) { +func (h *syspolicyStore) ReadStringArray(key pkey.Key) ([]string, error) { if key == "" { return nil, syspolicy.ErrNoSuchKey } - retVal, err := h.a.appCtx.GetSyspolicyStringArrayJSONValue(key) + retVal, err := h.a.appCtx.GetSyspolicyStringArrayJSONValue(string(key)) if err := translateHandlerError(err); err != nil { return nil, err } @@ -63,7 +64,7 @@ func (h *syspolicyHandler) ReadStringArray(key string) ([]string, error) { return arr, err } -func (h *syspolicyHandler) RegisterChangeCallback(cb func()) (unregister func(), err error) { +func (h *syspolicyStore) RegisterChangeCallback(cb func()) (unregister func(), err error) { h.mu.Lock() handle := h.cbs.Add(cb) h.mu.Unlock() @@ -74,7 +75,7 @@ func (h *syspolicyHandler) RegisterChangeCallback(cb func()) (unregister func(), }, nil } -func (h *syspolicyHandler) notifyChanged() { +func (h *syspolicyStore) notifyChanged() { h.mu.RLock() for _, cb := range h.cbs { go cb() diff --git a/libtailscale/tailscale.go b/libtailscale/tailscale.go index ecbe0df..c03e6f5 100644 --- a/libtailscale/tailscale.go +++ b/libtailscale/tailscale.go @@ -19,7 +19,8 @@ import ( "tailscale.com/types/logger" "tailscale.com/types/logid" "tailscale.com/util/clientmetric" - "tailscale.com/util/syspolicy" + "tailscale.com/util/syspolicy/rsop" + "tailscale.com/util/syspolicy/setting" ) const defaultMTU = 1280 // minimalMTU from wgengine/userspace.go @@ -39,9 +40,9 @@ func newApp(dataDir, directFileRoot string, appCtx AppContext) Application { a.ready.Add(2) a.store = newStateStore(a.appCtx) - a.policyStore = &syspolicyHandler{a: a} + a.policyStore = &syspolicyStore{a: a} netmon.RegisterInterfaceGetter(a.getInterfaces) - syspolicy.RegisterHandler(a.policyStore) + rsop.RegisterStore("DeviceHandler", setting.DeviceScope, a.policyStore) go a.watchFileOpsChanges() go func() { From b42ab6b58f19560c6421f475cfb0a49ae9539b3b Mon Sep 17 00:00:00 2001 From: James Tucker Date: Wed, 3 Sep 2025 12:14:12 -0700 Subject: [PATCH 43/49] Makefile: use shasum from perl for portability sha256sum is missing on macOS, but shasum appears to be available on both macOS and Ubuntu by default. Updates tailscale/tailscale#17024 Signed-off-by: James Tucker --- Makefile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 39be7ad..b7e969a 100644 --- a/Makefile +++ b/Makefile @@ -65,7 +65,6 @@ $(info Using ANDROID_HOME: $(ANDROID_HOME)) $(info Using NDK_ROOT: $(NDK_ROOT)) $(info Using STRIP_TOOL: $(STRIP_TOOL)) - # Attempt to find Android Studio for Linux configuration, which does not have a # predetermined location. ANDROID_STUDIO_ROOT ?= $(shell find ~/android-studio /usr/local/android-studio /opt/android-studio /Applications/Android\ Studio.app $(PROGRAMFILES)/Android/Android\ Studio -type d -maxdepth 1 2>/dev/null | head -n 1) @@ -290,7 +289,7 @@ $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager: mkdir -p $(ANDROID_HOME)/cmdline-tools (cd $(ANDROID_HOME)/tmp && \ curl --silent -O -L $(ANDROID_TOOLS_URL) && \ - echo $(ANDROID_TOOLS_SUM) | sha256sum -c && \ + echo $(ANDROID_TOOLS_SUM) | shasum -c - && \ unzip $(shell basename $(ANDROID_TOOLS_URL))) mv $(ANDROID_HOME)/tmp/cmdline-tools $(ANDROID_HOME)/cmdline-tools/latest rm -rf $(ANDROID_HOME)/tmp From 0498654ebd2d5d36c7912c0124dbb66a68bf9802 Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Sat, 6 Sep 2025 11:39:25 -0400 Subject: [PATCH 44/49] android: hide placeholder when avatar is loaded (#701) fixes tailscale/corp#32012 Hides the placeholder image once the user's avatar is loaded. Signed-off-by: Jonathan Nobels --- .../java/com/tailscale/ipn/ui/view/Avatar.kt | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt b/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt index 54adf27..e4adf8d 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import coil.annotation.ExperimentalCoilApi import coil.compose.AsyncImage +import coil.compose.AsyncImagePainter import com.tailscale.ipn.R import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.util.AndroidTVUtil @@ -44,6 +45,7 @@ fun Avatar( ) { val isFocused = remember { mutableStateOf(false) } val focusManager = LocalFocusManager.current + val isIconLoaded = remember { mutableStateOf(false) } // Outer Box for the larger focusable and clickable area Box( @@ -73,20 +75,28 @@ fun Avatar( contentAlignment = Alignment.Center, modifier = Modifier.size(size.dp).clip(CircleShape)) { // Always display the default icon as a background layer - Icon( - imageVector = Icons.Default.Person, - contentDescription = stringResource(R.string.settings_title), - modifier = - Modifier.conditional(AndroidTVUtil.isAndroidTV(), { size((size * 0.8f).dp) }) - .clip(CircleShape) // Icon size slightly smaller than the Box - ) + if (!isIconLoaded.value) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = stringResource(R.string.settings_title), + modifier = + Modifier.conditional( + AndroidTVUtil.isAndroidTV(), { size((size * 0.8f).dp) }) + .clip(CircleShape) // Icon size slightly smaller than the Box + ) + } // Overlay the profile picture if available profile?.UserProfile?.ProfilePicURL?.let { url -> AsyncImage( model = url, modifier = Modifier.size(size.dp).clip(CircleShape), - contentDescription = null) + contentDescription = null, + onState = { state -> + if (state is AsyncImagePainter.State.Success) { + isIconLoaded.value = true + } + }) } } } From 6d27f79bf6ed310ec8dc908abf509bf428e70085 Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Mon, 8 Sep 2025 17:02:33 -0400 Subject: [PATCH 45/49] android: bump OSS (#702) OSS and Version updated to 1.87.151-t3e4b0c151-g0498654eb Signed-off-by: Jonathan Nobels --- go.mod | 4 ++-- go.sum | 4 ++-- go.toolchain.rev | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 960bbcc..eac3697 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module github.com/tailscale/tailscale-android -go 1.25.0 +go 1.25.1 require ( github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab - tailscale.com v1.87.0-pre.0.20250903124732-c9f214e503af + tailscale.com v1.87.0-pre.0.20250908195942-3e4b0c151681 ) require ( diff --git a/go.sum b/go.sum index 40d4c13..cd4ae2c 100644 --- a/go.sum +++ b/go.sum @@ -235,5 +235,5 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.87.0-pre.0.20250903124732-c9f214e503af h1:Yy+2ebGeLueaiCOZcgWX5RVJ31KmCE7p/RLRDCr5TFg= -tailscale.com v1.87.0-pre.0.20250903124732-c9f214e503af/go.mod h1:seqDfrozU4su6MCw/6RqcZ9MHXb6vltC5NIhuQhBmpc= +tailscale.com v1.87.0-pre.0.20250908195942-3e4b0c151681 h1:vL6Qx3WW9wQJ6Vx920tLYAOiYnQxVsDTwYn+dT7QsIo= +tailscale.com v1.87.0-pre.0.20250908195942-3e4b0c151681/go.mod h1:LHaTiwRgzebPDLgZ6RQQVzX+1SR5fbNl51fzm7UtMaw= diff --git a/go.toolchain.rev b/go.toolchain.rev index 9c2417e..1fd4f3d 100644 --- a/go.toolchain.rev +++ b/go.toolchain.rev @@ -1 +1 @@ -f3339c88ea24212cc3cd49b64ad1045b85db23bf +aa85d1541af0921f830f053f29d91971fa5838f6 From 7c460a8da294e3733b0c34c471ddc5fc020d02fc Mon Sep 17 00:00:00 2001 From: Nick O'Neill Date: Tue, 9 Sep 2025 10:25:28 -0700 Subject: [PATCH 46/49] android: bump OSS (#704) OSS and Version updated to 1.87.154-t77250a301-g6d27f79bf Signed-off-by: Nick O'Neill --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index eac3697..5500779 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.1 require ( github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da golang.org/x/mobile v0.0.0-20240806205939-81131f6468ab - tailscale.com v1.87.0-pre.0.20250908195942-3e4b0c151681 + tailscale.com v1.87.0-pre.0.20250909160301-77250a301aee ) require ( diff --git a/go.sum b/go.sum index cd4ae2c..508feb2 100644 --- a/go.sum +++ b/go.sum @@ -235,5 +235,5 @@ howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.87.0-pre.0.20250908195942-3e4b0c151681 h1:vL6Qx3WW9wQJ6Vx920tLYAOiYnQxVsDTwYn+dT7QsIo= -tailscale.com v1.87.0-pre.0.20250908195942-3e4b0c151681/go.mod h1:LHaTiwRgzebPDLgZ6RQQVzX+1SR5fbNl51fzm7UtMaw= +tailscale.com v1.87.0-pre.0.20250909160301-77250a301aee h1:BCJ6ux5S7jSv8OkbUHUISyKso+m5VMf9zJ6mQAsBZ+s= +tailscale.com v1.87.0-pre.0.20250909160301-77250a301aee/go.mod h1:LHaTiwRgzebPDLgZ6RQQVzX+1SR5fbNl51fzm7UtMaw= From 0de26e52c0653d06c0cac07a6b1f515ad427552e Mon Sep 17 00:00:00 2001 From: Nick O'Neill Date: Tue, 9 Sep 2025 13:32:19 -0700 Subject: [PATCH 47/49] android: Support tailnet display name, falling back to domain (#703) android: support tailnet display name, falling back to domain Updates https://github.com/tailscale/corp/issues/30456 Signed-off-by: Nick O'Neill --- .../main/java/com/tailscale/ipn/ui/model/TailCfg.kt | 10 +++++++++- .../main/java/com/tailscale/ipn/ui/view/MainView.kt | 4 ++-- .../main/java/com/tailscale/ipn/ui/view/UserView.kt | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt index 511011c..658cb06 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt @@ -188,7 +188,15 @@ class Tailcfg { data class Service(var Proto: String, var Port: Int, var Description: String? = null) @Serializable - data class NetworkProfile(var MagicDNSName: String? = null, var DomainName: String? = null) + data class NetworkProfile( + var MagicDNSName: String? = null, + var DomainName: String? = null, + var DisplayName: String? = null + ) { + fun tailnetNameForDisplay(): String? { + return DisplayName ?: DomainName + } + } @Serializable data class Location( diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index 9b14cd9..6c11cc5 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -167,7 +167,7 @@ fun MainView( } }, headlineContent = { - user?.NetworkProfile?.DomainName?.let { domain -> + user?.NetworkProfile?.tailnetNameForDisplay()?.let { domain -> AutoResizingText( text = domain, style = MaterialTheme.typography.titleMedium.short, @@ -500,7 +500,7 @@ fun ConnectView( fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center, fontFamily = MaterialTheme.typography.titleMedium.fontFamily) - val tailnetName = user.NetworkProfile?.DomainName ?: "" + val tailnetName = user.NetworkProfile?.tailnetNameForDisplay() ?: "" Text( buildAnnotatedString { append(stringResource(id = R.string.connect_to_tailnet_prefix)) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt index 0c2a3dc..10f52d8 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt @@ -63,7 +63,7 @@ fun UserView( supportingContent = { Column { AutoResizingText( - text = profile.NetworkProfile?.DomainName ?: "", + text = profile.NetworkProfile?.tailnetNameForDisplay() ?: "", style = MaterialTheme.typography.bodyMedium.short, minFontSize = MaterialTheme.typography.minTextSize, overflow = TextOverflow.Ellipsis) From 11869b00c56850c9b9c6a6d25794e5ec33484490 Mon Sep 17 00:00:00 2001 From: Andrew Lytvynov Date: Mon, 15 Sep 2025 10:09:34 -0700 Subject: [PATCH 48/49] android,libtailscale: implement key.HardwareAttestationKey (#694) Use a KeyStore-backed key to store a hardware-bound private key. Updates https://github.com/tailscale/tailscale/issues/15830 Signed-off-by: Andrew Lytvynov --- .../src/main/java/com/tailscale/ipn/App.kt | 46 ++++++++- .../tailscale/ipn/util/HardwareKeyStore.kt | 95 +++++++++++++++++++ libtailscale/interfaces.go | 9 ++ libtailscale/keystore.go | 91 ++++++++++++++++++ libtailscale/tailscale.go | 9 ++ 5 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 android/src/main/java/com/tailscale/ipn/util/HardwareKeyStore.kt create mode 100644 libtailscale/keystore.go diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index f89821e..e8851d4 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -36,6 +36,8 @@ import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.viewModel.AppViewModel import com.tailscale.ipn.ui.viewModel.AppViewModelFactory import com.tailscale.ipn.util.FeatureFlags +import com.tailscale.ipn.util.HardwareKeyStore +import com.tailscale.ipn.util.NoSuchKeyException import com.tailscale.ipn.util.ShareFileHelper import com.tailscale.ipn.util.TSLog import java.io.IOException @@ -53,7 +55,7 @@ import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import libtailscale.Libtailscale - +import java.lang.UnsupportedOperationException class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -359,6 +361,48 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { fun notifyPolicyChanged() { app.notifyPolicyChanged() } + + override fun hardwareAttestationKeySupported(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) + } else { + false + } + } + + private lateinit var keyStore: HardwareKeyStore; + + private fun getKeyStore(): HardwareKeyStore { + if (hardwareAttestationKeySupported()) { + return HardwareKeyStore() + } else { + throw UnsupportedOperationException() + } + } + + override fun hardwareAttestationKeyCreate(): String { + return getKeyStore().createKey() + } + + @Throws(NoSuchKeyException::class) + override fun hardwareAttestationKeyRelease(id: String) { + return getKeyStore().releaseKey(id) + } + + @Throws(NoSuchKeyException::class) + override fun hardwareAttestationKeySign(id: String, data: ByteArray): ByteArray { + return getKeyStore().sign(id, data) + } + + @Throws(NoSuchKeyException::class) + override fun hardwareAttestationKeyPublic(id: String): ByteArray { + return getKeyStore().public(id) + } + + @Throws(NoSuchKeyException::class) + override fun hardwareAttestationKeyLoad(id: String) { + return getKeyStore().load(id) + } } /** * UninitializedApp contains all of the methods of App that can be used without having to initialize diff --git a/android/src/main/java/com/tailscale/ipn/util/HardwareKeyStore.kt b/android/src/main/java/com/tailscale/ipn/util/HardwareKeyStore.kt new file mode 100644 index 0000000..24b9d98 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/util/HardwareKeyStore.kt @@ -0,0 +1,95 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause +package com.tailscale.ipn.util + +import android.content.pm.PackageManager +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.Signature +import kotlin.random.Random + +class NoSuchKeyException : Exception("no key found matching the provided ID") +class HardwareKeysNotSupported : Exception("hardware-backed keys are not supported on this device") + +// HardwareKeyStore implements the callbacks necessary to implement key.HardwareAttestationKey on +// the Go side. It uses KeyStore with a StrongBox processor. +class HardwareKeyStore() { + var keyStoreKeys = HashMap(); + val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { + load(null) + } + + @OptIn(ExperimentalStdlibApi::class) + fun newID(): String { + var id: String + do { + id = Random.nextBytes(4).toHexString() + } while (keyStoreKeys.containsKey(id)) + return id + } + + fun createKey(): String { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + throw HardwareKeysNotSupported() + } + val id = newID() + val kpg: KeyPairGenerator = KeyPairGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore" + ) + val parameterSpec: KeyGenParameterSpec = KeyGenParameterSpec.Builder( + id, KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY + ).run { + // Use DIGEST_NONE because hashing is done on the Go side. + setDigests(KeyProperties.DIGEST_NONE) + setIsStrongBoxBacked(true) + build() + } + + kpg.initialize(parameterSpec) + + val kp = kpg.generateKeyPair() + keyStoreKeys[id] = kp + return id + } + + fun releaseKey(id: String) { + keyStoreKeys.remove(id) + } + + fun sign(id: String, data: ByteArray): ByteArray { + val key = keyStoreKeys[id] + if (key == null) { + throw NoSuchKeyException() + } + // Use NONEwithECDSA because hashing is done on the Go side. + return Signature.getInstance("NONEwithECDSA").run { + initSign(key.private) + update(data) + sign() + } + } + + fun public(id: String): ByteArray { + val key = keyStoreKeys[id] + if (key == null) { + throw NoSuchKeyException() + } + return key.public.encoded + } + + fun load(id: String) { + if (keyStoreKeys[id] != null) { + // Already loaded. + return + } + val entry: KeyStore.Entry = keyStore.getEntry(id, null) + if (entry !is KeyStore.PrivateKeyEntry) { + throw NoSuchKeyException() + } + keyStoreKeys[id] = KeyPair(entry.certificate.publicKey, entry.privateKey) + } +} \ No newline at end of file diff --git a/libtailscale/interfaces.go b/libtailscale/interfaces.go index ca13070..14c5694 100644 --- a/libtailscale/interfaces.go +++ b/libtailscale/interfaces.go @@ -65,6 +65,15 @@ type AppContext interface { // GetSyspolicyStringArrayValue returns the current string array value for the given system policy, // expressed as a JSON string. GetSyspolicyStringArrayJSONValue(key string) (string, error) + + // Methods used to implement key.HardwareAttestationKey using the Android + // KeyStore. + HardwareAttestationKeySupported() bool + HardwareAttestationKeyCreate() (id string, err error) + HardwareAttestationKeyRelease(id string) error + HardwareAttestationKeyPublic(id string) (pub []byte, err error) + HardwareAttestationKeySign(id string, data []byte) (sig []byte, err error) + HardwareAttestationKeyLoad(id string) error } // IPNService corresponds to our IPNService in Java. diff --git a/libtailscale/keystore.go b/libtailscale/keystore.go new file mode 100644 index 0000000..20150dc --- /dev/null +++ b/libtailscale/keystore.go @@ -0,0 +1,91 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package libtailscale + +import ( + "crypto" + "crypto/ecdsa" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + + "tailscale.com/types/key" +) + +func emptyHardwareAttestationKey(appCtx AppContext) key.HardwareAttestationKey { + return &hardwareAttestationKey{appCtx: appCtx} +} + +func createHardwareAttestationKey(appCtx AppContext) (key.HardwareAttestationKey, error) { + id, err := appCtx.HardwareAttestationKeyCreate() + if err != nil { + return nil, err + } + k := &hardwareAttestationKey{appCtx: appCtx, id: id} + if err := k.fetchPublic(); err != nil { + return nil, err + } + return k, nil +} + +var hardwareAttestationKeyNotInitialized = errors.New("HardwareAttestationKey has not been initialized") + +type hardwareAttestationKey struct { + appCtx AppContext + id string + // public key is always initialized in createHardwareAttestationKey and + // UnmarshalJSON. It's only nil in emptyHardwareAttestationKey. + public *ecdsa.PublicKey +} + +func (k *hardwareAttestationKey) fetchPublic() error { + if k.id == "" || k.appCtx == nil { + return hardwareAttestationKeyNotInitialized + } + + pubRaw, err := k.appCtx.HardwareAttestationKeyPublic(k.id) + if err != nil { + return fmt.Errorf("loading public key from KeyStore: %w", err) + } + pubAny, err := x509.ParsePKIXPublicKey(pubRaw) + if err != nil { + return fmt.Errorf("parsing public key: %w", err) + } + pub, ok := pubAny.(*ecdsa.PublicKey) + if !ok { + return fmt.Errorf("parsed key is %T, expected *ecdsa.PublicKey", pubAny) + } + k.public = pub + return nil +} + +func (k *hardwareAttestationKey) Public() crypto.PublicKey { return k.public } + +func (k *hardwareAttestationKey) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) (signature []byte, err error) { + if k.id == "" || k.appCtx == nil { + return nil, hardwareAttestationKeyNotInitialized + } + return k.appCtx.HardwareAttestationKeySign(k.id, digest) +} + +func (k *hardwareAttestationKey) MarshalJSON() ([]byte, error) { return json.Marshal(k.id) } + +func (k *hardwareAttestationKey) UnmarshalJSON(data []byte) error { + if err := json.Unmarshal(data, &k.id); err != nil { + return err + } + if err := k.appCtx.HardwareAttestationKeyLoad(k.id); err != nil { + return fmt.Errorf("loading key with ID %q from KeyStore: %w", k.id, err) + } + return k.fetchPublic() +} + +func (k *hardwareAttestationKey) Close() error { + if k.id == "" || k.appCtx == nil { + return hardwareAttestationKeyNotInitialized + } + return k.appCtx.HardwareAttestationKeyRelease(k.id) +} diff --git a/libtailscale/tailscale.go b/libtailscale/tailscale.go index c03e6f5..76dc979 100644 --- a/libtailscale/tailscale.go +++ b/libtailscale/tailscale.go @@ -16,6 +16,7 @@ import ( "tailscale.com/logtail" "tailscale.com/logtail/filch" "tailscale.com/net/netmon" + "tailscale.com/types/key" "tailscale.com/types/logger" "tailscale.com/types/logid" "tailscale.com/util/clientmetric" @@ -43,6 +44,14 @@ func newApp(dataDir, directFileRoot string, appCtx AppContext) Application { a.policyStore = &syspolicyStore{a: a} netmon.RegisterInterfaceGetter(a.getInterfaces) rsop.RegisterStore("DeviceHandler", setting.DeviceScope, a.policyStore) + if appCtx.HardwareAttestationKeySupported() { + key.RegisterHardwareAttestationKeyFns( + func() key.HardwareAttestationKey { return emptyHardwareAttestationKey(appCtx) }, + func() (key.HardwareAttestationKey, error) { return createHardwareAttestationKey(appCtx) }, + ) + } else { + log.Printf("HardwareAttestationKey is not supported on this device") + } go a.watchFileOpsChanges() go func() { From 7751f2a4ab00b9c6efcbd6e8f63a99cc759f0073 Mon Sep 17 00:00:00 2001 From: James Tucker Date: Fri, 26 Sep 2025 12:19:46 -0700 Subject: [PATCH 49/49] libtailscale: fix regression in interface address enumeration Fix regression introduced in 9c933a08a2ce531ed56dd3008de3cbc1b14b6d22. Fixes tailscale/tailscale#16836 Signed-off-by: James Tucker --- libtailscale/net.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/libtailscale/net.go b/libtailscale/net.go index 0405316..86becab 100644 --- a/libtailscale/net.go +++ b/libtailscale/net.go @@ -101,9 +101,20 @@ func (a *App) getInterfaces() ([]netmon.Interface, error) { addrs := strings.Trim(fields[1], " \n") for _, addr := range strings.Split(addrs, " ") { - _, ipnet, err := net.ParseCIDR(addr) + pfx, err := netip.ParsePrefix(addr) + var ip net.IP + if pfx.Addr().Is4() { + v4 := pfx.Addr().As4() + ip = net.IP(v4[:]) + } else { + v6 := pfx.Addr().As16() + ip = net.IP(v6[:]) + } if err == nil { - newIf.AltAddrs = append(newIf.AltAddrs, ipnet) + newIf.AltAddrs = append(newIf.AltAddrs, &net.IPAddr{ + IP: ip, + Zone: pfx.Addr().Zone(), + }) } }