Compare commits
336 Commits
1.56.0-tf5
...
main
Author | SHA1 | Date |
---|---|---|
kari-ts | 9572541648 | 22 hours ago |
kari-ts | c10aca720b | 22 hours ago |
kari-ts | 25e7681c32 | 1 day ago |
Jonathan Nobels | f8f2ee029a | 6 days ago |
kari-ts | 08ae018468 | 1 week ago |
Brad Fitzpatrick | f26a828cbd | 2 weeks ago |
kari-ts | 9731afd44c | 2 weeks ago |
kari-ts | 9654bb5d9d | 2 weeks ago |
kari-ts | 2ec7304092 | 2 weeks ago |
kari-ts | 22de0cdb7e | 2 weeks ago |
kari-ts | fc8ccc0057 | 2 weeks ago |
Jonathan Nobels | 0b2a04b475 | 2 weeks ago |
kari-ts | 9987dbc592 | 2 weeks ago |
kari-ts | 8b91b0ff0a | 2 weeks ago |
James Tucker | 2fcb080aa6 | 2 weeks ago |
James Tucker | 9e09fad087 | 2 weeks ago |
James Tucker | 204173d10c | 3 weeks ago |
James Tucker | b3a7f7f2ae | 3 weeks ago |
James Tucker | 209045d4f7 | 3 weeks ago |
James Tucker | 7888447f3f | 3 weeks ago |
James Tucker | 72c410465c | 3 weeks ago |
Andrea Gottardo | 001e79546c | 3 weeks ago |
Andrea Gottardo | ffbc556cde | 3 weeks ago |
Jonathan Nobels | e195def5e2 | 3 weeks ago |
Andrew Dunham | aaecc62e1c | 3 weeks ago |
Brad Fitzpatrick | 33f79deb3a | 3 weeks ago |
kari-ts | 28712da8d0 | 3 weeks ago |
Andrew Dunham | 45567146f4 | 3 weeks ago |
kari-ts | 283e1ebcd8 | 3 weeks ago |
Jonathan Nobels | 9f87446ab6 | 3 weeks ago |
Jonathan Nobels | ab7ab73736 | 4 weeks ago |
Anton Tolchanov | fb8a4f51dc | 4 weeks ago |
Anton Tolchanov | 095dae1195 | 4 weeks ago |
Andrea Gottardo | 19581721cf | 1 month ago |
kari-ts | 18e4b176c6 | 1 month ago |
kari-ts | 77eaadb360 | 1 month ago |
yin kaisheng | a9ff204ae4 | 1 month ago |
kari-ts | b4ca226eb7 | 1 month ago |
kari-ts | d94125e767 | 1 month ago |
kari-ts | eae8789628 | 1 month ago |
kari-ts | 29e3c187c2 | 1 month ago |
Josh Vocal | 40090f179b | 1 month ago |
Jonathan Nobels | 502eada21a | 1 month ago |
Josh Vocal | cdbd062426 | 1 month ago |
Josh Vocal | 26e5e796fa | 1 month ago |
Andrea Gottardo | 8648c2ef27 | 1 month ago |
kari-ts | 1a41ab3b66 | 1 month ago |
kari-ts | 10a4350c02 | 1 month ago |
Andrea Gottardo | 4830d8826e | 1 month ago |
Jonathan Nobels | 20a5beab3e | 1 month ago |
Andrea Gottardo | a843c93669 | 2 months ago |
kari-ts | fcfb997fde | 2 months ago |
kari-ts | c6f3239b1b | 2 months ago |
Jonathan Nobels | e6fc832494 | 2 months ago |
Andrea Gottardo | 7e5e0f25cf | 2 months ago |
Percy Wegmann | c1b957cc5f | 2 months ago |
Jonathan Nobels | 716152b57d | 2 months ago |
Andrea Gottardo | 338c13b6b5 | 2 months ago |
Andrea Gottardo | 403aa092c4 | 2 months ago |
Nick Khyl | 2a32ed1f30 | 2 months ago |
Nick Khyl | 8767fbd8d8 | 2 months ago |
Nick Khyl | 946afb6c33 | 2 months ago |
Nick Khyl | 101c9dd121 | 2 months ago |
Andrea Gottardo | ea0c1e960d | 2 months ago |
Jonathan Nobels | 76ab7eab92 | 2 months ago |
Jonathan Nobels | cb916676a4 | 2 months ago |
Jonathan Nobels | 32e48dc78e | 2 months ago |
Jonathan Nobels | 23454e9bc6 | 2 months ago |
Andrea Gottardo | 1465b2a67f | 2 months ago |
Andrea Gottardo | b9917c8647 | 2 months ago |
Jonathan Nobels | 6deb61a20e | 3 months ago |
Jonathan Nobels | b9477c64a8 | 3 months ago |
Jonathan Nobels | 2f59feef20 | 3 months ago |
Andrea Gottardo | c4a1dec8eb | 3 months ago |
Jonathan Nobels | 65a025007f | 3 months ago |
Andrea Gottardo | ca91191cc6 | 3 months ago |
Jonathan Nobels | 26b4635c11 | 3 months ago |
Jonathan Nobels | 66fa3c41a6 | 3 months ago |
Jonathan Nobels | dfda774dc0 | 3 months ago |
Jonathan Nobels | 2a8d07c5f6 | 3 months ago |
Andrea Gottardo | 9b24888c4c | 3 months ago |
Andrea Gottardo | a120eb2fe1 | 3 months ago |
Andrea Gottardo | b3a74986ac | 3 months ago |
Andrea Gottardo | 840a31d74e | 3 months ago |
Andrea Gottardo | b6cacdfd6a | 3 months ago |
Andrea Gottardo | d702d2dab8 | 3 months ago |
Jonathan Nobels | 811641f538 | 3 months ago |
Andrea Gottardo | 9ae30c06bf | 3 months ago |
Andrea Gottardo | 793a83fdc6 | 3 months ago |
Andrea Gottardo | ea928ca971 | 3 months ago |
Andrea Gottardo | 8dc1a13f77 | 3 months ago |
Jonathan Nobels | 196944d168 | 4 months ago |
Jonathan Nobels | 0ff6be6345 | 4 months ago |
Jonathan Nobels | 634d51c20b | 4 months ago |
Fred Silberberg | 864cc35bd4 | 4 months ago |
Jonathan Nobels | 23805e9d00 | 4 months ago |
Jonathan Nobels | 5b121c1876 | 4 months ago |
Jonathan Nobels | 80864fec12 | 4 months ago |
Jonathan Nobels | ef21753763 | 4 months ago |
Jonathan Nobels | 0e82e54ffb | 4 months ago |
Jonathan Nobels | 64fca2a712 | 4 months ago |
Jonathan Nobels | a74e30d4e2 | 4 months ago |
Jonathan Nobels | 2788cf7ee5 | 4 months ago |
kari-ts | d7a87e868c | 4 months ago |
kari-ts | 15da8f3797 | 4 months ago |
kari-ts | 8f62f0da79 | 4 months ago |
kari-ts | cbc47791ad | 4 months ago |
kari-ts | a6fd8a8093 | 4 months ago |
kari-ts | 0df6c61eee | 4 months ago |
Andrea Gottardo | 75db9e64c8 | 4 months ago |
kari-ts | e826a173aa | 4 months ago |
kari-ts | a05829b3c0 | 4 months ago |
kari-ts | 72f35cd318 | 4 months ago |
kari-ts | 4fa86dbf03 | 5 months ago |
Jonathan Nobels | 77c2d924ee | 5 months ago |
Jonathan Nobels | b37492a547 | 5 months ago |
kari-ts | 999c6f2357 | 5 months ago |
Andrea Gottardo | 006b1e6852 | 5 months ago |
kari-ts | 32e29c4efd | 5 months ago |
kari-ts | 9aa3a840de | 5 months ago |
kari-ts | 0ff47f7ab5 | 5 months ago |
kari-ts | 12ad295706 | 5 months ago |
kari-ts | d842ccde22 | 5 months ago |
Andrea Gottardo | cbcc773b98 | 5 months ago |
Andrea Gottardo | cbc0035dfe | 5 months ago |
kari-ts | c47ead9412 | 5 months ago |
Percy Wegmann | 46cdbb7b9b | 5 months ago |
kari-ts | 5476288100 | 5 months ago |
kari-ts | a3b356a81c | 5 months ago |
Percy Wegmann | 411d7b2597 | 5 months ago |
Percy Wegmann | 59a88ffbab | 5 months ago |
kari-ts | f684bf696d | 5 months ago |
Percy Wegmann | 698fb868a7 | 5 months ago |
Andrea Gottardo | 82c17a4d1d | 5 months ago |
Jonathan Nobels | b615eb38b4 | 5 months ago |
Andrea Gottardo | 24d6cc7a08 | 5 months ago |
kari-ts | ec1dc8b0be | 5 months ago |
Percy Wegmann | edb3f5b0c5 | 5 months ago |
kari-ts | 7f66c373ea | 5 months ago |
kari-ts | 2d7d6e1357 | 5 months ago |
Jonathan Nobels | 45fd2e0661 | 5 months ago |
Percy Wegmann | 31b0ec8865 | 5 months ago |
Will Norris | 9703d48f1a | 5 months ago |
Jonathan Nobels | 17ad0c8cc0 | 5 months ago |
Jonathan Nobels | a2471d38cb | 5 months ago |
kari-ts | e6f6d35a99 | 5 months ago |
kari-ts | 5e3236260f | 5 months ago |
kari-ts | d330726ba1 | 5 months ago |
Andrea Gottardo | 0c0853a962 | 5 months ago |
James Tucker | 3f864b28c7 | 5 months ago |
kari-ts | 22c129ee1c | 5 months ago |
Andrea Gottardo | 427e2d29b4 | 5 months ago |
kari-ts | 1c0aef5418 | 5 months ago |
kari-ts | 39628be8a6 | 5 months ago |
Brad Fitzpatrick | 9dda2cc470 | 5 months ago |
kari-ts | a6bc2244b6 | 5 months ago |
kari-ts | 24dd83090c | 5 months ago |
kari-ts | ad3b6a5a64 | 5 months ago |
Percy Wegmann | 16fa0e9b9e | 5 months ago |
Andrea Gottardo | 88b0af2c9b | 5 months ago |
Andrea Gottardo | 7119424e32 | 6 months ago |
Jonathan Nobels | b06342629f | 6 months ago |
Percy Wegmann | 07d04ca750 | 6 months ago |
Percy Wegmann | 057e25c23d | 6 months ago |
Will Norris | a54ebf75ef | 6 months ago |
Jonathan Nobels | f4d2a277a5 | 6 months ago |
kari-ts | 75e2d8983b | 6 months ago |
kari-ts | bbb3c86fa8 | 6 months ago |
Percy Wegmann | bc8985126d | 6 months ago |
Brad Fitzpatrick | eb8d731a04 | 6 months ago |
kari-ts | 81acaef5b7 | 6 months ago |
kari-ts | 19177df1e2 | 6 months ago |
Praneet Loke | 6197cb9576 | 6 months ago |
kari-ts | 253c116f9b | 6 months ago |
Jonathan Nobels | 1c3af6713c | 6 months ago |
kari-ts | 39d1d0b3c3 | 6 months ago |
Andrea Gottardo | 56da7b66d0 | 6 months ago |
kari-ts | f95428f7fa | 6 months ago |
Percy Wegmann | 0c58841350 | 6 months ago |
Andrea Gottardo | 8a7148c085 | 6 months ago |
Jonathan Nobels | 372af99c53 | 6 months ago |
Andrea Gottardo | a73025b36f | 6 months ago |
Andrea Gottardo | 4d86c1a6f6 | 6 months ago |
Andrea Gottardo | a1d97baeb0 | 6 months ago |
Matt Drollette | 9533db44b7 | 6 months ago |
Andrea Gottardo | 44ac22c29d | 6 months ago |
kari-ts | 5ad25262ad | 6 months ago |
Jonathan Nobels | be6364ca95 | 6 months ago |
kari-ts | 3e32e97261 | 6 months ago |
Andrea Gottardo | 164a243b77 | 6 months ago |
Percy Wegmann | a77edc6724 | 6 months ago |
Percy Wegmann | d396fdab27 | 6 months ago |
Percy Wegmann | 0ae9da385e | 6 months ago |
Percy Wegmann | 9054264363 | 6 months ago |
Jonathan Nobels | 11f52ad96b | 6 months ago |
Percy Wegmann | 482b350ce0 | 6 months ago |
kari-ts | c8d1b30918 | 6 months ago |
kari-ts | 6a00880f61 | 6 months ago |
Jonathan Nobels | a3638f9fc7 | 6 months ago |
Percy Wegmann | c59c8537cf | 6 months ago |
Jonathan Nobels | cc244812a6 | 6 months ago |
kari-ts | a325a90558 | 6 months ago |
kari-ts | f14836a750 | 6 months ago |
kari-ts | 38f57b4737 | 6 months ago |
Percy Wegmann | d676dca4f4 | 6 months ago |
Jonathan Nobels | 32e407d06b | 6 months ago |
Percy Wegmann | 9bfa839380 | 6 months ago |
Percy Wegmann | 2e237e375e | 6 months ago |
Percy Wegmann | 71f03cf0d2 | 6 months ago |
kari-ts | 5745854297 | 6 months ago |
Jonathan Nobels | b4c0a6931d | 6 months ago |
Jonathan Nobels | dbc809167e | 6 months ago |
kari-ts | f54e476328 | 6 months ago |
Percy Wegmann | ccda0499a7 | 6 months ago |
Percy Wegmann | e7539f5ff3 | 6 months ago |
Percy Wegmann | c0ffd5016b | 6 months ago |
Jonathan Nobels | a0e7777958 | 6 months ago |
Percy Wegmann | ef894fa8ca | 6 months ago |
Percy Wegmann | c3dac5954e | 6 months ago |
Percy Wegmann | 54dccff232 | 6 months ago |
Jonathan Nobels | 31939cc855 | 6 months ago |
Jonathan Nobels | 75ad5cfef6 | 6 months ago |
Jonathan Nobels | d188da3a24 | 6 months ago |
Jonathan Nobels | 9fcc1ddfe1 | 6 months ago |
Jonathan Nobels | 3b21a06c8b | 6 months ago |
kari-ts | 9b27516e96 | 6 months ago |
Percy Wegmann | 1719d5d558 | 6 months ago |
Percy Wegmann | d332ce049e | 6 months ago |
Percy Wegmann | 91c1a8d0f3 | 6 months ago |
Jonathan Nobels | e9465988dd | 6 months ago |
Percy Wegmann | 6e503f29a9 | 6 months ago |
Jonathan Nobels | a321d84dba | 6 months ago |
Will Norris | 77f720dba7 | 6 months ago |
kari-ts | 3f816eac4d | 6 months ago |
Percy Wegmann | 9fb742bd8b | 6 months ago |
kari-ts | dca2fc3bf4 | 6 months ago |
Jonathan Nobels | 67a9320d26 | 6 months ago |
Percy Wegmann | 4897f09e50 | 6 months ago |
Percy Wegmann | 8105271d25 | 6 months ago |
Jonathan Nobels | 2818195400 | 6 months ago |
Percy Wegmann | e024c896c1 | 6 months ago |
Percy Wegmann | cfd01af74a | 6 months ago |
kari-ts | facf6406c3 | 6 months ago |
kari-ts | af2e33d130 | 6 months ago |
Jonathan Nobels | cf56dd6793 | 6 months ago |
kari-ts | 4baec5ff80 | 6 months ago |
Jonathan Nobels | 61fb6bbf8e | 6 months ago |
Percy Wegmann | 5599f2ddeb | 6 months ago |
Jonathan Nobels | e59112a8fb | 6 months ago |
Percy Wegmann | db3ba696eb | 6 months ago |
Percy Wegmann | 44ba20a24e | 6 months ago |
Percy Wegmann | 8e063051b6 | 6 months ago |
Percy Wegmann | 7392c7086e | 6 months ago |
Percy Wegmann | 9f3e871637 | 6 months ago |
Andrea Gottardo | e511430f73 | 6 months ago |
Percy Wegmann | cf6a203f7a | 6 months ago |
Percy Wegmann | fb5635b8a5 | 6 months ago |
Andrea Gottardo | 3fea68ef2e | 6 months ago |
Andrea Gottardo | bf74edd551 | 6 months ago |
Percy Wegmann | 28d0ab4dd6 | 6 months ago |
Percy Wegmann | 6a875e8854 | 6 months ago |
Percy Wegmann | a15fdd44bf | 6 months ago |
Will Norris | 9fcdcfe630 | 6 months ago |
Andrea Gottardo | e187a8db81 | 6 months ago |
Andrea Gottardo | f96e9b923f | 6 months ago |
Andrea Gottardo | 19adff3077 | 6 months ago |
Jonathan Nobels | e953b19189 | 6 months ago |
James Tucker | 5454b34dd1 | 6 months ago |
Andrea Gottardo | 0d1a3cf415 | 6 months ago |
Andrea Gottardo | c3b62124bb | 6 months ago |
Jonathan Nobels | 910511d838 | 6 months ago |
kari-ts | b346321078 | 6 months ago |
Jonathan Nobels | 7b7f7254ba | 6 months ago |
kari-ts | 72753bb82a | 6 months ago |
kari-ts | 7470fcc173 | 6 months ago |
James Tucker | 4e923a65c1 | 7 months ago |
James Tucker | 2a14964878 | 7 months ago |
James Tucker | 244221706f | 7 months ago |
Jonathan Nobels | b4f1989b67 | 7 months ago |
Percy Wegmann | 5e7e36e3bc | 7 months ago |
kari-ts | 98a72c2963 | 7 months ago |
Jonathan Nobels | f12439f9a3 | 7 months ago |
Jonathan Nobels | 113a7c6f9d | 7 months ago |
Jonathan Nobels | e4b0e1f8cd | 7 months ago |
Percy Wegmann | e568741081 | 7 months ago |
Percy Wegmann | a1e67ff1e9 | 7 months ago |
Percy Wegmann | d42329e2e2 | 7 months ago |
Anton Tolchanov | e16303e1d8 | 7 months ago |
Percy Wegmann | 9a6aecb454 | 7 months ago |
Andrea Gottardo | 06e850bbd5 | 7 months ago |
Jonathan Nobels | 4df18951a6 | 7 months ago |
Jonathan Nobels | 2c694b7159 | 7 months ago |
Andrea Gottardo | 7c64091aab | 7 months ago |
James Tucker | bf7bf94b52 | 7 months ago |
Jonathan Nobels | 16ec19757d | 7 months ago |
Jonathan Nobels | f275656c25 | 7 months ago |
Jonathan Nobels | 1f457399b8 | 7 months ago |
Jonathan Nobels | 94a4f55eb2 | 7 months ago |
Jonathan Nobels | 0d867aedce | 7 months ago |
Jonathan Nobels | bf0e56469f | 7 months ago |
Jonathan Nobels | 3926cf4b56 | 7 months ago |
James Tucker | 87a8003d39 | 7 months ago |
Jonathan Nobels | 4f46c38c99 | 7 months ago |
Anton Tolchanov | a0f87846fd | 7 months ago |
Anton Tolchanov | 7d25cf97f8 | 7 months ago |
Jonathan Nobels | 9a206805df | 7 months ago |
kari-ts | 01ec98f29a | 7 months ago |
Aalok Kamble | f23477e796 | 7 months ago |
kari-ts | 464f089388 | 7 months ago |
kari-ts | 9492b01946 | 7 months ago |
Jonathan Nobels | bb7ea7cf9f | 7 months ago |
Percy Wegmann | 37832a5b72 | 7 months ago |
kari-ts | 89e160bd08 | 7 months ago |
kari-ts | bf9be063d7 | 7 months ago |
kari-ts | f6b0734e49 | 7 months ago |
Percy Wegmann | cbe8858427 | 7 months ago |
kari-ts | 60b9884aa2 | 7 months ago |
kari-ts | 98fe1e86e5 | 7 months ago |
Moritz Poldrack | e90f39a58c | 8 months ago |
kari-ts | f9310e7a1f | 8 months ago |
Nicola Beghin | df9c75136b | 8 months ago |
kari-ts | 915e4e3394 | 8 months ago |
kari-ts | b96df2b830 | 8 months ago |
kari-ts | 630a6069c4 | 8 months ago |
Charlotte Brandhorst-Satzkorn | 9e8dfbb2ab | 8 months ago |
Charlotte Brandhorst-Satzkorn | 3615398012 | 8 months ago |
kari-ts | 813ca8adea | 9 months ago |
David Anderson | 3255d55e39 | 9 months ago |
David Anderson | 4c7d66701f | 9 months ago |
kari-ts | a76b36506c | 9 months ago |
kari-ts | 1b42117791 | 9 months ago |
kari-ts | 99c54591e6 | 9 months ago |
Denton Gentry | 52601c0dff | 9 months ago |
Denton Gentry | dcca09fe7f | 9 months ago |
kari-ts | 7a52cae96f | 9 months ago |
kari-ts | ae647625b0 | 9 months ago |
@ -1,67 +0,0 @@
|
|||||||
name: go-licenses
|
|
||||||
|
|
||||||
on:
|
|
||||||
# run action when a change lands in the main branch which updates go.mod or
|
|
||||||
# our license template file. Also allow manual triggering.
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- go.mod
|
|
||||||
- .github/licenses.tmpl
|
|
||||||
- .github/workflows/go-licenses.yml
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
android:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check out code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Check out OSS code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
with:
|
|
||||||
repository: tailscale/tailscale
|
|
||||||
path: oss
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v3
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
|
|
||||||
- name: Install go-licenses
|
|
||||||
run: |
|
|
||||||
go install github.com/google/go-licenses@v1.2.2-0.20220825154955-5eedde1c6584
|
|
||||||
|
|
||||||
- name: Run go-licenses
|
|
||||||
run: |
|
|
||||||
go-licenses report ./cmd/tailscale --template .github/licenses.tmpl --ignore tailscale.com | tee oss/licenses/android.md
|
|
||||||
|
|
||||||
- name: Get access token
|
|
||||||
uses: tibdex/github-app-token@f717b5ecd4534d3c4df4ce9b5c1c2214f0f7cd06 # v1.6.0
|
|
||||||
id: generate-token
|
|
||||||
with:
|
|
||||||
app_id: ${{ secrets.LICENSING_APP_ID }}
|
|
||||||
installation_id: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
|
|
||||||
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
|
|
||||||
|
|
||||||
- name: Send pull request
|
|
||||||
uses: peter-evans/create-pull-request@18f90432bedd2afd6a825469ffd38aa24712a91d #v4.1.1
|
|
||||||
with:
|
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
|
||||||
path: oss
|
|
||||||
author: License Updater <noreply+license-updater@tailscale.com>
|
|
||||||
Committer: License Updater <noreply+license-updater@tailscale.com>
|
|
||||||
branch: licenses/android
|
|
||||||
commit-message: "licenses: update android licenses"
|
|
||||||
title: "licenses: update android licenses"
|
|
||||||
body: Triggered by ${{ github.repository }}@${{ github.sha }}
|
|
||||||
signoff: true
|
|
||||||
delete-branch: true
|
|
||||||
team-reviewers: opensource-license-reviewers
|
|
@ -0,0 +1,36 @@
|
|||||||
|
name: go mod tidy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- "release-branch/*"
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-go-mod-tidy:
|
||||||
|
runs-on: [ubuntu-latest]
|
||||||
|
timeout-minutes: 8
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
cache: false
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Check 'go mod tidy' is clean
|
||||||
|
run: |
|
||||||
|
./tool/go mod tidy
|
||||||
|
echo
|
||||||
|
echo
|
||||||
|
git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go mod tidy'."; exit 1)
|
@ -0,0 +1,19 @@
|
|||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "main"
|
||||||
|
- "release-branch/*"
|
||||||
|
pull_request:
|
||||||
|
# all PRs on all branches
|
||||||
|
merge_group:
|
||||||
|
branches:
|
||||||
|
- "main"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
license_headers:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: check license headers
|
||||||
|
run: ./scripts/check_license_headers.sh .
|
@ -1,59 +1,183 @@
|
|||||||
buildscript {
|
buildscript {
|
||||||
repositories {
|
ext.kotlin_version = "1.9.22"
|
||||||
google()
|
ext.compose_version = "1.5.10"
|
||||||
mavenCentral()
|
ext.accompanist_version = "0.34.0"
|
||||||
}
|
|
||||||
dependencies {
|
repositories {
|
||||||
classpath 'com.android.tools.build:gradle:8.1.0'
|
google()
|
||||||
}
|
mavenCentral()
|
||||||
|
maven {
|
||||||
|
url = uri("https://plugins.gradle.org/m2/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath 'com.android.tools.build:gradle:8.5.1'
|
||||||
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
|
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
|
||||||
|
classpath("com.ncorti.ktfmt.gradle:plugin:0.17.0")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
allprojects {
|
repositories {
|
||||||
repositories {
|
google()
|
||||||
google()
|
mavenCentral()
|
||||||
mavenCentral()
|
flatDir {
|
||||||
flatDir {
|
dirs 'libs'
|
||||||
dirs 'libs'
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
apply plugin: 'com.android.application'
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'org.jetbrains.kotlin.plugin.serialization'
|
||||||
|
apply plugin: 'com.ncorti.ktfmt.gradle'
|
||||||
|
|
||||||
android {
|
android {
|
||||||
ndkVersion "23.1.7779620"
|
ndkVersion "23.1.7779620"
|
||||||
compileSdk 33
|
compileSdkVersion 34
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdkVersion 22
|
minSdkVersion 26
|
||||||
targetSdkVersion 33
|
targetSdkVersion 34
|
||||||
versionCode 192
|
versionCode 241
|
||||||
versionName "1.55.148-t86aa0485a-g5ef7bbaff0a"
|
versionName "1.75.51-ta70287d32-gc10aca720b8"
|
||||||
}
|
|
||||||
compileOptions {
|
// This setting, which defaults to 'true', will cause Tailscale to fall
|
||||||
sourceCompatibility 1.8
|
// back to the Google DNS servers if it cannot determine what the
|
||||||
targetCompatibility 1.8
|
// operating system's DNS configuration is.
|
||||||
}
|
//
|
||||||
flavorDimensions "version"
|
// Set it to false either here or in your local.properties file to
|
||||||
productFlavors {
|
// disable this behaviour.
|
||||||
fdroid {
|
buildConfigField "boolean", "USE_GOOGLE_DNS_FALLBACK", getLocalProperty("tailscale.useGoogleDnsFallback", "true")
|
||||||
// The fdroid flavor contains only free dependencies and is suitable
|
}
|
||||||
// for the F-Droid app store.
|
|
||||||
}
|
compileOptions {
|
||||||
play {
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
// The play flavor contains all features and is for the Play Store.
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
lintOptions {
|
||||||
|
warningsAsErrors true
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig true
|
||||||
|
compose true
|
||||||
|
}
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = "$compose_version"
|
||||||
|
}
|
||||||
|
flavorDimensions "version"
|
||||||
namespace 'com.tailscale.ipn'
|
namespace 'com.tailscale.ipn'
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
applicationTest {
|
||||||
|
initWith debug
|
||||||
|
manifestPlaceholders.leanbackRequired = false
|
||||||
|
buildConfigField "String", "GITHUB_USERNAME", "\"" + getLocalProperty("githubUsername", "")+"\""
|
||||||
|
buildConfigField "String", "GITHUB_PASSWORD", "\"" + getLocalProperty("githubPassword", "")+"\""
|
||||||
|
buildConfigField "String", "GITHUB_2FA_SECRET", "\"" + getLocalProperty("github2FASecret", "")+"\""
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
manifestPlaceholders.leanbackRequired = false
|
||||||
|
}
|
||||||
|
release {
|
||||||
|
manifestPlaceholders.leanbackRequired = false
|
||||||
|
|
||||||
|
minifyEnabled true
|
||||||
|
|
||||||
|
shrinkResources true
|
||||||
|
|
||||||
|
proguardFiles getDefaultProguardFile(
|
||||||
|
'proguard-android-optimize.txt'),
|
||||||
|
'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
release_tv {
|
||||||
|
initWith release
|
||||||
|
manifestPlaceholders.leanbackRequired = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testBuildType "applicationTest"
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation "androidx.core:core:1.9.0"
|
// Android dependencies.
|
||||||
implementation "androidx.browser:browser:1.5.0"
|
implementation "androidx.core:core:1.13.1"
|
||||||
implementation "androidx.security:security-crypto:1.1.0-alpha06"
|
implementation 'androidx.core:core-ktx:1.13.1'
|
||||||
implementation "androidx.work:work-runtime:2.8.1"
|
implementation "androidx.browser:browser:1.8.0"
|
||||||
implementation ':ipn@aar'
|
implementation "androidx.security:security-crypto:1.1.0-alpha06"
|
||||||
testImplementation "junit:junit:4.12"
|
implementation "androidx.work:work-runtime:2.9.0"
|
||||||
|
|
||||||
// Non-free dependencies.
|
// Kotlin dependencies.
|
||||||
playImplementation 'com.google.android.gms:play-services-auth:20.7.0'
|
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
|
||||||
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1"
|
||||||
|
implementation 'junit:junit:4.13.2'
|
||||||
|
runtimeOnly "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"
|
||||||
|
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||||
|
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
|
||||||
|
|
||||||
|
// Compose dependencies.
|
||||||
|
def composeBom = platform('androidx.compose:compose-bom:2024.06.00')
|
||||||
|
implementation composeBom
|
||||||
|
implementation 'androidx.compose.material3:material3:1.2.1'
|
||||||
|
implementation 'androidx.compose.material:material-icons-core:1.6.8'
|
||||||
|
implementation "androidx.compose.ui:ui:1.6.8"
|
||||||
|
implementation "androidx.compose.ui:ui-tooling:1.6.8"
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3'
|
||||||
|
implementation 'androidx.activity:activity-compose:1.9.0'
|
||||||
|
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
|
||||||
|
implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version"
|
||||||
|
implementation "androidx.core:core-splashscreen:1.1.0-rc01"
|
||||||
|
|
||||||
|
// Navigation dependencies.
|
||||||
|
def nav_version = "2.7.7"
|
||||||
|
implementation "androidx.navigation:navigation-compose:$nav_version"
|
||||||
|
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
|
||||||
|
|
||||||
|
// Supporting libraries.
|
||||||
|
implementation("io.coil-kt:coil-compose:2.6.0")
|
||||||
|
implementation("com.google.zxing:core:3.5.1")
|
||||||
|
implementation("com.patrykandpatrick.vico:compose:1.15.0")
|
||||||
|
implementation("com.patrykandpatrick.vico:compose-m3:1.15.0")
|
||||||
|
|
||||||
|
// Tailscale dependencies.
|
||||||
|
implementation ':libtailscale@aar'
|
||||||
|
|
||||||
|
// Integration Tests
|
||||||
|
androidTestImplementation composeBom
|
||||||
|
androidTestImplementation 'androidx.test:runner:1.6.1'
|
||||||
|
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1'
|
||||||
|
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||||
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
|
||||||
|
implementation 'androidx.test.uiautomator:uiautomator:2.3.0'
|
||||||
|
|
||||||
|
|
||||||
|
// Authentication only for tests
|
||||||
|
androidTestImplementation 'dev.turingcomplete:kotlin-onetimepassword:2.4.0'
|
||||||
|
androidTestImplementation 'commons-codec:commons-codec:1.16.1'
|
||||||
|
|
||||||
|
// Unit Tests
|
||||||
|
testImplementation 'junit:junit:4.13.2'
|
||||||
|
testImplementation 'org.mockito:mockito-core:5.4.0'
|
||||||
|
testImplementation 'org.mockito:mockito-inline:5.2.0'
|
||||||
|
testImplementation 'org.mockito.kotlin:mockito-kotlin:5.4.0'
|
||||||
|
|
||||||
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
}
|
||||||
|
|
||||||
|
def getLocalProperty(key, defaultValue) {
|
||||||
|
try {
|
||||||
|
Properties properties = new Properties()
|
||||||
|
properties.load(project.file('local.properties').newDataInputStream())
|
||||||
|
return properties.getProperty(key) ?: defaultValue
|
||||||
|
} catch(Throwable ignored) {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
android.defaults.buildfeatures.buildconfig=true
|
android.defaults.buildfeatures.buildconfig=true
|
||||||
android.nonFinalResIds=false
|
android.nonFinalResIds=false
|
||||||
android.nonTransitiveRClass=false
|
android.nonTransitiveRClass=true
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionSha256Sum=38f66cd6eef217b4c35855bb11ea4e9fbc53594ccccb5fb82dfd317ef8c2c5a3
|
distributionSha256Sum=544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
@ -0,0 +1,25 @@
|
|||||||
|
# Keep all classes with native methods
|
||||||
|
-keepclasseswithmembernames class * {
|
||||||
|
native <methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep the classes with syspolicy MDM keys, some of which
|
||||||
|
# get used only by the Go backend.
|
||||||
|
-keep class com.tailscale.ipn.mdm.** { *; }
|
||||||
|
|
||||||
|
# Keep specific classes from Tink library
|
||||||
|
-keep class com.google.crypto.tink.** { *; }
|
||||||
|
|
||||||
|
# Ignore warnings about missing Error Prone annotations
|
||||||
|
-dontwarn com.google.errorprone.annotations.**
|
||||||
|
|
||||||
|
# Keep Error Prone annotations if referenced
|
||||||
|
-keep class com.google.errorprone.annotations.** { *; }
|
||||||
|
|
||||||
|
# Keep Google HTTP Client classes
|
||||||
|
-keep class com.google.api.client.http.** { *; }
|
||||||
|
-dontwarn com.google.api.client.http.**
|
||||||
|
|
||||||
|
# Keep Joda-Time classes
|
||||||
|
-keep class org.joda.time.** { *; }
|
||||||
|
-dontwarn org.joda.time.**
|
@ -0,0 +1,160 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.EditText
|
||||||
|
import androidx.test.ext.junit.rules.activityScenarioRule
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.filters.LargeTest
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.test.uiautomator.By
|
||||||
|
import androidx.test.uiautomator.UiDevice
|
||||||
|
import androidx.test.uiautomator.UiSelector
|
||||||
|
import dev.turingcomplete.kotlinonetimepassword.HmacAlgorithm
|
||||||
|
import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordConfig
|
||||||
|
import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordGenerator
|
||||||
|
import org.apache.commons.codec.binary.Base32
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@LargeTest
|
||||||
|
class MainActivityTest {
|
||||||
|
companion object {
|
||||||
|
const val TAG = "MainActivityTest"
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:Rule val activityRule = activityScenarioRule<MainActivity>()
|
||||||
|
|
||||||
|
@Before fun setUp() {}
|
||||||
|
|
||||||
|
@After fun tearDown() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This test starts with a clean install, logs the user in to a tailnet using credentials provided
|
||||||
|
* through a build config, and then makes sure we can hit https://hello.ts.net.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun loginAndVisitHello() {
|
||||||
|
val githubUsername = BuildConfig.GITHUB_USERNAME
|
||||||
|
val githubPassword = BuildConfig.GITHUB_PASSWORD
|
||||||
|
val github2FASecret = Base32().decode(BuildConfig.GITHUB_2FA_SECRET)
|
||||||
|
val config =
|
||||||
|
TimeBasedOneTimePasswordConfig(
|
||||||
|
codeDigits = 6,
|
||||||
|
hmacAlgorithm = HmacAlgorithm.SHA1,
|
||||||
|
timeStep = 30,
|
||||||
|
timeStepUnit = TimeUnit.SECONDS)
|
||||||
|
val githubTOTP = TimeBasedOneTimePasswordGenerator(github2FASecret, config)
|
||||||
|
|
||||||
|
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
|
||||||
|
Log.d(TAG, "Wait for VPN permission prompt and accept")
|
||||||
|
device.find(By.text("Connection request"))
|
||||||
|
device.find(By.text("OK")).click()
|
||||||
|
|
||||||
|
Log.d(TAG, "Click through Get Started screen")
|
||||||
|
device.find(By.text("Get Started"))
|
||||||
|
device.find(By.text("Get Started")).click()
|
||||||
|
|
||||||
|
asNecessary(
|
||||||
|
timeout = 2.minutes,
|
||||||
|
{
|
||||||
|
Log.d(TAG, "Log in")
|
||||||
|
device.find(By.text("Log in")).click()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Log.d(TAG, "Accept Chrome terms and conditions (if necessary)")
|
||||||
|
device.find(By.text("Welcome to Chrome"))
|
||||||
|
val dismissIndex =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) 1 else 0
|
||||||
|
device.find(UiSelector().instance(dismissIndex).className(Button::class.java)).click()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Log.d(TAG, "Don't turn on sync")
|
||||||
|
device.find(By.text("Turn on sync?"))
|
||||||
|
device.find(By.text("No thanks")).click()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Log.d(TAG, "Log in with GitHub")
|
||||||
|
device.find(By.text("Sign in with GitHub")).click()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Log.d(TAG, "Make sure GitHub page has loaded")
|
||||||
|
device.find(By.text("New to GitHub"))
|
||||||
|
device.find(By.text("Username or email address"))
|
||||||
|
device.find(By.text("Sign in"))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Log.d(TAG, "Enter credentials")
|
||||||
|
device
|
||||||
|
.find(UiSelector().instance(0).className(EditText::class.java))
|
||||||
|
.setText(githubUsername)
|
||||||
|
device
|
||||||
|
.find(UiSelector().instance(1).className(EditText::class.java))
|
||||||
|
.setText(githubPassword)
|
||||||
|
device.find(By.text("Sign in")).click()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Log.d(TAG, "Enter 2FA")
|
||||||
|
device.find(By.text("Two-factor authentication"))
|
||||||
|
device
|
||||||
|
.find(UiSelector().instance(0).className(EditText::class.java))
|
||||||
|
.setText(githubTOTP.generate())
|
||||||
|
device.find(UiSelector().instance(0).className(Button::class.java)).click()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Log.d(TAG, "Accept Tailscale app")
|
||||||
|
device.find(By.text("Learn more about OAuth"))
|
||||||
|
// Sleep a little to give button time to activate
|
||||||
|
Thread.sleep(5.seconds.inWholeMilliseconds)
|
||||||
|
device.find(UiSelector().instance(1).className(Button::class.java)).click()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Log.d(TAG, "Connect device")
|
||||||
|
device.find(By.text("Connect device"))
|
||||||
|
device.find(UiSelector().instance(0).className(Button::class.java)).click()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "Accept Permission (Either Storage or Notifications)")
|
||||||
|
device.find(By.text("Continue")).click()
|
||||||
|
device.find(By.text("Allow")).click()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
// we're not always prompted for permissions, that's okay
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Wait for VPN to connect")
|
||||||
|
device.find(By.text("Connected"))
|
||||||
|
|
||||||
|
val helloResponse = helloTSNet
|
||||||
|
Assert.assertTrue(
|
||||||
|
"Response from hello.ts.net should show success",
|
||||||
|
helloResponse.contains("You're connected over Tailscale!"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val helloTSNet: String
|
||||||
|
get() {
|
||||||
|
return URL("https://hello.ts.net").run {
|
||||||
|
openConnection().run {
|
||||||
|
this as HttpURLConnection
|
||||||
|
connectTimeout = 30000
|
||||||
|
readTimeout = 5000
|
||||||
|
inputStream.bufferedReader().readText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,92 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.test.uiautomator.BySelector
|
||||||
|
import androidx.test.uiautomator.UiDevice
|
||||||
|
import androidx.test.uiautomator.UiObject
|
||||||
|
import androidx.test.uiautomator.UiObject2
|
||||||
|
import androidx.test.uiautomator.UiSelector
|
||||||
|
import androidx.test.uiautomator.Until
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
private val defaultTimeout = 10.seconds
|
||||||
|
|
||||||
|
private val threadLocalTimeout = ThreadLocal<Duration>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait until the specified timeout for the given selector and return the matching UiObject2.
|
||||||
|
* Timeout defaults to 10 seconds.
|
||||||
|
*
|
||||||
|
* @throws Exception if selector is not found within timeout.
|
||||||
|
*/
|
||||||
|
fun UiDevice.find(
|
||||||
|
selector: BySelector,
|
||||||
|
timeout: Duration = threadLocalTimeout.get() ?: defaultTimeout
|
||||||
|
): UiObject2 {
|
||||||
|
wait(Until.findObject(selector), timeout.inWholeMilliseconds)?.let {
|
||||||
|
return it
|
||||||
|
} ?: run { throw Exception("not found") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait until the specified timeout for the given selector and return the matching UiObject. Timeout
|
||||||
|
* defaults to 10 seconds.
|
||||||
|
*
|
||||||
|
* @throws Exception if selector is not found within timeout.
|
||||||
|
*/
|
||||||
|
fun UiDevice.find(
|
||||||
|
selector: UiSelector,
|
||||||
|
timeout: Duration = threadLocalTimeout.get() ?: defaultTimeout
|
||||||
|
): UiObject {
|
||||||
|
val obj = findObject(selector)
|
||||||
|
if (!obj.waitForExists(timeout.inWholeMilliseconds)) {
|
||||||
|
throw Exception("not found")
|
||||||
|
}
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute an ordered collection of steps as necessary. If an earlier step fails but a subsequent
|
||||||
|
* step succeeds, this skips the earlier step. This is useful for interruptible sequences like
|
||||||
|
* logging in that may resume in an intermediate state.
|
||||||
|
*/
|
||||||
|
fun asNecessary(timeout: Duration, vararg steps: () -> Unit) {
|
||||||
|
val interval = 250.milliseconds
|
||||||
|
// Use a short timeout to avoid waiting on actions that can be skipped
|
||||||
|
threadLocalTimeout.set(interval)
|
||||||
|
try {
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
|
var furthestSuccessful = -1
|
||||||
|
while (System.currentTimeMillis() - start < timeout.inWholeMilliseconds) {
|
||||||
|
for (i in furthestSuccessful + 1 ..< steps.size) {
|
||||||
|
val step = steps[i]
|
||||||
|
try {
|
||||||
|
step()
|
||||||
|
furthestSuccessful = i
|
||||||
|
Log.d("TestUtil.asNecessary", "SUCCESS!")
|
||||||
|
// Going forward, use the normal timeout on the assumption that subsequent steps will
|
||||||
|
// succeed.
|
||||||
|
threadLocalTimeout.remove()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.d("TestUtil.asNecessary", t.toString())
|
||||||
|
// Going forward, use a short timeout to avoid waiting on actions that can be skipped
|
||||||
|
threadLocalTimeout.set(interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (furthestSuccessful == steps.size - 1) {
|
||||||
|
// All steps have completed successfully
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Still some steps left to run
|
||||||
|
Thread.sleep(interval.inWholeMilliseconds)
|
||||||
|
}
|
||||||
|
throw Exception("failed to complete within timeout")
|
||||||
|
} finally {
|
||||||
|
threadLocalTimeout.remove()
|
||||||
|
}
|
||||||
|
}
|
@ -1,84 +1,126 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
|
|
||||||
|
|
||||||
<!-- Disable input emulation on ChromeOS -->
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-feature android:name="android.hardware.type.pc" android:required="false"/>
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="29" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||||
|
tools:ignore="QueryAllPackagesPermission" />
|
||||||
|
|
||||||
<!-- Signal support for Android TV -->
|
<!-- Disable input emulation on ChromeOS -->
|
||||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
<uses-feature
|
||||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
android:name="android.hardware.type.pc"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
<application android:label="Tailscale" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round"
|
<!-- Signal support for Android TV -->
|
||||||
android:banner="@drawable/tv_banner"
|
<uses-feature
|
||||||
android:name=".App" android:allowBackup="false">
|
android:name="android.software.leanback"
|
||||||
<activity android:name="IPNActivity"
|
android:required="${leanbackRequired}" />
|
||||||
android:label="@string/app_name"
|
<uses-feature
|
||||||
android:theme="@style/Theme.GioApp"
|
android:name="android.hardware.touchscreen"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
|
android:required="false" />
|
||||||
android:windowSoftInputMode="adjustResize"
|
|
||||||
android:launchMode="singleTask"
|
<application
|
||||||
android:exported="true">
|
android:name=".App"
|
||||||
<intent-filter>
|
android:allowBackup="false"
|
||||||
<action android:name="android.intent.action.MAIN" />
|
android:banner="@drawable/tv_banner"
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
android:icon="@mipmap/ic_launcher"
|
||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
android:label="Tailscale"
|
||||||
</intent-filter>
|
android:requestLegacyExternalStorage="true"
|
||||||
<intent-filter>
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
|
android:theme="@style/Theme.App.SplashScreen">
|
||||||
</intent-filter>
|
<activity
|
||||||
<intent-filter>
|
android:name="MainActivity"
|
||||||
<action android:name="android.intent.action.SEND" />
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
|
||||||
<category android:name="android.intent.category.DEFAULT"/>
|
android:exported="true"
|
||||||
<data android:mimeType="application/*" />
|
android:label="@string/app_name"
|
||||||
<data android:mimeType="audio/*" />
|
android:launchMode="singleTask"
|
||||||
<data android:mimeType="image/*" />
|
android:windowSoftInputMode="adjustResize">
|
||||||
<data android:mimeType="message/*" />
|
<intent-filter>
|
||||||
<data android:mimeType="multipart/*" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<data android:mimeType="text/*" />
|
|
||||||
<data android:mimeType="video/*" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
<intent-filter>
|
</intent-filter>
|
||||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
<intent-filter>
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
|
||||||
<data android:mimeType="application/*" />
|
</intent-filter>
|
||||||
<data android:mimeType="audio/*" />
|
</activity>
|
||||||
<data android:mimeType="image/*" />
|
<activity
|
||||||
<data android:mimeType="message/*" />
|
android:name="ShareActivity"
|
||||||
<data android:mimeType="multipart/*" />
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
|
||||||
<data android:mimeType="text/*" />
|
android:exported="true"
|
||||||
<data android:mimeType="video/*" />
|
android:label="@string/app_name"
|
||||||
</intent-filter>
|
android:launchMode="singleTask"
|
||||||
</activity>
|
android:theme="@style/Theme.MaterialComponents.DayNight.NoActionBar"
|
||||||
<receiver android:name="IPNReceiver"
|
android:windowSoftInputMode="adjustResize">
|
||||||
android:exported="true"
|
<intent-filter>
|
||||||
>
|
<action android:name="android.intent.action.SEND" />
|
||||||
<intent-filter>
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<action android:name="com.tailscale.ipn.CONNECT_VPN" />
|
|
||||||
<action android:name="com.tailscale.ipn.DISCONNECT_VPN" />
|
<data android:mimeType="application/*" />
|
||||||
</intent-filter>
|
<data android:mimeType="audio/*" />
|
||||||
</receiver>
|
<data android:mimeType="image/*" />
|
||||||
<service android:name=".IPNService"
|
<data android:mimeType="message/*" />
|
||||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
<data android:mimeType="multipart/*" />
|
||||||
android:exported="false">
|
<data android:mimeType="text/*" />
|
||||||
<intent-filter>
|
<data android:mimeType="video/*" />
|
||||||
<action android:name="android.net.VpnService"/>
|
</intent-filter>
|
||||||
</intent-filter>
|
<intent-filter>
|
||||||
</service>
|
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||||
<service
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
android:name=".QuickToggleService"
|
|
||||||
android:icon="@drawable/ic_tile"
|
|
||||||
android:label="@string/tile_name"
|
|
||||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
|
||||||
android:exported="true">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.service.quicksettings.action.QS_TILE"/>
|
|
||||||
</intent-filter>
|
|
||||||
</service>
|
|
||||||
</application>
|
|
||||||
</manifest>
|
|
||||||
|
|
||||||
|
<data android:mimeType="application/*" />
|
||||||
|
<data android:mimeType="audio/*" />
|
||||||
|
<data android:mimeType="image/*" />
|
||||||
|
<data android:mimeType="message/*" />
|
||||||
|
<data android:mimeType="multipart/*" />
|
||||||
|
<data android:mimeType="text/*" />
|
||||||
|
<data android:mimeType="video/*" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name="IPNReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.tailscale.ipn.CONNECT_VPN" />
|
||||||
|
<action android:name="com.tailscale.ipn.DISCONNECT_VPN" />
|
||||||
|
<action android:name="com.tailscale.ipn.USE_EXIT_NODE" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".IPNService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="systemExempted"
|
||||||
|
android:permission="android.permission.BIND_VPN_SERVICE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.net.VpnService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
<service
|
||||||
|
android:name=".QuickToggleService"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@drawable/ic_tile"
|
||||||
|
android:label="@string/tile_name"
|
||||||
|
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="android.content.APP_RESTRICTIONS"
|
||||||
|
android:resource="@xml/app_restrictions" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
@ -1,408 +0,0 @@
|
|||||||
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
|
||||||
// Use of this source code is governed by a BSD-style
|
|
||||||
// license that can be found in the LICENSE file.
|
|
||||||
|
|
||||||
package com.tailscale.ipn;
|
|
||||||
|
|
||||||
import android.app.Application;
|
|
||||||
import android.app.Activity;
|
|
||||||
import android.app.DownloadManager;
|
|
||||||
import android.app.Fragment;
|
|
||||||
import android.app.FragmentTransaction;
|
|
||||||
import android.app.NotificationChannel;
|
|
||||||
import android.app.PendingIntent;
|
|
||||||
import android.app.UiModeManager;
|
|
||||||
import android.content.BroadcastReceiver;
|
|
||||||
import android.content.ContentResolver;
|
|
||||||
import android.content.ContentValues;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.content.IntentFilter;
|
|
||||||
import android.content.SharedPreferences;
|
|
||||||
import android.content.pm.PackageManager;
|
|
||||||
import android.content.pm.PackageInfo;
|
|
||||||
import android.content.pm.Signature;
|
|
||||||
import android.content.res.Configuration;
|
|
||||||
import android.provider.MediaStore;
|
|
||||||
import android.provider.Settings;
|
|
||||||
import android.net.ConnectivityManager;
|
|
||||||
import android.net.LinkProperties;
|
|
||||||
import android.net.Network;
|
|
||||||
import android.net.NetworkInfo;
|
|
||||||
import android.net.NetworkRequest;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.net.VpnService;
|
|
||||||
import android.view.View;
|
|
||||||
import android.os.Build;
|
|
||||||
import android.os.Environment;
|
|
||||||
import android.os.Handler;
|
|
||||||
import android.os.Looper;
|
|
||||||
|
|
||||||
import android.Manifest;
|
|
||||||
import android.webkit.MimeTypeMap;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.FileOutputStream;
|
|
||||||
|
|
||||||
import java.lang.StringBuilder;
|
|
||||||
|
|
||||||
import java.net.InetAddress;
|
|
||||||
import java.net.InterfaceAddress;
|
|
||||||
import java.net.NetworkInterface;
|
|
||||||
|
|
||||||
import java.security.GeneralSecurityException;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
import androidx.core.app.NotificationCompat;
|
|
||||||
import androidx.core.app.NotificationManagerCompat;
|
|
||||||
|
|
||||||
import androidx.core.content.ContextCompat;
|
|
||||||
|
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences;
|
|
||||||
import androidx.security.crypto.MasterKey;
|
|
||||||
|
|
||||||
import androidx.browser.customtabs.CustomTabsIntent;
|
|
||||||
|
|
||||||
import org.gioui.Gio;
|
|
||||||
|
|
||||||
public class App extends Application {
|
|
||||||
private final static String PEER_TAG = "peer";
|
|
||||||
|
|
||||||
static final String STATUS_CHANNEL_ID = "tailscale-status";
|
|
||||||
static final int STATUS_NOTIFICATION_ID = 1;
|
|
||||||
|
|
||||||
static final String NOTIFY_CHANNEL_ID = "tailscale-notify";
|
|
||||||
static final int NOTIFY_NOTIFICATION_ID = 2;
|
|
||||||
|
|
||||||
private static final String FILE_CHANNEL_ID = "tailscale-files";
|
|
||||||
private static final int FILE_NOTIFICATION_ID = 3;
|
|
||||||
|
|
||||||
private final static Handler mainHandler = new Handler(Looper.getMainLooper());
|
|
||||||
|
|
||||||
public DnsConfig dns = new DnsConfig(this);
|
|
||||||
public DnsConfig getDnsConfigObj() { return this.dns; }
|
|
||||||
|
|
||||||
@Override public void onCreate() {
|
|
||||||
super.onCreate();
|
|
||||||
// Load and initialize the Go library.
|
|
||||||
Gio.init(this);
|
|
||||||
registerNetworkCallback();
|
|
||||||
|
|
||||||
createNotificationChannel(NOTIFY_CHANNEL_ID, "Notifications", NotificationManagerCompat.IMPORTANCE_DEFAULT);
|
|
||||||
createNotificationChannel(STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW);
|
|
||||||
createNotificationChannel(FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private void registerNetworkCallback() {
|
|
||||||
ConnectivityManager cMgr = (ConnectivityManager) this.getSystemService(Context.CONNECTIVITY_SERVICE);
|
|
||||||
cMgr.registerNetworkCallback(new NetworkRequest.Builder().build(), new ConnectivityManager.NetworkCallback() {
|
|
||||||
private void reportConnectivityChange() {
|
|
||||||
NetworkInfo active = cMgr.getActiveNetworkInfo();
|
|
||||||
// https://developer.android.com/training/monitoring-device-state/connectivity-status-type
|
|
||||||
boolean isConnected = active != null && active.isConnectedOrConnecting();
|
|
||||||
onConnectivityChanged(isConnected);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onLost(Network network) {
|
|
||||||
super.onLost(network);
|
|
||||||
this.reportConnectivityChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onLinkPropertiesChanged(Network network, LinkProperties linkProperties) {
|
|
||||||
super.onLinkPropertiesChanged(network, linkProperties);
|
|
||||||
this.reportConnectivityChange();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public void startVPN() {
|
|
||||||
Intent intent = new Intent(this, IPNService.class);
|
|
||||||
intent.setAction(IPNService.ACTION_CONNECT);
|
|
||||||
startService(intent);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void stopVPN() {
|
|
||||||
Intent intent = new Intent(this, IPNService.class);
|
|
||||||
intent.setAction(IPNService.ACTION_DISCONNECT);
|
|
||||||
startService(intent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// encryptToPref a byte array of data using the Jetpack Security
|
|
||||||
// library and writes it to a global encrypted preference store.
|
|
||||||
public void encryptToPref(String prefKey, String plaintext) throws IOException, GeneralSecurityException {
|
|
||||||
getEncryptedPrefs().edit().putString(prefKey, plaintext).commit();
|
|
||||||
}
|
|
||||||
|
|
||||||
// decryptFromPref decrypts a encrypted preference using the Jetpack Security
|
|
||||||
// library and returns the plaintext.
|
|
||||||
public String decryptFromPref(String prefKey) throws IOException, GeneralSecurityException {
|
|
||||||
return getEncryptedPrefs().getString(prefKey, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private SharedPreferences getEncryptedPrefs() throws IOException, GeneralSecurityException {
|
|
||||||
MasterKey key = new MasterKey.Builder(this)
|
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
return EncryptedSharedPreferences.create(
|
|
||||||
this,
|
|
||||||
"secret_shared_prefs",
|
|
||||||
key,
|
|
||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean autoConnect = false;
|
|
||||||
public boolean vpnReady = false;
|
|
||||||
|
|
||||||
void setTileReady(boolean ready) {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
QuickToggleService.setReady(this, ready);
|
|
||||||
android.util.Log.d("App", "Set Tile Ready: " + ready + " " + autoConnect);
|
|
||||||
|
|
||||||
vpnReady = ready;
|
|
||||||
if (ready && autoConnect) {
|
|
||||||
startVPN();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void setTileStatus(boolean status) {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
QuickToggleService.setStatus(this, status);
|
|
||||||
}
|
|
||||||
|
|
||||||
String getHostname() {
|
|
||||||
String userConfiguredDeviceName = getUserConfiguredDeviceName();
|
|
||||||
if (!isEmpty(userConfiguredDeviceName)) return userConfiguredDeviceName;
|
|
||||||
|
|
||||||
return getModelName();
|
|
||||||
}
|
|
||||||
|
|
||||||
String getModelName() {
|
|
||||||
String manu = Build.MANUFACTURER;
|
|
||||||
String model = Build.MODEL;
|
|
||||||
// Strip manufacturer from model.
|
|
||||||
int idx = model.toLowerCase().indexOf(manu.toLowerCase());
|
|
||||||
if (idx != -1) {
|
|
||||||
model = model.substring(idx + manu.length());
|
|
||||||
model = model.trim();
|
|
||||||
}
|
|
||||||
return manu + " " + model;
|
|
||||||
}
|
|
||||||
|
|
||||||
String getOSVersion() {
|
|
||||||
return Build.VERSION.RELEASE;
|
|
||||||
}
|
|
||||||
|
|
||||||
// get user defined nickname from Settings
|
|
||||||
// returns null if not available
|
|
||||||
private String getUserConfiguredDeviceName() {
|
|
||||||
String nameFromSystemDevice = Settings.Secure.getString(getContentResolver(), "device_name");
|
|
||||||
if (!isEmpty(nameFromSystemDevice)) return nameFromSystemDevice;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isEmpty(String str) {
|
|
||||||
return str == null || str.length() == 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// attachPeer adds a Peer fragment for tracking the Activity
|
|
||||||
// lifecycle.
|
|
||||||
void attachPeer(Activity act) {
|
|
||||||
act.runOnUiThread(new Runnable() {
|
|
||||||
@Override public void run() {
|
|
||||||
FragmentTransaction ft = act.getFragmentManager().beginTransaction();
|
|
||||||
ft.add(new Peer(), PEER_TAG);
|
|
||||||
ft.commit();
|
|
||||||
act.getFragmentManager().executePendingTransactions();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean isChromeOS() {
|
|
||||||
return getPackageManager().hasSystemFeature("android.hardware.type.pc");
|
|
||||||
}
|
|
||||||
|
|
||||||
void prepareVPN(Activity act, int reqCode) {
|
|
||||||
act.runOnUiThread(new Runnable() {
|
|
||||||
@Override public void run() {
|
|
||||||
Intent intent = VpnService.prepare(act);
|
|
||||||
if (intent == null) {
|
|
||||||
onVPNPrepared();
|
|
||||||
} else {
|
|
||||||
startActivityForResult(act, intent, reqCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static void startActivityForResult(Activity act, Intent intent, int request) {
|
|
||||||
Fragment f = act.getFragmentManager().findFragmentByTag(PEER_TAG);
|
|
||||||
f.startActivityForResult(intent, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
void showURL(Activity act, String url) {
|
|
||||||
act.runOnUiThread(new Runnable() {
|
|
||||||
@Override public void run() {
|
|
||||||
CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
|
|
||||||
int headerColor = 0xff496495;
|
|
||||||
builder.setToolbarColor(headerColor);
|
|
||||||
CustomTabsIntent intent = builder.build();
|
|
||||||
intent.launchUrl(act, Uri.parse(url));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// getPackageSignatureFingerprint returns the first package signing certificate, if any.
|
|
||||||
byte[] getPackageCertificate() throws Exception {
|
|
||||||
PackageInfo info;
|
|
||||||
info = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES);
|
|
||||||
for (Signature signature : info.signatures) {
|
|
||||||
return signature.toByteArray();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
void requestWriteStoragePermission(Activity act) {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
|
||||||
// We can write files without permission.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ContextCompat.checkSelfPermission(act, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
act.requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, IPNActivity.WRITE_STORAGE_RESULT);
|
|
||||||
}
|
|
||||||
|
|
||||||
String insertMedia(String name, String mimeType) throws IOException {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
ContentResolver resolver = getContentResolver();
|
|
||||||
ContentValues contentValues = new ContentValues();
|
|
||||||
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name);
|
|
||||||
if (!"".equals(mimeType)) {
|
|
||||||
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
|
|
||||||
}
|
|
||||||
Uri root = MediaStore.Files.getContentUri("external");
|
|
||||||
return resolver.insert(root, contentValues).toString();
|
|
||||||
} else {
|
|
||||||
File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
|
|
||||||
dir.mkdirs();
|
|
||||||
File f = new File(dir, name);
|
|
||||||
return Uri.fromFile(f).toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int openUri(String uri, String mode) throws IOException {
|
|
||||||
ContentResolver resolver = getContentResolver();
|
|
||||||
return resolver.openFileDescriptor(Uri.parse(uri), mode).detachFd();
|
|
||||||
}
|
|
||||||
|
|
||||||
void deleteUri(String uri) {
|
|
||||||
ContentResolver resolver = getContentResolver();
|
|
||||||
resolver.delete(Uri.parse(uri), null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void notifyFile(String uri, String msg) {
|
|
||||||
Intent viewIntent;
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
viewIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));
|
|
||||||
} else {
|
|
||||||
// uri is a file:// which is not allowed to be shared outside the app.
|
|
||||||
viewIntent = new Intent(DownloadManager.ACTION_VIEW_DOWNLOADS);
|
|
||||||
}
|
|
||||||
PendingIntent pending = PendingIntent.getActivity(this, 0, viewIntent, PendingIntent.FLAG_UPDATE_CURRENT);
|
|
||||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, FILE_CHANNEL_ID)
|
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
|
||||||
.setContentTitle("File received")
|
|
||||||
.setContentText(msg)
|
|
||||||
.setContentIntent(pending)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.setOnlyAlertOnce(true)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
|
|
||||||
|
|
||||||
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
|
|
||||||
nm.notify(FILE_NOTIFICATION_ID, builder.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
public void createNotificationChannel(String id, String name, int importance) {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
NotificationChannel channel = new NotificationChannel(id, name, importance);
|
|
||||||
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
|
|
||||||
nm.createNotificationChannel(channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
static native void onVPNPrepared();
|
|
||||||
private static native void onConnectivityChanged(boolean connected);
|
|
||||||
static native void onShareIntent(int nfiles, int[] types, String[] mimes, String[] items, String[] names, long[] sizes);
|
|
||||||
static native void onWriteStorageGranted();
|
|
||||||
|
|
||||||
// Returns details of the interfaces in the system, encoded as a single string for ease
|
|
||||||
// of JNI transfer over to the Go environment.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
// rmnet_data0 10 2000 true false false false false | fe80::4059:dc16:7ed3:9c6e%rmnet_data0/64
|
|
||||||
// dummy0 3 1500 true false false false false | fe80::1450:5cff:fe13:f891%dummy0/64
|
|
||||||
// wlan0 30 1500 true true false false true | fe80::2f60:2c82:4163:8389%wlan0/64 10.1.10.131/24
|
|
||||||
// r_rmnet_data0 21 1500 true false false false false | fe80::9318:6093:d1ad:ba7f%r_rmnet_data0/64
|
|
||||||
// rmnet_data2 12 1500 true false false false false | fe80::3c8c:44dc:46a9:9907%rmnet_data2/64
|
|
||||||
// r_rmnet_data1 22 1500 true false false false false | fe80::b6cd:5cb0:8ae6:fe92%r_rmnet_data1/64
|
|
||||||
// rmnet_data1 11 1500 true false false false false | fe80::51f2:ee00:edce:d68b%rmnet_data1/64
|
|
||||||
// lo 1 65536 true false true false false | ::1/128 127.0.0.1/8
|
|
||||||
// v4-rmnet_data2 68 1472 true true false true true | 192.0.0.4/32
|
|
||||||
//
|
|
||||||
// Where the fields are:
|
|
||||||
// name ifindex mtu isUp hasBroadcast isLoopback isPointToPoint hasMulticast | ip1/N ip2/N ip3/N;
|
|
||||||
String getInterfacesAsString() {
|
|
||||||
List<NetworkInterface> interfaces;
|
|
||||||
try {
|
|
||||||
interfaces = Collections.list(NetworkInterface.getNetworkInterfaces());
|
|
||||||
} catch (Exception e) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder("");
|
|
||||||
for (NetworkInterface nif : interfaces) {
|
|
||||||
try {
|
|
||||||
// Android doesn't have a supportsBroadcast() but the Go net.Interface wants
|
|
||||||
// one, so we say the interface has broadcast if it has multicast.
|
|
||||||
sb.append(String.format(java.util.Locale.ROOT, "%s %d %d %b %b %b %b %b |", nif.getName(),
|
|
||||||
nif.getIndex(), nif.getMTU(), nif.isUp(), nif.supportsMulticast(),
|
|
||||||
nif.isLoopback(), nif.isPointToPoint(), nif.supportsMulticast()));
|
|
||||||
|
|
||||||
for (InterfaceAddress ia : nif.getInterfaceAddresses()) {
|
|
||||||
// InterfaceAddress == hostname + "/" + IP
|
|
||||||
String[] parts = ia.toString().split("/", 0);
|
|
||||||
if (parts.length > 1) {
|
|
||||||
sb.append(String.format(java.util.Locale.ROOT, "%s/%d ", parts[1], ia.getNetworkPrefixLength()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
// TODO(dgentry) should log the exception not silently suppress it.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
sb.append("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean isTV() {
|
|
||||||
UiModeManager mm = (UiModeManager)getSystemService(UI_MODE_SERVICE);
|
|
||||||
return mm.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION;
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,537 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
package com.tailscale.ipn
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Application
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.ViewModelStore
|
||||||
|
import androidx.lifecycle.ViewModelStoreOwner
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import com.tailscale.ipn.mdm.MDMSettings
|
||||||
|
import com.tailscale.ipn.ui.localapi.Client
|
||||||
|
import com.tailscale.ipn.ui.localapi.Request
|
||||||
|
import com.tailscale.ipn.ui.model.Ipn
|
||||||
|
import com.tailscale.ipn.ui.notifier.HealthNotifier
|
||||||
|
import com.tailscale.ipn.ui.notifier.Notifier
|
||||||
|
import com.tailscale.ipn.ui.viewModel.VpnViewModel
|
||||||
|
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
|
||||||
|
import com.tailscale.ipn.util.TSLog
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import libtailscale.Libtailscale
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.NetworkInterface
|
||||||
|
import java.security.GeneralSecurityException
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
|
||||||
|
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val FILE_CHANNEL_ID = "tailscale-files"
|
||||||
|
private const val TAG = "App"
|
||||||
|
private lateinit var appInstance: App
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the app (if necessary) and returns the singleton app instance. Always use this
|
||||||
|
* function to obtain an App reference to make sure the app initializes.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun get(): App {
|
||||||
|
appInstance.initOnce()
|
||||||
|
return appInstance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val dns = DnsConfig()
|
||||||
|
private lateinit var connectivityManager: ConnectivityManager
|
||||||
|
private lateinit var app: libtailscale.Application
|
||||||
|
|
||||||
|
override val viewModelStore: ViewModelStore
|
||||||
|
get() = appViewModelStore
|
||||||
|
|
||||||
|
private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() }
|
||||||
|
|
||||||
|
var healthNotifier: HealthNotifier? = null
|
||||||
|
|
||||||
|
override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString
|
||||||
|
|
||||||
|
override fun getInstallSource(): String = AppSourceChecker.getInstallSource(this)
|
||||||
|
|
||||||
|
override fun shouldUseGoogleDNSFallback(): Boolean = BuildConfig.USE_GOOGLE_DNS_FALLBACK
|
||||||
|
|
||||||
|
override fun log(s: String, s1: String) {
|
||||||
|
Log.d(s, s1)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createNotificationChannel(
|
||||||
|
STATUS_CHANNEL_ID,
|
||||||
|
getString(R.string.vpn_status),
|
||||||
|
getString(R.string.optional_notifications_which_display_the_status_of_the_vpn_tunnel),
|
||||||
|
NotificationManagerCompat.IMPORTANCE_MIN)
|
||||||
|
createNotificationChannel(
|
||||||
|
FILE_CHANNEL_ID,
|
||||||
|
getString(R.string.taildrop_file_transfers),
|
||||||
|
getString(R.string.notifications_delivered_when_a_file_is_received_using_taildrop),
|
||||||
|
NotificationManagerCompat.IMPORTANCE_DEFAULT)
|
||||||
|
createNotificationChannel(
|
||||||
|
HealthNotifier.HEALTH_CHANNEL_ID,
|
||||||
|
getString(R.string.health_channel_name),
|
||||||
|
getString(R.string.health_channel_description),
|
||||||
|
NotificationManagerCompat.IMPORTANCE_HIGH)
|
||||||
|
appInstance = this
|
||||||
|
setUnprotectedInstance(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTerminate() {
|
||||||
|
super.onTerminate()
|
||||||
|
Notifier.stop()
|
||||||
|
notificationManager.cancelAll()
|
||||||
|
applicationScope.cancel()
|
||||||
|
viewModelStore.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isInitialized = false
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun initOnce() {
|
||||||
|
if (isInitialized) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isInitialized = true
|
||||||
|
|
||||||
|
val dataDir = this.filesDir.absolutePath
|
||||||
|
|
||||||
|
// Set this to enable direct mode for taildrop whereby downloads will be saved directly
|
||||||
|
// to the given folder. We will preferentially use <shared>/Downloads and fallback to
|
||||||
|
// an app local directory "Taildrop" if we cannot create that. This mode does not support
|
||||||
|
// user notifications for incoming files.
|
||||||
|
val directFileDir = this.prepareDownloadsFolder()
|
||||||
|
app = Libtailscale.start(dataDir, directFileDir.absolutePath, this)
|
||||||
|
Request.setApp(app)
|
||||||
|
Notifier.setApp(app)
|
||||||
|
Notifier.start(applicationScope)
|
||||||
|
healthNotifier = HealthNotifier(Notifier.health, applicationScope)
|
||||||
|
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
|
||||||
|
initViewModels()
|
||||||
|
applicationScope.launch {
|
||||||
|
Notifier.state.collect { state ->
|
||||||
|
val ableToStartVPN = state > Ipn.State.NeedsMachineAuth
|
||||||
|
// If VPN is stopped, show a disconnected notification. If it is running as a foregrround
|
||||||
|
// service, IPNService will show a connected notification.
|
||||||
|
if (state == Ipn.State.Stopped) {
|
||||||
|
notifyStatus(false)
|
||||||
|
}
|
||||||
|
val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running
|
||||||
|
updateConnStatus(ableToStartVPN)
|
||||||
|
QuickToggleService.setVPNRunning(vpnRunning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initViewModels() {
|
||||||
|
vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setWantRunning(wantRunning: Boolean, onSuccess: (() -> Unit)? = null) {
|
||||||
|
val callback: (Result<Ipn.Prefs>) -> Unit = { result ->
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { onSuccess?.invoke() },
|
||||||
|
onFailure = { error ->
|
||||||
|
TSLog.d("TAG", "Set want running: failed to update preferences: ${error.message}")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Client(applicationScope)
|
||||||
|
.editPrefs(Ipn.MaskedPrefs().apply { WantRunning = wantRunning }, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptToPref a byte array of data using the Jetpack Security
|
||||||
|
// library and writes it to a global encrypted preference store.
|
||||||
|
@Throws(IOException::class, GeneralSecurityException::class)
|
||||||
|
override fun encryptToPref(prefKey: String?, plaintext: String?) {
|
||||||
|
getEncryptedPrefs().edit().putString(prefKey, plaintext).commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptFromPref decrypts a encrypted preference using the Jetpack Security
|
||||||
|
// library and returns the plaintext.
|
||||||
|
@Throws(IOException::class, GeneralSecurityException::class)
|
||||||
|
override fun decryptFromPref(prefKey: String?): String? {
|
||||||
|
return getEncryptedPrefs().getString(prefKey, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class, GeneralSecurityException::class)
|
||||||
|
fun getEncryptedPrefs(): SharedPreferences {
|
||||||
|
val key = MasterKey.Builder(this).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
|
||||||
|
|
||||||
|
return EncryptedSharedPreferences.create(
|
||||||
|
this,
|
||||||
|
"secret_shared_prefs",
|
||||||
|
key,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* setAbleToStartVPN remembers whether or not we're able to start the VPN
|
||||||
|
* by storing this in a shared preference. This allows us to check this
|
||||||
|
* value without needing a fully initialized instance of the application.
|
||||||
|
*/
|
||||||
|
private fun updateConnStatus(ableToStartVPN: Boolean) {
|
||||||
|
setAbleToStartVPN(ableToStartVPN)
|
||||||
|
QuickToggleService.updateTile()
|
||||||
|
TSLog.d("App", "Set Tile Ready: $ableToStartVPN")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getModelName(): String {
|
||||||
|
val manu = Build.MANUFACTURER
|
||||||
|
var model = Build.MODEL
|
||||||
|
// Strip manufacturer from model.
|
||||||
|
val idx = model.lowercase(Locale.getDefault()).indexOf(manu.lowercase(Locale.getDefault()))
|
||||||
|
if (idx != -1) {
|
||||||
|
model = model.substring(idx + manu.length).trim()
|
||||||
|
}
|
||||||
|
return "$manu $model"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getOSVersion(): String = Build.VERSION.RELEASE
|
||||||
|
|
||||||
|
override fun isChromeOS(): Boolean {
|
||||||
|
return packageManager.hasSystemFeature("android.hardware.type.pc")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getInterfacesAsString(): String {
|
||||||
|
val interfaces: ArrayList<NetworkInterface> =
|
||||||
|
java.util.Collections.list(NetworkInterface.getNetworkInterfaces())
|
||||||
|
|
||||||
|
val sb = StringBuilder()
|
||||||
|
for (nif in interfaces) {
|
||||||
|
try {
|
||||||
|
sb.append(
|
||||||
|
String.format(
|
||||||
|
Locale.ROOT,
|
||||||
|
"%s %d %d %b %b %b %b %b |",
|
||||||
|
nif.name,
|
||||||
|
nif.index,
|
||||||
|
nif.mtu,
|
||||||
|
nif.isUp,
|
||||||
|
nif.supportsMulticast(),
|
||||||
|
nif.isLoopback,
|
||||||
|
nif.isPointToPoint,
|
||||||
|
nif.supportsMulticast()))
|
||||||
|
|
||||||
|
for (ia in nif.interfaceAddresses) {
|
||||||
|
val parts = ia.toString().split("/", limit = 0)
|
||||||
|
if (parts.size > 1) {
|
||||||
|
sb.append(String.format(Locale.ROOT, "%s/%d ", parts[1], ia.networkPrefixLength))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sb.append("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prepareDownloadsFolder(): File {
|
||||||
|
var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!downloads.exists()) {
|
||||||
|
downloads.mkdirs()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TSLog.e(TAG, "Failed to create downloads folder: $e")
|
||||||
|
downloads = File(this.filesDir, "Taildrop")
|
||||||
|
try {
|
||||||
|
if (!downloads.exists()) {
|
||||||
|
downloads.mkdirs()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TSLog.e(TAG, "Failed to create Taildrop folder: $e")
|
||||||
|
downloads = File("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return downloads
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(
|
||||||
|
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
|
||||||
|
override fun getSyspolicyBooleanValue(key: String): Boolean {
|
||||||
|
return getSyspolicyStringValue(key) == "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(
|
||||||
|
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
|
||||||
|
override fun getSyspolicyStringValue(key: String): String {
|
||||||
|
val setting = MDMSettings.allSettingsByKey[key]?.flow?.value
|
||||||
|
if (setting?.isSet != true) {
|
||||||
|
throw MDMSettings.NoSuchKeyException()
|
||||||
|
}
|
||||||
|
return setting.value?.toString() ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(
|
||||||
|
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
|
||||||
|
override fun getSyspolicyStringArrayJSONValue(key: String): String {
|
||||||
|
val setting = MDMSettings.allSettingsByKey[key]?.flow?.value
|
||||||
|
if (setting?.isSet != true) {
|
||||||
|
throw MDMSettings.NoSuchKeyException()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val list = setting.value as? List<*>
|
||||||
|
return Json.encodeToString(list)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TSLog.d("MDM", "$key value cannot be serialized to JSON. Throwing NoSuchKeyException.")
|
||||||
|
throw MDMSettings.NoSuchKeyException()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notifyPolicyChanged() {
|
||||||
|
app.notifyPolicyChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UninitializedApp contains all of the methods of App that can be used without having to initialize
|
||||||
|
* the Go backend. This is useful when you want to access functions on the App without creating side
|
||||||
|
* effects from starting the Go backend (such as launching the VPN).
|
||||||
|
*/
|
||||||
|
open class UninitializedApp : Application() {
|
||||||
|
companion object {
|
||||||
|
const val TAG = "UninitializedApp"
|
||||||
|
|
||||||
|
const val STATUS_NOTIFICATION_ID = 1
|
||||||
|
const val STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID = 2
|
||||||
|
const val STATUS_CHANNEL_ID = "tailscale-status"
|
||||||
|
|
||||||
|
// Key for shared preference that tracks whether or not we're able to start
|
||||||
|
// the VPN (i.e. we're logged in and machine is authorized).
|
||||||
|
private const val ABLE_TO_START_VPN_KEY = "ableToStartVPN"
|
||||||
|
|
||||||
|
private const val DISALLOWED_APPS_KEY = "disallowedApps"
|
||||||
|
|
||||||
|
// File for shared preferences that are not encrypted.
|
||||||
|
private const val UNENCRYPTED_PREFERENCES = "unencrypted"
|
||||||
|
|
||||||
|
private lateinit var appInstance: UninitializedApp
|
||||||
|
lateinit var notificationManager: NotificationManagerCompat
|
||||||
|
|
||||||
|
lateinit var vpnViewModel: VpnViewModel
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun get(): UninitializedApp {
|
||||||
|
return appInstance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun setUnprotectedInstance(instance: UninitializedApp) {
|
||||||
|
appInstance = instance
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun setAbleToStartVPN(rdy: Boolean) {
|
||||||
|
getUnencryptedPrefs().edit().putBoolean(ABLE_TO_START_VPN_KEY, rdy).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This function can be called without initializing the App. */
|
||||||
|
fun isAbleToStartVPN(): Boolean {
|
||||||
|
return getUnencryptedPrefs().getBoolean(ABLE_TO_START_VPN_KEY, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getUnencryptedPrefs(): SharedPreferences {
|
||||||
|
return getSharedPreferences(UNENCRYPTED_PREFERENCES, MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startVPN() {
|
||||||
|
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_START_VPN }
|
||||||
|
try {
|
||||||
|
startForegroundService(intent)
|
||||||
|
} catch (foregroundServiceStartException: IllegalStateException) {
|
||||||
|
TSLog.e(
|
||||||
|
TAG,
|
||||||
|
"startVPN hit ForegroundServiceStartNotAllowedException in startForegroundService(): $foregroundServiceStartException")
|
||||||
|
} catch (securityException: SecurityException) {
|
||||||
|
TSLog.e(TAG, "startVPN hit SecurityException in startForegroundService(): $securityException")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TSLog.e(TAG, "startVPN hit exception in startForegroundService(): $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopVPN() {
|
||||||
|
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_STOP_VPN }
|
||||||
|
try {
|
||||||
|
startService(intent)
|
||||||
|
} catch (illegalStateException: IllegalStateException) {
|
||||||
|
TSLog.e(TAG, "stopVPN hit IllegalStateException in startService(): $illegalStateException")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TSLog.e(TAG, "stopVPN hit exception in startService(): $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calls stopVPN() followed by startVPN() to restart the VPN.
|
||||||
|
fun restartVPN() {
|
||||||
|
stopVPN()
|
||||||
|
startVPN()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createNotificationChannel(id: String, name: String, description: String, importance: Int) {
|
||||||
|
val channel = NotificationChannel(id, name, importance)
|
||||||
|
channel.description = description
|
||||||
|
notificationManager = NotificationManagerCompat.from(this)
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notifyStatus(vpnRunning: Boolean) {
|
||||||
|
notifyStatus(buildStatusNotification(vpnRunning))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notifyStatus(notification: Notification) {
|
||||||
|
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||||
|
PackageManager.PERMISSION_GRANTED) {
|
||||||
|
// TODO: Consider calling
|
||||||
|
// ActivityCompat#requestPermissions
|
||||||
|
// here to request the missing permissions, and then overriding
|
||||||
|
// public void onRequestPermissionsResult(int requestCode, String[] permissions,
|
||||||
|
// int[] grantResults)
|
||||||
|
// to handle the case where the user grants the permission. See the documentation
|
||||||
|
// for ActivityCompat#requestPermissions for more details.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notificationManager.notify(STATUS_NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildStatusNotification(vpnRunning: Boolean): Notification {
|
||||||
|
val message = getString(if (vpnRunning) R.string.connected else R.string.not_connected)
|
||||||
|
val icon = if (vpnRunning) R.drawable.ic_notification else R.drawable.ic_notification_disabled
|
||||||
|
val action =
|
||||||
|
if (vpnRunning) IPNReceiver.INTENT_DISCONNECT_VPN else IPNReceiver.INTENT_CONNECT_VPN
|
||||||
|
val actionLabel = getString(if (vpnRunning) R.string.disconnect else R.string.connect)
|
||||||
|
val buttonIntent = Intent(this, IPNReceiver::class.java).apply { this.action = action }
|
||||||
|
val pendingButtonIntent: PendingIntent =
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
buttonIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
|
||||||
|
val intent =
|
||||||
|
Intent(this, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
|
}
|
||||||
|
val pendingIntent: PendingIntent =
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
|
||||||
|
.setSmallIcon(icon)
|
||||||
|
.setContentTitle("Tailscale")
|
||||||
|
.setContentText(message)
|
||||||
|
.setAutoCancel(!vpnRunning)
|
||||||
|
.setOnlyAlertOnce(!vpnRunning)
|
||||||
|
.setOngoing(vpnRunning)
|
||||||
|
.setSilent(true)
|
||||||
|
.setOngoing(false)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.addAction(NotificationCompat.Action.Builder(0, actionLabel, pendingButtonIntent).build())
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addUserDisallowedPackageName(packageName: String) {
|
||||||
|
if (packageName.isEmpty()) {
|
||||||
|
TSLog.e(TAG, "addUserDisallowedPackageName called with empty packageName")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
getUnencryptedPrefs()
|
||||||
|
.edit()
|
||||||
|
.putStringSet(
|
||||||
|
DISALLOWED_APPS_KEY, disallowedPackageNames().toMutableSet().union(setOf(packageName)))
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
this.restartVPN()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeUserDisallowedPackageName(packageName: String) {
|
||||||
|
if (packageName.isEmpty()) {
|
||||||
|
TSLog.e(TAG, "removeUserDisallowedPackageName called with empty packageName")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
getUnencryptedPrefs()
|
||||||
|
.edit()
|
||||||
|
.putStringSet(
|
||||||
|
DISALLOWED_APPS_KEY,
|
||||||
|
disallowedPackageNames().toMutableSet().subtract(setOf(packageName)))
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
this.restartVPN()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disallowedPackageNames(): List<String> {
|
||||||
|
val mdmDisallowed =
|
||||||
|
MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
|
||||||
|
if (mdmDisallowed.isNotEmpty()) {
|
||||||
|
TSLog.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed")
|
||||||
|
return builtInDisallowedPackageNames + mdmDisallowed
|
||||||
|
}
|
||||||
|
val userDisallowed =
|
||||||
|
getUnencryptedPrefs().getStringSet(DISALLOWED_APPS_KEY, emptySet())?.toList() ?: emptyList()
|
||||||
|
return builtInDisallowedPackageNames + userDisallowed
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAppScopedViewModel(): VpnViewModel {
|
||||||
|
return vpnViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
val builtInDisallowedPackageNames: List<String> =
|
||||||
|
listOf(
|
||||||
|
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
|
||||||
|
"com.google.android.apps.messaging",
|
||||||
|
// Android Auto https://github.com/tailscale/tailscale/issues/3828
|
||||||
|
"com.google.android.projection.gearhead",
|
||||||
|
// GoPro https://github.com/tailscale/tailscale/issues/2554
|
||||||
|
"com.gopro.smarty",
|
||||||
|
// Sonos https://github.com/tailscale/tailscale/issues/2548
|
||||||
|
"com.sonos.acr",
|
||||||
|
"com.sonos.acr2",
|
||||||
|
// Google Chromecast https://github.com/tailscale/tailscale/issues/3636
|
||||||
|
"com.google.android.apps.chromecast.app",
|
||||||
|
// Voicemail https://github.com/tailscale/tailscale/issues/13199
|
||||||
|
"com.samsung.attvvm",
|
||||||
|
"com.att.mobile.android.vvm",
|
||||||
|
"com.tmobile.vvm.application",
|
||||||
|
"com.metropcs.service.vvm",
|
||||||
|
"com.mizmowireless.vvm",
|
||||||
|
"com.vna.service.vvm",
|
||||||
|
"com.dish.vvm",
|
||||||
|
"com.comcast.modesto.vvm.client",
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
package com.tailscale.ipn
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
object AppSourceChecker {
|
||||||
|
|
||||||
|
const val TAG = "AppSourceChecker"
|
||||||
|
|
||||||
|
fun getInstallSource(context: Context): String {
|
||||||
|
val packageManager = context.packageManager
|
||||||
|
val packageName = context.packageName
|
||||||
|
Log.d(TAG, "Package name: $packageName")
|
||||||
|
|
||||||
|
val installerPackageName =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
packageManager.getInstallSourceInfo(packageName).installingPackageName
|
||||||
|
} else {
|
||||||
|
@Suppress("deprecation") packageManager.getInstallerPackageName(packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Installer package name: $installerPackageName")
|
||||||
|
|
||||||
|
return when (installerPackageName) {
|
||||||
|
"com.android.vending" -> "googleplay"
|
||||||
|
"org.fdroid.fdroid" -> "fdroid"
|
||||||
|
"com.amazon.venezia" -> "amazon"
|
||||||
|
null -> "unknown"
|
||||||
|
else -> "unknown($installerPackageName)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,133 +0,0 @@
|
|||||||
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
|
||||||
// Use of this source code is governed by a BSD-style
|
|
||||||
// license that can be found in the LICENSE file.
|
|
||||||
|
|
||||||
package com.tailscale.ipn;
|
|
||||||
|
|
||||||
import android.app.Activity;
|
|
||||||
import android.content.res.AssetFileDescriptor;
|
|
||||||
import android.content.res.Configuration;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.database.Cursor;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.provider.OpenableColumns;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.content.pm.PackageManager;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
|
|
||||||
import org.gioui.GioView;
|
|
||||||
|
|
||||||
public final class IPNActivity extends Activity {
|
|
||||||
final static int WRITE_STORAGE_RESULT = 1000;
|
|
||||||
|
|
||||||
private GioView view;
|
|
||||||
|
|
||||||
@Override public void onCreate(Bundle state) {
|
|
||||||
super.onCreate(state);
|
|
||||||
view = new GioView(this);
|
|
||||||
setContentView(view);
|
|
||||||
handleIntent();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onNewIntent(Intent i) {
|
|
||||||
setIntent(i);
|
|
||||||
handleIntent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleIntent() {
|
|
||||||
Intent it = getIntent();
|
|
||||||
String act = it.getAction();
|
|
||||||
String[] texts;
|
|
||||||
Uri[] uris;
|
|
||||||
if (Intent.ACTION_SEND.equals(act)) {
|
|
||||||
uris = new Uri[]{it.getParcelableExtra(Intent.EXTRA_STREAM)};
|
|
||||||
texts = new String[]{it.getStringExtra(Intent.EXTRA_TEXT)};
|
|
||||||
} else if (Intent.ACTION_SEND_MULTIPLE.equals(act)) {
|
|
||||||
List<Uri> extraUris = it.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
|
|
||||||
uris = extraUris.toArray(new Uri[0]);
|
|
||||||
texts = new String[uris.length];
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String mime = it.getType();
|
|
||||||
int nitems = uris.length;
|
|
||||||
String[] items = new String[nitems];
|
|
||||||
String[] mimes = new String[nitems];
|
|
||||||
int[] types = new int[nitems];
|
|
||||||
String[] names = new String[nitems];
|
|
||||||
long[] sizes = new long[nitems];
|
|
||||||
int nfiles = 0;
|
|
||||||
for (int i = 0; i < uris.length; i++) {
|
|
||||||
String text = texts[i];
|
|
||||||
Uri uri = uris[i];
|
|
||||||
if (text != null) {
|
|
||||||
types[nfiles] = 1; // FileTypeText
|
|
||||||
names[nfiles] = "file.txt";
|
|
||||||
mimes[nfiles] = mime;
|
|
||||||
items[nfiles] = text;
|
|
||||||
// Determined by len(text) in Go to eliminate UTF-8 encoding differences.
|
|
||||||
sizes[nfiles] = 0;
|
|
||||||
nfiles++;
|
|
||||||
} else if (uri != null) {
|
|
||||||
Cursor c = getContentResolver().query(uri, null, null, null, null);
|
|
||||||
if (c == null) {
|
|
||||||
// Ignore files we have no permission to access.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
int nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
|
||||||
int sizeCol = c.getColumnIndex(OpenableColumns.SIZE);
|
|
||||||
c.moveToFirst();
|
|
||||||
String name = c.getString(nameCol);
|
|
||||||
long size = c.getLong(sizeCol);
|
|
||||||
types[nfiles] = 2; // FileTypeURI
|
|
||||||
mimes[nfiles] = mime;
|
|
||||||
items[nfiles] = uri.toString();
|
|
||||||
names[nfiles] = name;
|
|
||||||
sizes[nfiles] = size;
|
|
||||||
nfiles++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
App.onShareIntent(nfiles, types, mimes, items, names, sizes);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onRequestPermissionsResult(int reqCode, String[] perms, int[] grants) {
|
|
||||||
switch (reqCode) {
|
|
||||||
case WRITE_STORAGE_RESULT:
|
|
||||||
if (grants.length > 0 && grants[0] == PackageManager.PERMISSION_GRANTED) {
|
|
||||||
App.onWriteStorageGranted();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onDestroy() {
|
|
||||||
view.destroy();
|
|
||||||
super.onDestroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onStart() {
|
|
||||||
super.onStart();
|
|
||||||
view.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onStop() {
|
|
||||||
view.stop();
|
|
||||||
super.onStop();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onConfigurationChanged(Configuration c) {
|
|
||||||
super.onConfigurationChanged(c);
|
|
||||||
view.configurationChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onLowMemory() {
|
|
||||||
super.onLowMemory();
|
|
||||||
view.onLowMemory();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onBackPressed() {
|
|
||||||
if (!view.backPressed())
|
|
||||||
super.onBackPressed();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,26 +1,45 @@
|
|||||||
// Copyright (c) 2023 Tailscale Inc & AUTHORS All rights reserved.
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
// Use of this source code is governed by a BSD-style
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
// license that can be found in the LICENSE file.
|
|
||||||
|
|
||||||
package com.tailscale.ipn;
|
package com.tailscale.ipn;
|
||||||
|
|
||||||
import android.content.BroadcastReceiver;
|
import android.content.BroadcastReceiver;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import androidx.work.WorkManager;
|
import androidx.work.Data;
|
||||||
|
|
||||||
import androidx.work.OneTimeWorkRequest;
|
import androidx.work.OneTimeWorkRequest;
|
||||||
|
import androidx.work.WorkManager;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IPNReceiver allows external applications to start the VPN.
|
||||||
|
*/
|
||||||
public class IPNReceiver extends BroadcastReceiver {
|
public class IPNReceiver extends BroadcastReceiver {
|
||||||
|
|
||||||
|
public static final String INTENT_CONNECT_VPN = "com.tailscale.ipn.CONNECT_VPN";
|
||||||
|
public static final String INTENT_DISCONNECT_VPN = "com.tailscale.ipn.DISCONNECT_VPN";
|
||||||
|
|
||||||
|
private static final String INTENT_USE_EXIT_NODE = "com.tailscale.ipn.USE_EXIT_NODE";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onReceive(Context context, Intent intent) {
|
public void onReceive(Context context, Intent intent) {
|
||||||
WorkManager workManager = WorkManager.getInstance(context);
|
WorkManager workManager = WorkManager.getInstance(context);
|
||||||
|
|
||||||
// On the relevant action, start the relevant worker, which can stay active for longer than this receiver can.
|
// On the relevant action, start the relevant worker, which can stay active for longer than this receiver can.
|
||||||
if (intent.getAction() == "com.tailscale.ipn.CONNECT_VPN") {
|
if (Objects.equals(intent.getAction(), INTENT_CONNECT_VPN)) {
|
||||||
workManager.enqueue(new OneTimeWorkRequest.Builder(StartVPNWorker.class).build());
|
workManager.enqueue(new OneTimeWorkRequest.Builder(StartVPNWorker.class).build());
|
||||||
} else if (intent.getAction() == "com.tailscale.ipn.DISCONNECT_VPN") {
|
} else if (Objects.equals(intent.getAction(), INTENT_DISCONNECT_VPN)) {
|
||||||
workManager.enqueue(new OneTimeWorkRequest.Builder(StopVPNWorker.class).build());
|
workManager.enqueue(new OneTimeWorkRequest.Builder(StopVPNWorker.class).build());
|
||||||
}
|
}
|
||||||
|
else if (Objects.equals(intent.getAction(), INTENT_USE_EXIT_NODE)) {
|
||||||
|
String exitNode = intent.getStringExtra("exitNode");
|
||||||
|
boolean allowLanAccess = intent.getBooleanExtra("allowLanAccess", false);
|
||||||
|
Data.Builder workData = new Data.Builder();
|
||||||
|
workData.putString(UseExitNodeWorker.EXIT_NODE_NAME, exitNode);
|
||||||
|
workData.putBoolean(UseExitNodeWorker.ALLOW_LAN_ACCESS, allowLanAccess);
|
||||||
|
workManager.enqueue(new OneTimeWorkRequest.Builder(UseExitNodeWorker.class).setInputData(workData.build()).build());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,126 +0,0 @@
|
|||||||
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
|
||||||
// Use of this source code is governed by a BSD-style
|
|
||||||
// license that can be found in the LICENSE file.
|
|
||||||
|
|
||||||
package com.tailscale.ipn;
|
|
||||||
|
|
||||||
import android.os.Build;
|
|
||||||
import android.app.PendingIntent;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.content.pm.PackageManager;
|
|
||||||
import android.net.VpnService;
|
|
||||||
import android.system.OsConstants;
|
|
||||||
|
|
||||||
import org.gioui.GioActivity;
|
|
||||||
|
|
||||||
import androidx.core.app.NotificationCompat;
|
|
||||||
import androidx.core.app.NotificationManagerCompat;
|
|
||||||
|
|
||||||
public class IPNService extends VpnService {
|
|
||||||
public static final String ACTION_CONNECT = "com.tailscale.ipn.CONNECT";
|
|
||||||
public static final String ACTION_DISCONNECT = "com.tailscale.ipn.DISCONNECT";
|
|
||||||
|
|
||||||
@Override public int onStartCommand(Intent intent, int flags, int startId) {
|
|
||||||
if (intent != null && ACTION_DISCONNECT.equals(intent.getAction())) {
|
|
||||||
((App)getApplicationContext()).autoConnect = false;
|
|
||||||
close();
|
|
||||||
return START_NOT_STICKY;
|
|
||||||
}
|
|
||||||
connect();
|
|
||||||
App app = ((App)getApplicationContext());
|
|
||||||
if (app.vpnReady && app.autoConnect) {
|
|
||||||
directConnect();
|
|
||||||
}
|
|
||||||
return START_STICKY;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void close() {
|
|
||||||
stopForeground(true);
|
|
||||||
disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onDestroy() {
|
|
||||||
close();
|
|
||||||
super.onDestroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onRevoke() {
|
|
||||||
close();
|
|
||||||
super.onRevoke();
|
|
||||||
}
|
|
||||||
|
|
||||||
private PendingIntent configIntent() {
|
|
||||||
return PendingIntent.getActivity(this, 0, new Intent(this, IPNActivity.class),
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void disallowApp(VpnService.Builder b, String name) {
|
|
||||||
try {
|
|
||||||
b.addDisallowedApplication(name);
|
|
||||||
} catch (PackageManager.NameNotFoundException e) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected VpnService.Builder newBuilder() {
|
|
||||||
VpnService.Builder b = new VpnService.Builder()
|
|
||||||
.setConfigureIntent(configIntent())
|
|
||||||
.allowFamily(OsConstants.AF_INET)
|
|
||||||
.allowFamily(OsConstants.AF_INET6);
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
|
||||||
b.setMetered(false); // Inherit the metered status from the underlying networks.
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
|
||||||
b.setUnderlyingNetworks(null); // Use all available networks.
|
|
||||||
|
|
||||||
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
|
|
||||||
this.disallowApp(b, "com.google.android.apps.messaging");
|
|
||||||
|
|
||||||
// Stadia https://github.com/tailscale/tailscale/issues/3460
|
|
||||||
this.disallowApp(b, "com.google.stadia.android");
|
|
||||||
|
|
||||||
// Android Auto https://github.com/tailscale/tailscale/issues/3828
|
|
||||||
this.disallowApp(b, "com.google.android.projection.gearhead");
|
|
||||||
|
|
||||||
// GoPro https://github.com/tailscale/tailscale/issues/2554
|
|
||||||
this.disallowApp(b, "com.gopro.smarty");
|
|
||||||
|
|
||||||
// Sonos https://github.com/tailscale/tailscale/issues/2548
|
|
||||||
this.disallowApp(b, "com.sonos.acr");
|
|
||||||
this.disallowApp(b, "com.sonos.acr2");
|
|
||||||
|
|
||||||
// Google Chromecast https://github.com/tailscale/tailscale/issues/3636
|
|
||||||
this.disallowApp(b, "com.google.android.apps.chromecast.app");
|
|
||||||
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void notify(String title, String message) {
|
|
||||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.NOTIFY_CHANNEL_ID)
|
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setContentText(message)
|
|
||||||
.setContentIntent(configIntent())
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.setOnlyAlertOnce(true)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
|
|
||||||
|
|
||||||
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
|
|
||||||
nm.notify(App.NOTIFY_NOTIFICATION_ID, builder.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
public void updateStatusNotification(String title, String message) {
|
|
||||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.STATUS_CHANNEL_ID)
|
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setContentText(message)
|
|
||||||
.setContentIntent(configIntent())
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW);
|
|
||||||
|
|
||||||
startForeground(App.STATUS_NOTIFICATION_ID, builder.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
private native void connect();
|
|
||||||
private native void disconnect();
|
|
||||||
|
|
||||||
public native void directConnect();
|
|
||||||
}
|
|
@ -0,0 +1,164 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
package com.tailscale.ipn
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.VpnService
|
||||||
|
import android.os.Build
|
||||||
|
import android.system.OsConstants
|
||||||
|
import com.tailscale.ipn.mdm.MDMSettings
|
||||||
|
import com.tailscale.ipn.ui.model.Ipn
|
||||||
|
import com.tailscale.ipn.ui.notifier.Notifier
|
||||||
|
import com.tailscale.ipn.util.TSLog
|
||||||
|
import libtailscale.Libtailscale
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
open class IPNService : VpnService(), libtailscale.IPNService {
|
||||||
|
private val TAG = "IPNService"
|
||||||
|
private val randomID: String = UUID.randomUUID().toString()
|
||||||
|
private lateinit var app: App
|
||||||
|
|
||||||
|
override fun id(): String {
|
||||||
|
return randomID
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateVpnStatus(status: Boolean) {
|
||||||
|
app.getAppScopedViewModel().setVpnActive(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
// grab app to make sure it initializes
|
||||||
|
app = App.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_STOP_VPN -> {
|
||||||
|
app.setWantRunning(false)
|
||||||
|
close()
|
||||||
|
START_NOT_STICKY
|
||||||
|
}
|
||||||
|
ACTION_START_VPN -> {
|
||||||
|
showForegroundNotification()
|
||||||
|
app.setWantRunning(true)
|
||||||
|
Libtailscale.requestVPN(this)
|
||||||
|
START_STICKY
|
||||||
|
}
|
||||||
|
"android.net.VpnService" -> {
|
||||||
|
// This means we were started by Android due to Always On VPN.
|
||||||
|
// We show a non-foreground notification because we weren't
|
||||||
|
// started as a foreground service.
|
||||||
|
app.notifyStatus(true)
|
||||||
|
app.setWantRunning(true)
|
||||||
|
Libtailscale.requestVPN(this)
|
||||||
|
START_STICKY
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// This means that we were restarted after the service was killed
|
||||||
|
// (potentially due to OOM).
|
||||||
|
if (UninitializedApp.get().isAbleToStartVPN()) {
|
||||||
|
showForegroundNotification()
|
||||||
|
App.get()
|
||||||
|
Libtailscale.requestVPN(this)
|
||||||
|
START_STICKY
|
||||||
|
} else {
|
||||||
|
START_NOT_STICKY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
app.setWantRunning(false) {}
|
||||||
|
Notifier.setState(Ipn.State.Stopping)
|
||||||
|
disconnectVPN()
|
||||||
|
Libtailscale.serviceDisconnect(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun disconnectVPN(){
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
close()
|
||||||
|
updateVpnStatus(false)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRevoke() {
|
||||||
|
close()
|
||||||
|
updateVpnStatus(false)
|
||||||
|
super.onRevoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setVpnPrepared(isPrepared: Boolean) {
|
||||||
|
app.getAppScopedViewModel().setVpnPrepared(isPrepared)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showForegroundNotification() {
|
||||||
|
try {
|
||||||
|
startForeground(
|
||||||
|
UninitializedApp.STATUS_NOTIFICATION_ID,
|
||||||
|
UninitializedApp.get().buildStatusNotification(true))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TSLog.e(TAG, "Failed to start foreground service: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configIntent(): PendingIntent {
|
||||||
|
return PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
Intent(this, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disallowApp(b: Builder, name: String) {
|
||||||
|
try {
|
||||||
|
b.addDisallowedApplication(name)
|
||||||
|
} catch (e: PackageManager.NameNotFoundException) {
|
||||||
|
TSLog.d(TAG, "Failed to add disallowed application: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun newBuilder(): VPNServiceBuilder {
|
||||||
|
val b: Builder =
|
||||||
|
Builder()
|
||||||
|
.setConfigureIntent(configIntent())
|
||||||
|
.allowFamily(OsConstants.AF_INET)
|
||||||
|
.allowFamily(OsConstants.AF_INET6)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
b.setMetered(false) // Inherit the metered status from the underlying networks.
|
||||||
|
}
|
||||||
|
b.setUnderlyingNetworks(null) // Use all available networks.
|
||||||
|
|
||||||
|
val includedPackages: List<String> =
|
||||||
|
MDMSettings.includedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
|
||||||
|
if (includedPackages.isNotEmpty()) {
|
||||||
|
// If an admin defined a list of packages that are exclusively allowed to be used via
|
||||||
|
// Tailscale,
|
||||||
|
// then only allow those apps.
|
||||||
|
for (packageName in includedPackages) {
|
||||||
|
TSLog.d(TAG, "Including app: $packageName")
|
||||||
|
b.addAllowedApplication(packageName)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Otherwise, prevent certain apps from getting their traffic + DNS routed via Tailscale:
|
||||||
|
// - any app that the user manually disallowed in the GUI
|
||||||
|
// - any app that we disallowed via hard-coding
|
||||||
|
for (disallowedPackageName in UninitializedApp.get().disallowedPackageNames()) {
|
||||||
|
TSLog.d(TAG, "Disallowing app: $disallowedPackageName")
|
||||||
|
disallowApp(b, disallowedPackageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return VPNServiceBuilder(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_START_VPN = "com.tailscale.ipn.START_VPN"
|
||||||
|
const val ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,426 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.AlertDialog
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.RestrictionsManager
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
|
import android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE
|
||||||
|
import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
|
import androidx.browser.customtabs.CustomTabsIntent
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.NavType
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import com.tailscale.ipn.mdm.MDMSettings
|
||||||
|
import com.tailscale.ipn.ui.model.Ipn
|
||||||
|
import com.tailscale.ipn.ui.notifier.Notifier
|
||||||
|
import com.tailscale.ipn.ui.theme.AppTheme
|
||||||
|
import com.tailscale.ipn.ui.util.AndroidTVUtil
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import com.tailscale.ipn.ui.util.universalFit
|
||||||
|
import com.tailscale.ipn.ui.view.AboutView
|
||||||
|
import com.tailscale.ipn.ui.view.BugReportView
|
||||||
|
import com.tailscale.ipn.ui.view.DNSSettingsView
|
||||||
|
import com.tailscale.ipn.ui.view.ExitNodePicker
|
||||||
|
import com.tailscale.ipn.ui.view.HealthView
|
||||||
|
import com.tailscale.ipn.ui.view.IntroView
|
||||||
|
import com.tailscale.ipn.ui.view.LoginQRView
|
||||||
|
import com.tailscale.ipn.ui.view.LoginWithAuthKeyView
|
||||||
|
import com.tailscale.ipn.ui.view.LoginWithCustomControlURLView
|
||||||
|
import com.tailscale.ipn.ui.view.MDMSettingsDebugView
|
||||||
|
import com.tailscale.ipn.ui.view.MainView
|
||||||
|
import com.tailscale.ipn.ui.view.MainViewNavigation
|
||||||
|
import com.tailscale.ipn.ui.view.ManagedByView
|
||||||
|
import com.tailscale.ipn.ui.view.MullvadExitNodePicker
|
||||||
|
import com.tailscale.ipn.ui.view.MullvadExitNodePickerList
|
||||||
|
import com.tailscale.ipn.ui.view.MullvadInfoView
|
||||||
|
import com.tailscale.ipn.ui.view.PeerDetails
|
||||||
|
import com.tailscale.ipn.ui.view.PermissionsView
|
||||||
|
import com.tailscale.ipn.ui.view.RunExitNodeView
|
||||||
|
import com.tailscale.ipn.ui.view.SettingsView
|
||||||
|
import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView
|
||||||
|
import com.tailscale.ipn.ui.view.TailnetLockSetupView
|
||||||
|
import com.tailscale.ipn.ui.view.UserSwitcherNav
|
||||||
|
import com.tailscale.ipn.ui.view.UserSwitcherView
|
||||||
|
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
|
||||||
|
import com.tailscale.ipn.ui.viewModel.MainViewModel
|
||||||
|
import com.tailscale.ipn.ui.viewModel.MainViewModelFactory
|
||||||
|
import com.tailscale.ipn.ui.viewModel.PingViewModel
|
||||||
|
import com.tailscale.ipn.ui.viewModel.SettingsNav
|
||||||
|
import com.tailscale.ipn.ui.viewModel.VpnViewModel
|
||||||
|
import com.tailscale.ipn.util.TSLog
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
private lateinit var navController: NavHostController
|
||||||
|
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
|
||||||
|
private val viewModel: MainViewModel by lazy {
|
||||||
|
val app = App.get()
|
||||||
|
vpnViewModel = app.getAppScopedViewModel()
|
||||||
|
ViewModelProvider(this, MainViewModelFactory(vpnViewModel)).get(MainViewModel::class.java)
|
||||||
|
}
|
||||||
|
private lateinit var vpnViewModel: VpnViewModel
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "Main Activity"
|
||||||
|
private const val START_AT_ROOT = "startAtRoot"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Context.isLandscapeCapable(): Boolean {
|
||||||
|
return (resources.configuration.screenLayout and SCREENLAYOUT_SIZE_MASK) >=
|
||||||
|
SCREENLAYOUT_SIZE_LARGE
|
||||||
|
}
|
||||||
|
|
||||||
|
// The loginQRCode is used to track whether or not we should be rendering a QR code
|
||||||
|
// to the user. This is used only on TV platforms with no browser in lieu of
|
||||||
|
// simply opening the URL. This should be consumed once it has been handled.
|
||||||
|
private val loginQRCode: StateFlow<String?> = MutableStateFlow(null)
|
||||||
|
|
||||||
|
@SuppressLint("SourceLockedOrientationActivity")
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// grab app to make sure it initializes
|
||||||
|
App.get()
|
||||||
|
vpnViewModel = ViewModelProvider(App.get()).get(VpnViewModel::class.java)
|
||||||
|
|
||||||
|
// (jonathan) TODO: Force the app to be portrait on small screens until we have
|
||||||
|
// proper landscape layout support
|
||||||
|
if (!isLandscapeCapable()) {
|
||||||
|
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||||
|
}
|
||||||
|
|
||||||
|
installSplashScreen()
|
||||||
|
|
||||||
|
vpnPermissionLauncher =
|
||||||
|
registerForActivityResult(VpnPermissionContract()) { granted ->
|
||||||
|
if (granted) {
|
||||||
|
TSLog.d("VpnPermission", "VPN permission granted")
|
||||||
|
vpnViewModel.setVpnPrepared(true)
|
||||||
|
App.get().startVPN()
|
||||||
|
} else {
|
||||||
|
if (isAnotherVpnActive(this)) {
|
||||||
|
TSLog.d("VpnPermission", "Another VPN is likely active")
|
||||||
|
showOtherVPNConflictDialog()
|
||||||
|
} else {
|
||||||
|
TSLog.d("VpnPermission", "Permission was denied by the user")
|
||||||
|
vpnViewModel.setVpnPrepared(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher)
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
AppTheme {
|
||||||
|
navController = rememberNavController()
|
||||||
|
Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox
|
||||||
|
Surface(modifier = Modifier.universalFit()) { // Letterbox for AndroidTV
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = "main",
|
||||||
|
enterTransition = {
|
||||||
|
slideInHorizontally(animationSpec = tween(150), initialOffsetX = { it })
|
||||||
|
},
|
||||||
|
exitTransition = {
|
||||||
|
slideOutHorizontally(animationSpec = tween(150), targetOffsetX = { -it })
|
||||||
|
},
|
||||||
|
popEnterTransition = {
|
||||||
|
slideInHorizontally(animationSpec = tween(150), initialOffsetX = { -it })
|
||||||
|
},
|
||||||
|
popExitTransition = {
|
||||||
|
slideOutHorizontally(animationSpec = tween(150), targetOffsetX = { it })
|
||||||
|
}) {
|
||||||
|
fun backTo(route: String): () -> Unit = {
|
||||||
|
navController.popBackStack(route = route, inclusive = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
val mainViewNav =
|
||||||
|
MainViewNavigation(
|
||||||
|
onNavigateToSettings = { navController.navigate("settings") },
|
||||||
|
onNavigateToPeerDetails = {
|
||||||
|
navController.navigate("peerDetails/${it.StableID}")
|
||||||
|
},
|
||||||
|
onNavigateToExitNodes = { navController.navigate("exitNodes") },
|
||||||
|
onNavigateToHealth = { navController.navigate("health") })
|
||||||
|
|
||||||
|
val settingsNav =
|
||||||
|
SettingsNav(
|
||||||
|
onNavigateToBugReport = { navController.navigate("bugReport") },
|
||||||
|
onNavigateToAbout = { navController.navigate("about") },
|
||||||
|
onNavigateToDNSSettings = { navController.navigate("dnsSettings") },
|
||||||
|
onNavigateToSplitTunneling = { navController.navigate("splitTunneling") },
|
||||||
|
onNavigateToTailnetLock = { navController.navigate("tailnetLock") },
|
||||||
|
onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
|
||||||
|
onNavigateToManagedBy = { navController.navigate("managedBy") },
|
||||||
|
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
|
||||||
|
onNavigateToPermissions = { navController.navigate("permissions") },
|
||||||
|
onBackToSettings = backTo("settings"),
|
||||||
|
onNavigateBackHome = backTo("main"))
|
||||||
|
|
||||||
|
val exitNodePickerNav =
|
||||||
|
ExitNodePickerNav(
|
||||||
|
onNavigateBackHome = {
|
||||||
|
navController.popBackStack(route = "main", inclusive = false)
|
||||||
|
},
|
||||||
|
onNavigateBackToExitNodes = backTo("exitNodes"),
|
||||||
|
onNavigateToMullvad = { navController.navigate("mullvad") },
|
||||||
|
onNavigateToMullvadInfo = { navController.navigate("mullvad_info") },
|
||||||
|
onNavigateBackToMullvad = backTo("mullvad"),
|
||||||
|
onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") },
|
||||||
|
onNavigateToRunAsExitNode = { navController.navigate("runExitNode") })
|
||||||
|
|
||||||
|
val userSwitcherNav =
|
||||||
|
UserSwitcherNav(
|
||||||
|
backToSettings = backTo("settings"),
|
||||||
|
onNavigateHome = backTo("main"),
|
||||||
|
onNavigateCustomControl = {
|
||||||
|
navController.navigate("loginWithCustomControl")
|
||||||
|
},
|
||||||
|
onNavigateToAuthKey = { navController.navigate("loginWithAuthKey") })
|
||||||
|
|
||||||
|
composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) {
|
||||||
|
MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel)
|
||||||
|
}
|
||||||
|
composable("settings") { SettingsView(settingsNav) }
|
||||||
|
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
|
||||||
|
composable("health") { HealthView(backTo("main")) }
|
||||||
|
composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) }
|
||||||
|
composable("mullvad_info") { MullvadInfoView(exitNodePickerNav) }
|
||||||
|
composable(
|
||||||
|
"mullvad/{countryCode}",
|
||||||
|
arguments =
|
||||||
|
listOf(navArgument("countryCode") { type = NavType.StringType })) {
|
||||||
|
MullvadExitNodePicker(
|
||||||
|
it.arguments!!.getString("countryCode")!!, exitNodePickerNav)
|
||||||
|
}
|
||||||
|
composable("runExitNode") { RunExitNodeView(exitNodePickerNav) }
|
||||||
|
composable(
|
||||||
|
"peerDetails/{nodeId}",
|
||||||
|
arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) {
|
||||||
|
PeerDetails(
|
||||||
|
backTo("main"),
|
||||||
|
it.arguments?.getString("nodeId") ?: "",
|
||||||
|
PingViewModel())
|
||||||
|
}
|
||||||
|
composable("bugReport") { BugReportView(backTo("settings")) }
|
||||||
|
composable("dnsSettings") { DNSSettingsView(backTo("settings")) }
|
||||||
|
composable("splitTunneling") { SplitTunnelAppPickerView(backTo("settings")) }
|
||||||
|
composable("tailnetLock") { TailnetLockSetupView(backTo("settings")) }
|
||||||
|
composable("about") { AboutView(backTo("settings")) }
|
||||||
|
composable("mdmSettings") { MDMSettingsDebugView(backTo("settings")) }
|
||||||
|
composable("managedBy") { ManagedByView(backTo("settings")) }
|
||||||
|
composable("userSwitcher") { UserSwitcherView(userSwitcherNav) }
|
||||||
|
composable("permissions") {
|
||||||
|
PermissionsView(backTo("settings"), ::openApplicationSettings)
|
||||||
|
}
|
||||||
|
composable("intro", exitTransition = { fadeOut(animationSpec = tween(150)) }) {
|
||||||
|
IntroView(backTo("main"))
|
||||||
|
}
|
||||||
|
composable("loginWithAuthKey") {
|
||||||
|
LoginWithAuthKeyView(onNavigateHome = backTo("main"), backTo("userSwitcher"))
|
||||||
|
}
|
||||||
|
composable("loginWithCustomControl") {
|
||||||
|
LoginWithCustomControlURLView(
|
||||||
|
onNavigateHome = backTo("main"), backTo("userSwitcher"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the intro screen one time
|
||||||
|
if (!introScreenViewed()) {
|
||||||
|
navController.navigate("intro")
|
||||||
|
setIntroScreenViewed(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login actions are app wide. If we are told about a browse-to-url, we should render it
|
||||||
|
// over whatever screen we happen to be on.
|
||||||
|
loginQRCode.collectAsState().value?.let {
|
||||||
|
LoginQRView(onDismiss = { loginQRCode.set(null) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Watch the model's browseToURL and launch the browser when it changes or
|
||||||
|
// pop up a QR code to scan
|
||||||
|
lifecycleScope.launch {
|
||||||
|
Notifier.browseToURL.collect { url ->
|
||||||
|
url?.let {
|
||||||
|
when (useQRCodeLogin()) {
|
||||||
|
false -> Dispatchers.Main.run { login(it) }
|
||||||
|
true -> loginQRCode.set(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once we see a loginFinished event, clear the QR code which will dismiss the QR dialog.
|
||||||
|
lifecycleScope.launch { Notifier.loginFinished.collect { _ -> loginQRCode.set(null) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showOtherVPNConflictDialog() {
|
||||||
|
AlertDialog.Builder(this)
|
||||||
|
.setTitle(R.string.vpn_permission_denied)
|
||||||
|
.setMessage(R.string.multiple_vpn_explainer)
|
||||||
|
.setPositiveButton(R.string.go_to_settings) { _, _ ->
|
||||||
|
// Intent to open the VPN settings
|
||||||
|
val intent = Intent(Settings.ACTION_VPN_SETTINGS)
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
.setNegativeButton(R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isAnotherVpnActive(context: Context): Boolean {
|
||||||
|
val connectivityManager =
|
||||||
|
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
|
||||||
|
val activeNetwork = connectivityManager.activeNetwork
|
||||||
|
if (activeNetwork != null) {
|
||||||
|
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
|
||||||
|
if (networkCapabilities != null &&
|
||||||
|
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if we should render a QR code instead of launching a browser
|
||||||
|
// for login requests
|
||||||
|
private fun useQRCodeLogin(): Boolean {
|
||||||
|
return AndroidTVUtil.isAndroidTV()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
if (intent.getBooleanExtra(START_AT_ROOT, false)) {
|
||||||
|
if (this::navController.isInitialized) {
|
||||||
|
navController.popBackStack(route = "main", inclusive = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun login(urlString: String) {
|
||||||
|
// Launch coroutine to listen for state changes. When the user completes login, relaunch
|
||||||
|
// MainActivity to bring the app back to focus.
|
||||||
|
App.get().applicationScope.launch {
|
||||||
|
try {
|
||||||
|
Notifier.state.collect { state ->
|
||||||
|
if (state > Ipn.State.NeedsMachineAuth) {
|
||||||
|
val intent =
|
||||||
|
Intent(applicationContext, MainActivity::class.java).apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
|
action = Intent.ACTION_MAIN
|
||||||
|
addCategory(Intent.CATEGORY_LAUNCHER)
|
||||||
|
putExtra(START_AT_ROOT, true)
|
||||||
|
}
|
||||||
|
startActivity(intent)
|
||||||
|
|
||||||
|
// Cancel coroutine once we've logged in
|
||||||
|
this@launch.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TSLog.e(TAG, "Login: failed to start MainActivity: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val url = urlString.toUri()
|
||||||
|
try {
|
||||||
|
val customTabsIntent = CustomTabsIntent.Builder().build()
|
||||||
|
customTabsIntent.launchUrl(this, url)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Fallback to a regular browser if CustomTabsIntent fails
|
||||||
|
try {
|
||||||
|
val fallbackIntent = Intent(Intent.ACTION_VIEW, url)
|
||||||
|
startActivity(fallbackIntent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TSLog.e(TAG, "Login: failed to open browser: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
val restrictionsManager =
|
||||||
|
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
val restrictionsManager =
|
||||||
|
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openApplicationSettings() {
|
||||||
|
val intent =
|
||||||
|
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||||
|
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
||||||
|
}
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun introScreenViewed(): Boolean {
|
||||||
|
return getSharedPreferences("introScreen", Context.MODE_PRIVATE).getBoolean("seen", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setIntroScreenViewed(seen: Boolean) {
|
||||||
|
getSharedPreferences("introScreen", Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putBoolean("seen", seen)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class VpnPermissionContract : ActivityResultContract<Intent, Boolean>() {
|
||||||
|
override fun createIntent(context: Context, input: Intent): Intent {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
|
||||||
|
return resultCode == Activity.RESULT_OK
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,168 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
package com.tailscale.ipn
|
||||||
|
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.LinkProperties
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.NetworkRequest
|
||||||
|
import android.util.Log
|
||||||
|
import com.tailscale.ipn.util.TSLog
|
||||||
|
import libtailscale.Libtailscale
|
||||||
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
|
import kotlin.concurrent.withLock
|
||||||
|
|
||||||
|
object NetworkChangeCallback {
|
||||||
|
|
||||||
|
private const val TAG = "NetworkChangeCallback"
|
||||||
|
|
||||||
|
private data class NetworkInfo(var caps: NetworkCapabilities, var linkProps: LinkProperties)
|
||||||
|
|
||||||
|
private val lock = ReentrantLock()
|
||||||
|
|
||||||
|
private val activeNetworks = mutableMapOf<Network, NetworkInfo>() // keyed by Network
|
||||||
|
|
||||||
|
// monitorDnsChanges sets up a network callback to monitor changes to the
|
||||||
|
// system's network state and update the DNS configuration when interfaces
|
||||||
|
// become available or properties of those interfaces change.
|
||||||
|
fun monitorDnsChanges(connectivityManager: ConnectivityManager, dns: DnsConfig) {
|
||||||
|
val networkConnectivityRequest =
|
||||||
|
NetworkRequest.Builder()
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Use registerNetworkCallback to listen for updates from all networks, and
|
||||||
|
// then update DNS configs for the best network when LinkProperties are changed.
|
||||||
|
// Per
|
||||||
|
// https://developer.android.com/reference/android/net/ConnectivityManager.NetworkCallback#onAvailable(android.net.Network), this happens after all other updates.
|
||||||
|
//
|
||||||
|
// Note that we can't use registerDefaultNetworkCallback because the
|
||||||
|
// default network used by Tailscale will always show up with capability
|
||||||
|
// NOT_VPN=false, and we must filter out NOT_VPN networks to avoid routing
|
||||||
|
// loops.
|
||||||
|
connectivityManager.registerNetworkCallback(
|
||||||
|
networkConnectivityRequest,
|
||||||
|
object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(network: Network) {
|
||||||
|
super.onAvailable(network)
|
||||||
|
|
||||||
|
TSLog.d(TAG, "onAvailable: network ${network}")
|
||||||
|
lock.withLock {
|
||||||
|
activeNetworks[network] = NetworkInfo(NetworkCapabilities(), LinkProperties())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
|
||||||
|
super.onCapabilitiesChanged(network, capabilities)
|
||||||
|
lock.withLock { activeNetworks[network]?.caps = capabilities }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
|
||||||
|
super.onLinkPropertiesChanged(network, linkProperties)
|
||||||
|
lock.withLock {
|
||||||
|
activeNetworks[network]?.linkProps = linkProperties
|
||||||
|
maybeUpdateDNSConfig("onLinkPropertiesChanged", dns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLost(network: Network) {
|
||||||
|
super.onLost(network)
|
||||||
|
|
||||||
|
TSLog.d(TAG, "onLost: network ${network}")
|
||||||
|
lock.withLock {
|
||||||
|
activeNetworks.remove(network)
|
||||||
|
maybeUpdateDNSConfig("onLost", dns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickNonMetered returns the first non-metered network in the list of
|
||||||
|
// networks, or the first network if none are non-metered.
|
||||||
|
private fun pickNonMetered(networks: Map<Network, NetworkInfo>): Network? {
|
||||||
|
for ((network, info) in networks) {
|
||||||
|
if (info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)) {
|
||||||
|
return network
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return networks.keys.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickDefaultNetwork returns a non-VPN network to use as the 'default'
|
||||||
|
// network; one that is used as a gateway to the internet and from which we
|
||||||
|
// obtain our DNS servers.
|
||||||
|
private fun pickDefaultNetwork(): Network? {
|
||||||
|
// Filter the list of all networks to those that have the INTERNET
|
||||||
|
// capability, are not VPNs, and have a non-zero number of DNS servers
|
||||||
|
// available.
|
||||||
|
val networks =
|
||||||
|
activeNetworks.filter { (_, info) ->
|
||||||
|
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||||
|
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) &&
|
||||||
|
info.linkProps.dnsServers.isNotEmpty() == true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have one; just return it; otherwise, prefer networks that are also
|
||||||
|
// not metered (i.e. cell modems).
|
||||||
|
val nonMeteredNetwork = pickNonMetered(networks)
|
||||||
|
if (nonMeteredNetwork != null) {
|
||||||
|
return nonMeteredNetwork
|
||||||
|
}
|
||||||
|
|
||||||
|
// Okay, less good; just return the first network that has the INTERNET and
|
||||||
|
// NOT_VPN capabilities; even though this interface doesn't have any DNS
|
||||||
|
// servers set, we'll use our DNS fallback servers to make queries. It's
|
||||||
|
// strictly better to return an interface + use the DNS fallback servers
|
||||||
|
// than to return nothing and not be able to route traffic.
|
||||||
|
for ((network, info) in activeNetworks) {
|
||||||
|
if (info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||||
|
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"no networks available that also have DNS servers set; falling back to first network ${network}")
|
||||||
|
return network
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, return nothing; we don't want to return a VPN network since
|
||||||
|
// it could result in a routing loop, and a non-INTERNET network isn't
|
||||||
|
// helpful.
|
||||||
|
Log.w(TAG, "no networks available to pick a default network")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeUpdateDNSConfig will maybe update our DNS configuration based on the
|
||||||
|
// current set of active Networks.
|
||||||
|
private fun maybeUpdateDNSConfig(why: String, dns: DnsConfig) {
|
||||||
|
val defaultNetwork = pickDefaultNetwork()
|
||||||
|
if (defaultNetwork == null) {
|
||||||
|
TSLog.d(TAG, "${why}: no default network available; not updating DNS config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val info = activeNetworks[defaultNetwork]
|
||||||
|
if (info == null) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"${why}: [unexpected] no info available for default network; not updating DNS config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val sb = StringBuilder()
|
||||||
|
for (ip in info.linkProps.dnsServers) {
|
||||||
|
sb.append(ip.hostAddress).append(" ")
|
||||||
|
}
|
||||||
|
val searchDomains: String? = info.linkProps.domains
|
||||||
|
if (searchDomains != null) {
|
||||||
|
sb.append("\n")
|
||||||
|
sb.append(searchDomains)
|
||||||
|
}
|
||||||
|
if (dns.updateDNSFromNetwork(sb.toString())) {
|
||||||
|
TSLog.d(
|
||||||
|
TAG,
|
||||||
|
"${why}: updated DNS config for network ${defaultNetwork} (${info.linkProps.interfaceName})")
|
||||||
|
Libtailscale.onDNSConfigChanged(info.linkProps.interfaceName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,17 +0,0 @@
|
|||||||
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
|
||||||
// Use of this source code is governed by a BSD-style
|
|
||||||
// license that can be found in the LICENSE file.
|
|
||||||
|
|
||||||
package com.tailscale.ipn;
|
|
||||||
|
|
||||||
import android.app.Activity;
|
|
||||||
import android.app.Fragment;
|
|
||||||
import android.content.Intent;
|
|
||||||
|
|
||||||
public class Peer extends Fragment {
|
|
||||||
@Override public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
|
||||||
onActivityResult0(getActivity(), requestCode, resultCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static native void onActivityResult0(Activity act, int reqCode, int resCode);
|
|
||||||
}
|
|
@ -1,83 +1,99 @@
|
|||||||
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
// Use of this source code is governed by a BSD-style
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
// license that can be found in the LICENSE file.
|
|
||||||
|
|
||||||
package com.tailscale.ipn;
|
package com.tailscale.ipn;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.app.PendingIntent;
|
||||||
import android.content.ComponentName;
|
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
import android.os.Build;
|
||||||
import android.service.quicksettings.Tile;
|
import android.service.quicksettings.Tile;
|
||||||
import android.service.quicksettings.TileService;
|
import android.service.quicksettings.TileService;
|
||||||
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
|
|
||||||
public class QuickToggleService extends TileService {
|
public class QuickToggleService extends TileService {
|
||||||
// lock protects the static fields below it.
|
// lock protects the static fields below it.
|
||||||
private static Object lock = new Object();
|
private static final Object lock = new Object();
|
||||||
// Active tracks whether the VPN is active.
|
|
||||||
private static boolean active;
|
// isRunning tracks whether the VPN is running.
|
||||||
// Ready tracks whether the tailscale backend is
|
private static boolean isRunning;
|
||||||
// ready to switch on/off.
|
|
||||||
private static boolean ready;
|
|
||||||
// currentTile tracks getQsTile while service is listening.
|
|
||||||
private static Tile currentTile;
|
|
||||||
|
|
||||||
@Override public void onStartListening() {
|
// currentTile tracks getQsTile while service is listening.
|
||||||
synchronized (lock) {
|
private static Tile currentTile;
|
||||||
currentTile = getQsTile();
|
|
||||||
}
|
|
||||||
updateTile();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onStopListening() {
|
public static void updateTile() {
|
||||||
synchronized (lock) {
|
var app = UninitializedApp.get();
|
||||||
currentTile = null;
|
Tile t;
|
||||||
}
|
boolean act;
|
||||||
}
|
synchronized (lock) {
|
||||||
|
t = currentTile;
|
||||||
|
act = isRunning && app.isAbleToStartVPN();
|
||||||
|
}
|
||||||
|
if (t == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
t.setLabel("Tailscale");
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
t.setSubtitle(act ? app.getString(R.string.connected) : app.getString(R.string.not_connected));
|
||||||
|
}
|
||||||
|
t.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
|
||||||
|
t.updateTile();
|
||||||
|
}
|
||||||
|
|
||||||
@Override public void onClick() {
|
static void setVPNRunning(boolean running) {
|
||||||
boolean r;
|
synchronized (lock) {
|
||||||
synchronized (lock) {
|
isRunning = running;
|
||||||
r = ready;
|
}
|
||||||
}
|
updateTile();
|
||||||
if (r) {
|
}
|
||||||
onTileClick();
|
|
||||||
} else {
|
|
||||||
// Start main activity.
|
|
||||||
Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName());
|
|
||||||
startActivityAndCollapse(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void updateTile() {
|
@Override
|
||||||
Tile t;
|
public void onStartListening() {
|
||||||
boolean act;
|
synchronized (lock) {
|
||||||
synchronized (lock) {
|
currentTile = getQsTile();
|
||||||
t = currentTile;
|
}
|
||||||
act = active && ready;
|
updateTile();
|
||||||
}
|
}
|
||||||
if (t == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
t.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
|
|
||||||
t.updateTile();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void setReady(Context ctx, boolean rdy) {
|
@Override
|
||||||
synchronized (lock) {
|
public void onStopListening() {
|
||||||
ready = rdy;
|
synchronized (lock) {
|
||||||
}
|
currentTile = null;
|
||||||
updateTile();
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void setStatus(Context ctx, boolean act) {
|
@SuppressWarnings("deprecation")
|
||||||
synchronized (lock) {
|
@Override
|
||||||
active = act;
|
public void onClick() {
|
||||||
}
|
boolean r;
|
||||||
updateTile();
|
synchronized (lock) {
|
||||||
}
|
r = UninitializedApp.get().isAbleToStartVPN();
|
||||||
|
}
|
||||||
|
if (r) {
|
||||||
|
// Get the application to make sure it initializes
|
||||||
|
App.get();
|
||||||
|
onTileClick();
|
||||||
|
} else {
|
||||||
|
// Start main activity.
|
||||||
|
Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName());
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
// Request code for opening activity.
|
||||||
|
startActivityAndCollapse(PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE));
|
||||||
|
} else {
|
||||||
|
// Deprecated, but still required for older versions.
|
||||||
|
startActivityAndCollapse(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static native void onTileClick();
|
private void onTileClick() {
|
||||||
|
UninitializedApp app = UninitializedApp.get();
|
||||||
|
boolean needsToStop;
|
||||||
|
synchronized (lock) {
|
||||||
|
needsToStop = app.isAbleToStartVPN() && isRunning;
|
||||||
|
}
|
||||||
|
if (needsToStop) {
|
||||||
|
app.stopVPN();
|
||||||
|
} else {
|
||||||
|
app.startVPN();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,125 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.tailscale.ipn.ui.model.Ipn
|
||||||
|
import com.tailscale.ipn.ui.theme.AppTheme
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import com.tailscale.ipn.ui.util.universalFit
|
||||||
|
import com.tailscale.ipn.ui.view.TaildropView
|
||||||
|
import com.tailscale.ipn.util.TSLog
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
// ShareActivity is the entry point for Taildrop share intents
|
||||||
|
class ShareActivity : ComponentActivity() {
|
||||||
|
private val TAG = ShareActivity::class.simpleName
|
||||||
|
|
||||||
|
private val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContent {
|
||||||
|
AppTheme {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox
|
||||||
|
Surface(modifier = Modifier.universalFit()) {
|
||||||
|
TaildropView(requestedTransfers, (application as App).applicationScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
// Ensure our app instance is initialized
|
||||||
|
App.get()
|
||||||
|
loadFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
loadFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loads the files from the intent.
|
||||||
|
fun loadFiles() {
|
||||||
|
if (intent == null) {
|
||||||
|
TSLog.e(TAG, "Share failure - No intent found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val act = intent.action
|
||||||
|
|
||||||
|
val uris: List<Uri?>? =
|
||||||
|
when (act) {
|
||||||
|
Intent.ACTION_SEND -> {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java))
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Intent.ACTION_SEND_MULTIPLE -> {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
intent.getParcelableArrayListExtra<Uri?>(Intent.EXTRA_STREAM, Uri::class.java)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION") intent.getParcelableArrayListExtra<Uri?>(Intent.EXTRA_STREAM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
TSLog.e(TAG, "No extras found in intent - nothing to share")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val pendingFiles: List<Ipn.OutgoingFile> =
|
||||||
|
uris?.filterNotNull()?.mapNotNull {
|
||||||
|
contentResolver?.query(it, null, null, null, null)?.let { c ->
|
||||||
|
val nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
|
val sizeCol = c.getColumnIndex(OpenableColumns.SIZE)
|
||||||
|
c.moveToFirst()
|
||||||
|
val name: String =
|
||||||
|
c.getString(nameCol)
|
||||||
|
?: run {
|
||||||
|
// For some reason, some content resolvers don't return a name.
|
||||||
|
// Try to build a name from a random integer plus file extension
|
||||||
|
// (if type can be determined), else just a random integer.
|
||||||
|
val rand = Random.nextLong()
|
||||||
|
contentResolver.getType(it)?.let { mimeType ->
|
||||||
|
MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let {
|
||||||
|
extension ->
|
||||||
|
"$rand.$extension"
|
||||||
|
} ?: "$rand"
|
||||||
|
} ?: "$rand"
|
||||||
|
}
|
||||||
|
val size = c.getLong(sizeCol)
|
||||||
|
c.close()
|
||||||
|
val file = Ipn.OutgoingFile(Name = name, DeclaredSize = size)
|
||||||
|
file.uri = it
|
||||||
|
file
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
if (pendingFiles.isEmpty()) {
|
||||||
|
TSLog.e(TAG, "Share failure - no files extracted from intent")
|
||||||
|
}
|
||||||
|
|
||||||
|
requestedTransfers.set(pendingFiles)
|
||||||
|
}
|
||||||
|
}
|
@ -1,66 +1,65 @@
|
|||||||
// Copyright (c) 2023 Tailscale Inc & AUTHORS All rights reserved.
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
// Use of this source code is governed by a BSD-style
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
// license that can be found in the LICENSE file.
|
|
||||||
|
|
||||||
package com.tailscale.ipn;
|
package com.tailscale.ipn;
|
||||||
|
|
||||||
import android.app.Notification;
|
import android.app.Notification;
|
||||||
import android.app.NotificationChannel;
|
|
||||||
import android.app.NotificationManager;
|
import android.app.NotificationManager;
|
||||||
import android.app.PendingIntent;
|
import android.app.PendingIntent;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.net.VpnService;
|
import android.net.VpnService;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
import androidx.work.Worker;
|
import androidx.work.Worker;
|
||||||
import androidx.work.WorkerParameters;
|
import androidx.work.WorkerParameters;
|
||||||
|
|
||||||
|
import com.tailscale.ipn.util.TSLog;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A worker that exists to support IPNReceiver.
|
||||||
|
*/
|
||||||
public final class StartVPNWorker extends Worker {
|
public final class StartVPNWorker extends Worker {
|
||||||
|
|
||||||
public StartVPNWorker(
|
public StartVPNWorker(Context appContext, WorkerParameters workerParams) {
|
||||||
Context appContext,
|
|
||||||
WorkerParameters workerParams) {
|
|
||||||
super(appContext, workerParams);
|
super(appContext, workerParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override public Result doWork() {
|
@NonNull
|
||||||
App app = ((App)getApplicationContext());
|
@Override
|
||||||
|
public Result doWork() {
|
||||||
// We will start the VPN from the background
|
UninitializedApp app = UninitializedApp.get();
|
||||||
app.autoConnect = true;
|
boolean ableToStartVPN = app.isAbleToStartVPN();
|
||||||
// We need to make sure we prepare the VPN Service, just in case it isn't prepared.
|
if (ableToStartVPN) {
|
||||||
|
if (VpnService.prepare(app) == null) {
|
||||||
|
// We're ready and have permissions, start the VPN
|
||||||
|
app.startVPN();
|
||||||
|
return Result.success();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Intent intent = VpnService.prepare(app);
|
// We aren't ready to start the VPN or don't have permission, open the Tailscale app.
|
||||||
if (intent == null) {
|
TSLog.e("StartVPNWorker", "Tailscale isn't ready to start the VPN, notify the user.");
|
||||||
// If null then the VPN is already prepared and/or it's just been prepared because we have permission
|
|
||||||
app.startVPN();
|
|
||||||
return Result.success();
|
|
||||||
} else {
|
|
||||||
// This VPN possibly doesn't have permission, we need to display a notification which when clicked launches the intent provided.
|
|
||||||
android.util.Log.e("StartVPNWorker", "Tailscale doesn't have permission from the system to start VPN. Launching the intent provided.");
|
|
||||||
|
|
||||||
// Send notification
|
// Send notification
|
||||||
NotificationManager notificationManager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE);
|
NotificationManager notificationManager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||||
String channelId = "start_vpn_channel";
|
String channelId = "start_vpn_channel";
|
||||||
|
|
||||||
// Use createNotificationChannel method from App.java
|
// Use createNotificationChannel method from App.java
|
||||||
app.createNotificationChannel(channelId, "Start VPN Channel", NotificationManager.IMPORTANCE_DEFAULT);
|
app.createNotificationChannel(channelId, getApplicationContext().getString(R.string.vpn_start), getApplicationContext().getString(R.string.notifications_delivered_when_user_interaction_is_required_to_establish_the_vpn_tunnel), NotificationManager.IMPORTANCE_HIGH);
|
||||||
|
|
||||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
// Use prepareIntent if available.
|
||||||
int pendingIntentFlags = PendingIntent.FLAG_ONE_SHOT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_IMMUTABLE : 0);
|
Intent intent = app.getPackageManager().getLaunchIntentForPackage(app.getPackageName());
|
||||||
PendingIntent pendingIntent = PendingIntent.getActivity(app, 0, intent, pendingIntentFlags);
|
assert intent != null;
|
||||||
|
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
|
int pendingIntentFlags = PendingIntent.FLAG_ONE_SHOT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_IMMUTABLE : 0);
|
||||||
|
PendingIntent pendingIntent = PendingIntent.getActivity(app, 0, intent, pendingIntentFlags);
|
||||||
|
|
||||||
Notification notification = new Notification.Builder(app, channelId)
|
Notification notification = new Notification.Builder(app, channelId).setContentTitle(app.getString(R.string.title_connection_failed)).setContentText(app.getString(R.string.body_open_tailscale)).setSmallIcon(R.drawable.ic_notification).setContentIntent(pendingIntent).setAutoCancel(true).build();
|
||||||
.setContentTitle("Tailscale Connection Failed")
|
|
||||||
.setContentText("Tap here to renew permission.")
|
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
|
||||||
.setContentIntent(pendingIntent)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
notificationManager.notify(1, notification);
|
notificationManager.notify(1, notification);
|
||||||
|
|
||||||
return Result.failure();
|
return Result.failure();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -0,0 +1,112 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
package com.tailscale.ipn
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.Data
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import com.tailscale.ipn.UninitializedApp.Companion.STATUS_CHANNEL_ID
|
||||||
|
import com.tailscale.ipn.UninitializedApp.Companion.STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID
|
||||||
|
import com.tailscale.ipn.ui.localapi.Client
|
||||||
|
import com.tailscale.ipn.ui.model.Ipn
|
||||||
|
import com.tailscale.ipn.ui.notifier.Notifier
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
|
||||||
|
class UseExitNodeWorker(
|
||||||
|
appContext: Context,
|
||||||
|
workerParams: WorkerParameters
|
||||||
|
) : CoroutineWorker(appContext, workerParams) {
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val app = UninitializedApp.get()
|
||||||
|
suspend fun runAndGetResult(): String? {
|
||||||
|
val exitNodeName = inputData.getString(EXIT_NODE_NAME)
|
||||||
|
|
||||||
|
val exitNodeId = if (exitNodeName.isNullOrEmpty()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
if (!app.isAbleToStartVPN()) {
|
||||||
|
return app.getString(R.string.vpn_is_not_ready_to_start)
|
||||||
|
}
|
||||||
|
|
||||||
|
val peers =
|
||||||
|
(Notifier.netmap.value
|
||||||
|
?: run { return@runAndGetResult app.getString(R.string.tailscale_is_not_setup) })
|
||||||
|
.Peers ?: run { return@runAndGetResult app.getString(R.string.no_peers_found) }
|
||||||
|
|
||||||
|
val filteredPeers = peers.filter {
|
||||||
|
it.displayName == exitNodeName
|
||||||
|
}.toList()
|
||||||
|
|
||||||
|
if (filteredPeers.isEmpty()) {
|
||||||
|
return app.getString(R.string.no_peers_with_name_found, exitNodeName)
|
||||||
|
} else if (filteredPeers.size > 1) {
|
||||||
|
return app.getString(R.string.multiple_peers_with_name_found, exitNodeName)
|
||||||
|
} else if (!filteredPeers[0].isExitNode) {
|
||||||
|
return app.getString(
|
||||||
|
R.string.peer_with_name_is_not_an_exit_node,
|
||||||
|
exitNodeName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredPeers[0].StableID
|
||||||
|
}
|
||||||
|
|
||||||
|
val allowLanAccess = inputData.getBoolean(ALLOW_LAN_ACCESS, false)
|
||||||
|
val prefsOut = Ipn.MaskedPrefs()
|
||||||
|
prefsOut.ExitNodeID = exitNodeId
|
||||||
|
prefsOut.ExitNodeAllowLANAccess = allowLanAccess
|
||||||
|
|
||||||
|
val scope = CoroutineScope(Dispatchers.Default + Job())
|
||||||
|
var result: String? = null
|
||||||
|
Client(scope).editPrefs(prefsOut) {
|
||||||
|
result = if (it.isFailure) {
|
||||||
|
it.exceptionOrNull()?.message
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.coroutineContext[Job]?.join()
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = runAndGetResult()
|
||||||
|
|
||||||
|
return if (result != null) {
|
||||||
|
val intent =
|
||||||
|
Intent(app, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
|
}
|
||||||
|
val pendingIntent: PendingIntent =
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
app, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(app, STATUS_CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(app.getString(R.string.use_exit_node_intent_failed))
|
||||||
|
.setContentText(result)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
app.notifyStatus(notification)
|
||||||
|
|
||||||
|
Result.failure(Data.Builder().putString(ERROR_KEY, result).build())
|
||||||
|
} else {
|
||||||
|
Result.success()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXIT_NODE_NAME = "EXIT_NODE_NAME"
|
||||||
|
const val ALLOW_LAN_ACCESS = "ALLOW_LAN_ACCESS"
|
||||||
|
const val ERROR_KEY = "error"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn
|
||||||
|
|
||||||
|
import android.net.VpnService
|
||||||
|
import libtailscale.ParcelFileDescriptor
|
||||||
|
import java.net.InetAddress
|
||||||
|
import android.net.IpPrefix as AndroidIpPrefix
|
||||||
|
|
||||||
|
class VPNServiceBuilder(private val builder: VpnService.Builder) : libtailscale.VPNServiceBuilder {
|
||||||
|
override fun addAddress(p0: String, p1: Int) {
|
||||||
|
builder.addAddress(p0, p1)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun addDNSServer(p0: String) {
|
||||||
|
builder.addDnsServer(p0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun addRoute(p0: String, p1: Int) {
|
||||||
|
builder.addRoute(p0, p1)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun excludeRoute(p0: String, p1: Int) {
|
||||||
|
val inetAddress = InetAddress.getByName(p0)
|
||||||
|
val prefix = AndroidIpPrefix(inetAddress, p1)
|
||||||
|
builder.excludeRoute(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun addSearchDomain(p0: String) {
|
||||||
|
builder.addSearchDomain(p0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun establish(): ParcelFileDescriptor? {
|
||||||
|
return builder.establish()?.let { ParcelFileDescriptor(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setMTU(p0: Int) {
|
||||||
|
builder.setMtu(p0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ParcelFileDescriptor(private val fd: android.os.ParcelFileDescriptor) :
|
||||||
|
libtailscale.ParcelFileDescriptor {
|
||||||
|
override fun detach(): Int {
|
||||||
|
return fd.detachFd()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,115 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.mdm
|
||||||
|
|
||||||
|
import android.content.RestrictionsManager
|
||||||
|
import com.tailscale.ipn.App
|
||||||
|
import kotlin.reflect.KVisibility
|
||||||
|
import kotlin.reflect.full.declaredMemberProperties
|
||||||
|
import kotlin.reflect.full.isSubclassOf
|
||||||
|
import kotlin.reflect.jvm.jvmErasure
|
||||||
|
|
||||||
|
object MDMSettings {
|
||||||
|
// The String message used in this NoSuchKeyException must match the value of
|
||||||
|
// syspolicy.ErrNoSuchKey defined in Go. We compare against its exact text
|
||||||
|
// to determine whether the requested policy setting is not configured and
|
||||||
|
// an actual syspolicy.ErrNoSuchKey should be returned from syspolicyHandler
|
||||||
|
// to the backend.
|
||||||
|
class NoSuchKeyException : Exception("no such key")
|
||||||
|
|
||||||
|
val forceEnabled = BooleanMDMSetting("ForceEnabled", "Force Enabled Connection Toggle")
|
||||||
|
|
||||||
|
// Handled on the backed
|
||||||
|
val exitNodeID = StringMDMSetting("ExitNodeID", "Forced Exit Node: Stable ID")
|
||||||
|
|
||||||
|
// (jonathan) TODO: Unused but required. There is some funky go string duration parsing required
|
||||||
|
// here.
|
||||||
|
val keyExpirationNotice = StringMDMSetting("KeyExpirationNotice", "Key Expiration Notice Period")
|
||||||
|
|
||||||
|
val loginURL = StringMDMSetting("LoginURL", "Custom control server URL")
|
||||||
|
|
||||||
|
val managedByCaption = StringMDMSetting("ManagedByCaption", "Managed By - Caption")
|
||||||
|
|
||||||
|
val managedByOrganizationName =
|
||||||
|
StringMDMSetting("ManagedByOrganizationName", "Managed By - Organization Name")
|
||||||
|
|
||||||
|
val managedByURL = StringMDMSetting("ManagedByURL", "Managed By - Support URL")
|
||||||
|
|
||||||
|
// Handled on the backend
|
||||||
|
val tailnet = StringMDMSetting("Tailnet", "Recommended/Required Tailnet Name")
|
||||||
|
|
||||||
|
val hiddenNetworkDevices =
|
||||||
|
StringArrayListMDMSetting("HiddenNetworkDevices", "Hidden Network Device Categories")
|
||||||
|
|
||||||
|
// Unused on Android
|
||||||
|
val allowIncomingConnections =
|
||||||
|
AlwaysNeverUserDecidesMDMSetting("AllowIncomingConnections", "Allow Incoming Connections")
|
||||||
|
|
||||||
|
// Unused on Android
|
||||||
|
val detectThirdPartyAppConflicts =
|
||||||
|
AlwaysNeverUserDecidesMDMSetting(
|
||||||
|
"DetectThirdPartyAppConflicts", "Detect potentially problematic third-party apps")
|
||||||
|
|
||||||
|
val exitNodeAllowLANAccess =
|
||||||
|
AlwaysNeverUserDecidesMDMSetting(
|
||||||
|
"ExitNodeAllowLANAccess", "Allow LAN Access when using an exit node")
|
||||||
|
|
||||||
|
// Handled on the backend
|
||||||
|
val postureChecking =
|
||||||
|
AlwaysNeverUserDecidesMDMSetting("PostureChecking", "Enable Posture Checking")
|
||||||
|
|
||||||
|
val useTailscaleDNSSettings =
|
||||||
|
AlwaysNeverUserDecidesMDMSetting("UseTailscaleDNSSettings", "Use Tailscale DNS Settings")
|
||||||
|
|
||||||
|
// Unused on Android
|
||||||
|
val useTailscaleSubnets =
|
||||||
|
AlwaysNeverUserDecidesMDMSetting("UseTailscaleSubnets", "Use Tailscale Subnets")
|
||||||
|
|
||||||
|
val exitNodesPicker = ShowHideMDMSetting("ExitNodesPicker", "Exit Nodes Picker")
|
||||||
|
|
||||||
|
val manageTailnetLock = ShowHideMDMSetting("ManageTailnetLock", "“Manage Tailnet lock” menu item")
|
||||||
|
|
||||||
|
// Unused on Android
|
||||||
|
val resetToDefaults = ShowHideMDMSetting("ResetToDefaults", "“Reset to Defaults” menu item")
|
||||||
|
|
||||||
|
val runExitNode = ShowHideMDMSetting("RunExitNode", "Run as Exit Node")
|
||||||
|
|
||||||
|
// Unused on Android
|
||||||
|
val testMenu = ShowHideMDMSetting("TestMenu", "Show Debug Menu")
|
||||||
|
|
||||||
|
// Unused on Android
|
||||||
|
val updateMenu = ShowHideMDMSetting("UpdateMenu", "“Update Available” menu item")
|
||||||
|
|
||||||
|
// (jonathan) TODO: Use this when suggested exit nodes are implemented
|
||||||
|
val allowedSuggestedExitNodes =
|
||||||
|
StringArrayListMDMSetting("AllowedSuggestedExitNodes", "Allowed Suggested Exit Nodes")
|
||||||
|
|
||||||
|
// Allows admins to define a list of packages that won't be routed via Tailscale.
|
||||||
|
val excludedPackages = StringMDMSetting("ExcludedPackageNames", "Excluded Package Names")
|
||||||
|
// Allows admins to define a list of packages that will be routed via Tailscale, letting all other
|
||||||
|
// apps skip the VPN tunnel.
|
||||||
|
val includedPackages = StringMDMSetting("IncludedPackageNames", "Included Package Names")
|
||||||
|
|
||||||
|
// Handled on the backend
|
||||||
|
val authKey = StringMDMSetting("AuthKey", "Auth Key for login")
|
||||||
|
|
||||||
|
val allSettings by lazy {
|
||||||
|
MDMSettings::class
|
||||||
|
.declaredMemberProperties
|
||||||
|
.filter {
|
||||||
|
it.visibility == KVisibility.PUBLIC &&
|
||||||
|
it.returnType.jvmErasure.isSubclassOf(MDMSetting::class)
|
||||||
|
}
|
||||||
|
.map { it.call(MDMSettings) as MDMSetting<*> }
|
||||||
|
}
|
||||||
|
|
||||||
|
val allSettingsByKey by lazy { allSettings.associateBy { it.key } }
|
||||||
|
|
||||||
|
fun update(app: App, restrictionsManager: RestrictionsManager?) {
|
||||||
|
val bundle = restrictionsManager?.applicationRestrictions
|
||||||
|
val preferences = lazy { app.getEncryptedPrefs() }
|
||||||
|
allSettings.forEach { it.setFrom(bundle, preferences) }
|
||||||
|
app.notifyPolicyChanged()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,104 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.mdm
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Bundle
|
||||||
|
import com.tailscale.ipn.App
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
data class SettingState<T>(val value: T, val isSet: Boolean)
|
||||||
|
|
||||||
|
abstract class MDMSetting<T>(defaultValue: T, val key: String, val localizedTitle: String) {
|
||||||
|
val defaultValue = defaultValue
|
||||||
|
val flow = MutableStateFlow(SettingState(defaultValue, false))
|
||||||
|
|
||||||
|
fun setFrom(bundle: Bundle?, prefs: Lazy<SharedPreferences>) {
|
||||||
|
val v: T? = getFrom(bundle, prefs)
|
||||||
|
flow.set(SettingState(v ?: defaultValue, v != null))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getFrom(bundle: Bundle?, prefs: Lazy<SharedPreferences>): T? {
|
||||||
|
return when {
|
||||||
|
bundle != null -> bundle.takeIf { it.containsKey(key) }?.let { getFromBundle(it) }
|
||||||
|
else -> prefs.value.takeIf { it.contains(key) }?.let { getFromPrefs(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract fun getFromBundle(bundle: Bundle): T
|
||||||
|
protected abstract fun getFromPrefs(prefs: SharedPreferences): T
|
||||||
|
}
|
||||||
|
|
||||||
|
class BooleanMDMSetting(key: String, localizedTitle: String) :
|
||||||
|
MDMSetting<Boolean>(false, key, localizedTitle) {
|
||||||
|
override fun getFromBundle(bundle: Bundle) = bundle.getBoolean(key)
|
||||||
|
override fun getFromPrefs(prefs: SharedPreferences) = prefs.getBoolean(key, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
class StringMDMSetting(key: String, localizedTitle: String) :
|
||||||
|
MDMSetting<String?>(null, key, localizedTitle) {
|
||||||
|
override fun getFromBundle(bundle: Bundle) = bundle.getString(key)
|
||||||
|
override fun getFromPrefs(prefs: SharedPreferences) = prefs.getString(key, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
class StringArrayListMDMSetting(key: String, localizedTitle: String) :
|
||||||
|
MDMSetting<List<String>?>(null, key, localizedTitle) {
|
||||||
|
override fun getFromBundle(bundle: Bundle) = bundle.getStringArrayList(key)
|
||||||
|
override fun getFromPrefs(prefs: SharedPreferences) =
|
||||||
|
prefs.getStringSet(key, HashSet<String>())?.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlwaysNeverUserDecidesMDMSetting(key: String, localizedTitle: String) :
|
||||||
|
MDMSetting<AlwaysNeverUserDecides>(AlwaysNeverUserDecides.UserDecides, key, localizedTitle) {
|
||||||
|
override fun getFromBundle(bundle: Bundle) =
|
||||||
|
AlwaysNeverUserDecides.fromString(bundle.getString(key))
|
||||||
|
override fun getFromPrefs(prefs: SharedPreferences) =
|
||||||
|
AlwaysNeverUserDecides.fromString(prefs.getString(key, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
class ShowHideMDMSetting(key: String, localizedTitle: String) :
|
||||||
|
MDMSetting<ShowHide>(ShowHide.Show, key, localizedTitle) {
|
||||||
|
override fun getFromBundle(bundle: Bundle) =
|
||||||
|
ShowHide.fromString(bundle.getString(key))
|
||||||
|
override fun getFromPrefs(prefs: SharedPreferences) =
|
||||||
|
ShowHide.fromString(prefs.getString(key, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AlwaysNeverUserDecides(val value: String) {
|
||||||
|
Always("always"),
|
||||||
|
Never("never"),
|
||||||
|
UserDecides("user-decides");
|
||||||
|
|
||||||
|
val hiddenFromUser: Boolean
|
||||||
|
get() {
|
||||||
|
return this != UserDecides
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(value: String?): AlwaysNeverUserDecides {
|
||||||
|
return values().find { it.value == value } ?: UserDecides
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ShowHide(val value: String) {
|
||||||
|
Show("show"),
|
||||||
|
Hide("hide");
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(value: String?): ShowHide {
|
||||||
|
return ShowHide.values().find { it.value == value } ?: Show
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,27 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui
|
||||||
|
|
||||||
|
object Links {
|
||||||
|
const val DEFAULT_CONTROL_URL = "https://controlplane.tailscale.com"
|
||||||
|
const val SERVER_URL = "https://login.tailscale.com"
|
||||||
|
const val ADMIN_URL = SERVER_URL + "/admin"
|
||||||
|
const val SIGNIN_URL = "https://tailscale.com/login"
|
||||||
|
const val PRIVACY_POLICY_URL = "https://tailscale.com/privacy-policy/"
|
||||||
|
const val TERMS_URL = "https://tailscale.com/terms"
|
||||||
|
const val DOCS_URL = "https://tailscale.com/kb/"
|
||||||
|
const val START_GUIDE_URL = "https://tailscale.com/kb/1017/install/"
|
||||||
|
const val LICENSES_URL = "https://tailscale.com/licenses/android"
|
||||||
|
const val DELETE_ACCOUNT_URL =
|
||||||
|
"https://login.tailscale.com/login?next_url=%2Fadmin%2Fsettings%2Fgeneral"
|
||||||
|
const val TAILNET_LOCK_KB_URL = "https://tailscale.com/kb/1226/tailnet-lock/"
|
||||||
|
const val KEY_EXPIRY_KB_URL = "https://tailscale.com/kb/1028/key-expiry/"
|
||||||
|
const val INSTALL_TAILSCALE_KB_URL = "https://tailscale.com/kb/installation/"
|
||||||
|
const val INSTALL_UNSTABLE_KB_URL = "https://tailscale.com/kb/1083/install-unstable"
|
||||||
|
const val MAGICDNS_KB_URL = "https://tailscale.com/kb/1081/magicdns"
|
||||||
|
const val TROUBLESHOOTING_KB_URL = "https://tailscale.com/kb/1023/troubleshooting"
|
||||||
|
const val SUPPORT_URL = "https://tailscale.com/contact/support#support-form"
|
||||||
|
const val TAILDROP_KB_URL = "https://tailscale.com/kb/1106/taildrop"
|
||||||
|
const val TAILFS_KB_URL = "https://tailscale.com/kb/1106/taildrop"
|
||||||
|
}
|
@ -0,0 +1,368 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.localapi
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.tailscale.ipn.ui.model.BugReportID
|
||||||
|
import com.tailscale.ipn.ui.model.Errors
|
||||||
|
import com.tailscale.ipn.ui.model.Ipn
|
||||||
|
import com.tailscale.ipn.ui.model.IpnLocal
|
||||||
|
import com.tailscale.ipn.ui.model.IpnState
|
||||||
|
import com.tailscale.ipn.ui.model.StableNodeID
|
||||||
|
import com.tailscale.ipn.ui.model.Tailcfg
|
||||||
|
import com.tailscale.ipn.ui.util.InputStreamAdapter
|
||||||
|
import com.tailscale.ipn.util.TSLog
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.decodeFromStream
|
||||||
|
import kotlinx.serialization.serializer
|
||||||
|
import libtailscale.FilePart
|
||||||
|
import java.nio.charset.Charset
|
||||||
|
import kotlin.reflect.KType
|
||||||
|
import kotlin.reflect.typeOf
|
||||||
|
|
||||||
|
private object Endpoint {
|
||||||
|
const val DEBUG = "debug"
|
||||||
|
const val DEBUG_LOG = "debug-log"
|
||||||
|
const val BUG_REPORT = "bugreport"
|
||||||
|
const val PREFS = "prefs"
|
||||||
|
const val FILE_TARGETS = "file-targets"
|
||||||
|
const val UPLOAD_METRICS = "upload-client-metrics"
|
||||||
|
const val START = "start"
|
||||||
|
const val LOGIN_INTERACTIVE = "login-interactive"
|
||||||
|
const val RESET_AUTH = "reset-auth"
|
||||||
|
const val LOGOUT = "logout"
|
||||||
|
const val PROFILES = "profiles/"
|
||||||
|
const val PROFILES_CURRENT = "profiles/current"
|
||||||
|
const val STATUS = "status"
|
||||||
|
const val TKA_STATUS = "tka/status"
|
||||||
|
const val TKA_SIGN = "tka/sign"
|
||||||
|
const val TKA_VERIFY_DEEP_LINK = "tka/verify-deeplink"
|
||||||
|
const val PING = "ping"
|
||||||
|
const val FILES = "files"
|
||||||
|
const val FILE_PUT = "file-put"
|
||||||
|
const val TAILFS_SERVER_ADDRESS = "tailfs/fileserver-address"
|
||||||
|
const val ENABLE_EXIT_NODE = "set-use-exit-node-enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit
|
||||||
|
|
||||||
|
typealias TailnetLockStatusResponseHandler = (Result<IpnState.NetworkLockStatus>) -> Unit
|
||||||
|
|
||||||
|
typealias BugReportIdHandler = (Result<BugReportID>) -> Unit
|
||||||
|
|
||||||
|
typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
|
||||||
|
|
||||||
|
typealias PingResultHandler = (Result<IpnState.PingResult>) -> Unit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client provides a mechanism for calling Go's LocalAPIClient. Every LocalAPI endpoint has a
|
||||||
|
* corresponding method on this Client.
|
||||||
|
*/
|
||||||
|
class Client(private val scope: CoroutineScope) {
|
||||||
|
private val TAG = Client::class.simpleName
|
||||||
|
|
||||||
|
fun start(options: Ipn.Options, responseHandler: (Result<Unit>) -> Unit) {
|
||||||
|
val body = Json.encodeToString(options).toByteArray()
|
||||||
|
return post(Endpoint.START, body, responseHandler = responseHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun status(responseHandler: StatusResponseHandler) {
|
||||||
|
get(Endpoint.STATUS, responseHandler = responseHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ping(peer: Tailcfg.Node, responseHandler: PingResultHandler) {
|
||||||
|
val ip = peer.primaryIPv4Address.orEmpty()
|
||||||
|
if (ip.isEmpty()) {
|
||||||
|
responseHandler(Result.failure(Exception("No IP address for peer $peer")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val path = "${Endpoint.PING}?ip=${ip}&type=disco"
|
||||||
|
post(path, timeoutMillis = 2000L, responseHandler = responseHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bugReportId(responseHandler: BugReportIdHandler) {
|
||||||
|
post(Endpoint.BUG_REPORT, responseHandler = responseHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun prefs(responseHandler: PrefsHandler) {
|
||||||
|
get(Endpoint.PREFS, responseHandler = responseHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun editPrefs(prefs: Ipn.MaskedPrefs, responseHandler: (Result<Ipn.Prefs>) -> Unit) {
|
||||||
|
val body = Json.encodeToString(prefs).toByteArray()
|
||||||
|
return patch(Endpoint.PREFS, body, responseHandler = responseHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUseExitNode(use: Boolean, responseHandler: (Result<Ipn.Prefs>) -> Unit) {
|
||||||
|
val path = "${Endpoint.ENABLE_EXIT_NODE}?enabled=$use"
|
||||||
|
return post(path, responseHandler = responseHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun profiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit) {
|
||||||
|
get(Endpoint.PROFILES, responseHandler = responseHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun currentProfile(responseHandler: (Result<IpnLocal.LoginProfile>) -> Unit) {
|
||||||
|
return get(Endpoint.PROFILES_CURRENT, responseHandler = responseHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addProfile(responseHandler: (Result<String>) -> Unit = {}) {
|
||||||
|
return put(Endpoint.PROFILES, responseHandler = responseHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteProfile(
|
||||||
|
profile: IpnLocal.LoginProfile,
|
||||||
|
responseHandler: (Result<String>) -> Unit = {}
|
||||||
|
) {
|
||||||
|
return delete(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun switchProfile(
|
||||||
|
profile: IpnLocal.LoginProfile,
|
||||||
|
responseHandler: (Result<String>) -> Unit = {}
|
||||||
|
) {
|
||||||
|
return post(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startLoginInteractive(responseHandler: (Result<Unit>) -> Unit) {
|
||||||
|
return post(Endpoint.LOGIN_INTERACTIVE, responseHandler = responseHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout(responseHandler: (Result<String>) -> Unit) {
|
||||||
|
return post(Endpoint.LOGOUT, responseHandler = responseHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tailnetLockStatus(responseHandler: TailnetLockStatusResponseHandler) {
|
||||||
|
get(Endpoint.TKA_STATUS, responseHandler = responseHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fileTargets(responseHandler: (Result<List<Ipn.FileTarget>>) -> Unit) {
|
||||||
|
get(Endpoint.FILE_TARGETS, responseHandler = responseHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun putTaildropFiles(
|
||||||
|
context: Context,
|
||||||
|
peerId: StableNodeID,
|
||||||
|
files: Collection<Ipn.OutgoingFile>,
|
||||||
|
responseHandler: (Result<String>) -> Unit
|
||||||
|
) {
|
||||||
|
val manifest = Json.encodeToString(files)
|
||||||
|
val manifestPart = FilePart()
|
||||||
|
manifestPart.body = InputStreamAdapter(manifest.byteInputStream(Charset.defaultCharset()))
|
||||||
|
manifestPart.filename = "manifest.json"
|
||||||
|
manifestPart.contentType = "application/json"
|
||||||
|
val parts = mutableListOf(manifestPart)
|
||||||
|
|
||||||
|
try {
|
||||||
|
parts.addAll(
|
||||||
|
files.map { file ->
|
||||||
|
val stream =
|
||||||
|
context.contentResolver.openInputStream(file.uri)
|
||||||
|
?: throw Exception("Error opening file stream")
|
||||||
|
|
||||||
|
val part = FilePart()
|
||||||
|
part.filename = file.Name
|
||||||
|
part.contentLength = file.DeclaredSize
|
||||||
|
part.body = InputStreamAdapter(stream)
|
||||||
|
part
|
||||||
|
})
|
||||||
|
} catch (e: Exception) {
|
||||||
|
parts.forEach { it.body.close() }
|
||||||
|
TSLog.e(TAG, "Error creating file upload body: $e")
|
||||||
|
responseHandler(Result.failure(e))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return postMultipart(
|
||||||
|
"${Endpoint.FILE_PUT}/${peerId}",
|
||||||
|
FileParts(parts),
|
||||||
|
responseHandler,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T> get(
|
||||||
|
path: String,
|
||||||
|
body: ByteArray? = null,
|
||||||
|
noinline responseHandler: (Result<T>) -> Unit
|
||||||
|
) {
|
||||||
|
Request(
|
||||||
|
scope = scope,
|
||||||
|
method = "GET",
|
||||||
|
path = path,
|
||||||
|
body = body,
|
||||||
|
responseType = typeOf<T>(),
|
||||||
|
responseHandler = responseHandler)
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T> put(
|
||||||
|
path: String,
|
||||||
|
body: ByteArray? = null,
|
||||||
|
noinline responseHandler: (Result<T>) -> Unit
|
||||||
|
) {
|
||||||
|
Request(
|
||||||
|
scope = scope,
|
||||||
|
method = "PUT",
|
||||||
|
path = path,
|
||||||
|
body = body,
|
||||||
|
responseType = typeOf<T>(),
|
||||||
|
responseHandler = responseHandler)
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T> post(
|
||||||
|
path: String,
|
||||||
|
body: ByteArray? = null,
|
||||||
|
timeoutMillis: Long = 30000,
|
||||||
|
noinline responseHandler: (Result<T>) -> Unit
|
||||||
|
) {
|
||||||
|
Request(
|
||||||
|
scope = scope,
|
||||||
|
method = "POST",
|
||||||
|
path = path,
|
||||||
|
body = body,
|
||||||
|
timeoutMillis = timeoutMillis,
|
||||||
|
responseType = typeOf<T>(),
|
||||||
|
responseHandler = responseHandler)
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T> postMultipart(
|
||||||
|
path: String,
|
||||||
|
parts: FileParts,
|
||||||
|
noinline responseHandler: (Result<T>) -> Unit
|
||||||
|
) {
|
||||||
|
Request(
|
||||||
|
scope = scope,
|
||||||
|
method = "POST",
|
||||||
|
path = path,
|
||||||
|
parts = parts,
|
||||||
|
timeoutMillis = 24 * 60 * 60 * 1000, // 24 hours
|
||||||
|
responseType = typeOf<T>(),
|
||||||
|
responseHandler = responseHandler)
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T> patch(
|
||||||
|
path: String,
|
||||||
|
body: ByteArray? = null,
|
||||||
|
noinline responseHandler: (Result<T>) -> Unit
|
||||||
|
) {
|
||||||
|
Request(
|
||||||
|
scope = scope,
|
||||||
|
method = "PATCH",
|
||||||
|
path = path,
|
||||||
|
body = body,
|
||||||
|
responseType = typeOf<T>(),
|
||||||
|
responseHandler = responseHandler)
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T> delete(
|
||||||
|
path: String,
|
||||||
|
noinline responseHandler: (Result<T>) -> Unit
|
||||||
|
) {
|
||||||
|
Request(
|
||||||
|
scope = scope,
|
||||||
|
method = "DELETE",
|
||||||
|
path = path,
|
||||||
|
responseType = typeOf<T>(),
|
||||||
|
responseHandler = responseHandler)
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Request<T>(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val method: String,
|
||||||
|
path: String,
|
||||||
|
private val body: ByteArray? = null,
|
||||||
|
private val parts: FileParts? = null,
|
||||||
|
private val timeoutMillis: Long = 30000,
|
||||||
|
private val responseType: KType,
|
||||||
|
private val responseHandler: (Result<T>) -> Unit
|
||||||
|
) {
|
||||||
|
private val fullPath = "/localapi/v0/$path"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "LocalAPIRequest"
|
||||||
|
|
||||||
|
private val jsonDecoder = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private lateinit var app: libtailscale.Application
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun setApp(newApp: libtailscale.Application) {
|
||||||
|
app = newApp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
fun execute() {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
TSLog.d(TAG, "Executing request:${method}:${fullPath} on app $app")
|
||||||
|
try {
|
||||||
|
val resp =
|
||||||
|
if (parts != null) app.callLocalAPIMultipart(timeoutMillis, method, fullPath, parts)
|
||||||
|
else
|
||||||
|
app.callLocalAPI(
|
||||||
|
timeoutMillis,
|
||||||
|
method,
|
||||||
|
fullPath,
|
||||||
|
body?.let { InputStreamAdapter(it.inputStream()) })
|
||||||
|
// TODO: use the streaming body for performance
|
||||||
|
// An empty body is a perfectly valid response and indicates success
|
||||||
|
val respData = resp.bodyBytes() ?: ByteArray(0)
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val response: Result<T> =
|
||||||
|
when (responseType) {
|
||||||
|
typeOf<String>() -> Result.success(respData.decodeToString() as T)
|
||||||
|
typeOf<Unit>() -> Result.success(Unit as T)
|
||||||
|
else ->
|
||||||
|
try {
|
||||||
|
Result.success(
|
||||||
|
jsonDecoder.decodeFromStream(
|
||||||
|
Json.serializersModule.serializer(responseType), respData.inputStream())
|
||||||
|
as T)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
// If we couldn't parse the response body, assume it's an error response
|
||||||
|
try {
|
||||||
|
val error =
|
||||||
|
jsonDecoder.decodeFromStream<Errors.GenericError>(respData.inputStream())
|
||||||
|
throw Exception(error.error)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Result.failure(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resp.statusCode() >= 400) {
|
||||||
|
throw Exception(
|
||||||
|
"Request failed with status ${resp.statusCode()}: ${respData.toString(Charset.defaultCharset())}")
|
||||||
|
}
|
||||||
|
// The response handler will invoked internally by the request parser
|
||||||
|
scope.launch { responseHandler(response) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
TSLog.e(TAG, "Error executing request:${method}:${fullPath}: $e")
|
||||||
|
scope.launch { responseHandler(Result.failure(e)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileParts(private val parts: List<FilePart>) : libtailscale.FileParts {
|
||||||
|
override fun get(i: Int): FilePart {
|
||||||
|
return parts[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun len(): Int {
|
||||||
|
return parts.size
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
class Dns {
|
||||||
|
@Serializable data class HostEntry(val addr: Addr?, val hosts: List<String>?)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OSConfig(
|
||||||
|
val hosts: List<HostEntry>? = null,
|
||||||
|
val nameservers: List<Addr>? = null,
|
||||||
|
val searchDomains: List<String>? = null,
|
||||||
|
val matchDomains: List<String>? = null,
|
||||||
|
) {
|
||||||
|
val isEmpty: Boolean
|
||||||
|
get() =
|
||||||
|
(hosts.isNullOrEmpty()) &&
|
||||||
|
(nameservers.isNullOrEmpty()) &&
|
||||||
|
(searchDomains.isNullOrEmpty()) &&
|
||||||
|
(matchDomains.isNullOrEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DnsType {
|
||||||
|
@Serializable
|
||||||
|
data class Resolver(var Addr: String? = null, var BootstrapResolution: List<Addr>? = null)
|
||||||
|
}
|
@ -0,0 +1,85 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.model
|
||||||
|
|
||||||
|
import androidx.compose.material3.ListItemColors
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.tailscale.ipn.ui.theme.warning
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
class Health {
|
||||||
|
@Serializable
|
||||||
|
data class State(
|
||||||
|
// WarnableCode -> UnhealthyState or null
|
||||||
|
var Warnings: Map<String, UnhealthyState?>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UnhealthyState(
|
||||||
|
var WarnableCode: String,
|
||||||
|
var Severity: Severity,
|
||||||
|
var Title: String,
|
||||||
|
var Text: String,
|
||||||
|
var BrokenSince: String? = null,
|
||||||
|
var Args: Map<String, String>? = null,
|
||||||
|
var ImpactsConnectivity: Boolean? = false,
|
||||||
|
var DependsOn: List<String>? = null, // an array of WarnableCodes this depends on
|
||||||
|
) : Comparable<UnhealthyState> {
|
||||||
|
fun hiddenByDependencies(currentWarnableCodes: Set<String>): Boolean {
|
||||||
|
return this.DependsOn?.let {
|
||||||
|
it.any { depWarnableCode -> currentWarnableCodes.contains(depWarnableCode) }
|
||||||
|
} == true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun compareTo(other: UnhealthyState): Int {
|
||||||
|
// Compare by severity first
|
||||||
|
val severityComparison = Severity.compareTo(other.Severity)
|
||||||
|
if (severityComparison != 0) {
|
||||||
|
return severityComparison
|
||||||
|
}
|
||||||
|
|
||||||
|
// If severities are equal, compare by warnableCode
|
||||||
|
return WarnableCode.compareTo(other.WarnableCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class Severity : Comparable<Severity> {
|
||||||
|
low,
|
||||||
|
medium,
|
||||||
|
high;
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun listItemColors(): ListItemColors {
|
||||||
|
val default = ListItemDefaults.colors()
|
||||||
|
return when (this) {
|
||||||
|
Severity.low ->
|
||||||
|
ListItemColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
headlineColor = MaterialTheme.colorScheme.secondary,
|
||||||
|
leadingIconColor = MaterialTheme.colorScheme.secondary,
|
||||||
|
overlineColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.8f),
|
||||||
|
supportingTextColor = MaterialTheme.colorScheme.secondary,
|
||||||
|
trailingIconColor = MaterialTheme.colorScheme.secondary,
|
||||||
|
disabledHeadlineColor = default.disabledHeadlineColor,
|
||||||
|
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
||||||
|
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
||||||
|
Severity.medium,
|
||||||
|
Severity.high ->
|
||||||
|
ListItemColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.warning,
|
||||||
|
headlineColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f),
|
||||||
|
supportingTextColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
disabledHeadlineColor = default.disabledHeadlineColor,
|
||||||
|
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
||||||
|
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,240 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.model
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.Transient
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class Ipn {
|
||||||
|
|
||||||
|
// Represents the overall state of the Tailscale engine.
|
||||||
|
enum class State(val value: Int) {
|
||||||
|
NoState(0),
|
||||||
|
InUseOtherUser(1),
|
||||||
|
NeedsLogin(2),
|
||||||
|
NeedsMachineAuth(3),
|
||||||
|
Stopped(4),
|
||||||
|
Starting(5),
|
||||||
|
Running(6),
|
||||||
|
// Stopping represents a state where a request to stop Tailscale has been issue but has not
|
||||||
|
// completed. This state allows UI to optimistically reflect a stopped state, and to fallback if
|
||||||
|
// necessary.
|
||||||
|
Stopping(7);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromInt(value: Int): State {
|
||||||
|
return State.values().firstOrNull { it.value == value } ?: NoState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A nofitication message recieved on the Notify bus. Fields will be populated based
|
||||||
|
// on which NotifyWatchOpts were set when the Notifier was created.
|
||||||
|
@Serializable
|
||||||
|
data class Notify(
|
||||||
|
val Version: String? = null,
|
||||||
|
val ErrMessage: String? = null,
|
||||||
|
val LoginFinished: Empty.Message? = null,
|
||||||
|
val FilesWaiting: Empty.Message? = null,
|
||||||
|
val OutgoingFiles: List<OutgoingFile>? = null,
|
||||||
|
val State: Int? = null,
|
||||||
|
var Prefs: Prefs? = null,
|
||||||
|
var NetMap: Netmap.NetworkMap? = null,
|
||||||
|
var Engine: EngineStatus? = null,
|
||||||
|
var BrowseToURL: String? = null,
|
||||||
|
var BackendLogId: String? = null,
|
||||||
|
var LocalTCPPort: Int? = null,
|
||||||
|
var IncomingFiles: List<PartialFile>? = null,
|
||||||
|
var ClientVersion: Tailcfg.ClientVersion? = null,
|
||||||
|
var TailFSShares: List<String>? = null,
|
||||||
|
var Health: Health.State? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Prefs(
|
||||||
|
var ControlURL: String = "",
|
||||||
|
var RouteAll: Boolean = false,
|
||||||
|
var AllowsSingleHosts: Boolean = false,
|
||||||
|
var CorpDNS: Boolean = false,
|
||||||
|
var WantRunning: Boolean = false,
|
||||||
|
var LoggedOut: Boolean = false,
|
||||||
|
var ShieldsUp: Boolean = false,
|
||||||
|
var AdvertiseRoutes: List<String>? = null,
|
||||||
|
var AdvertiseTags: List<String>? = null,
|
||||||
|
var ExitNodeID: StableNodeID? = null,
|
||||||
|
var ExitNodeAllowLANAccess: Boolean = false,
|
||||||
|
var Config: Persist.Persist? = null,
|
||||||
|
var ForceDaemon: Boolean = false,
|
||||||
|
var HostName: String = "",
|
||||||
|
var AutoUpdate: AutoUpdatePrefs? = AutoUpdatePrefs(true, true),
|
||||||
|
var InternalExitNodePrior: String? = null,
|
||||||
|
) {
|
||||||
|
|
||||||
|
// For the InternalExitNodePrior and ExitNodeId, these will treats the empty string as null to
|
||||||
|
// simplify the downstream logic.
|
||||||
|
|
||||||
|
val selectedExitNodeID: String?
|
||||||
|
get() {
|
||||||
|
return if (InternalExitNodePrior.isNullOrEmpty()) null else InternalExitNodePrior
|
||||||
|
}
|
||||||
|
|
||||||
|
val activeExitNodeID: String?
|
||||||
|
get() {
|
||||||
|
return if (ExitNodeID.isNullOrEmpty()) null else ExitNodeID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MaskedPrefs(
|
||||||
|
var ControlURLSet: Boolean? = null,
|
||||||
|
var RouteAllSet: Boolean? = null,
|
||||||
|
var CorpDNSSet: Boolean? = null,
|
||||||
|
var ExitNodeIDSet: Boolean? = null,
|
||||||
|
var ExitNodeAllowLANAccessSet: Boolean? = null,
|
||||||
|
var WantRunningSet: Boolean? = null,
|
||||||
|
var ShieldsUpSet: Boolean? = null,
|
||||||
|
var AdvertiseRoutesSet: Boolean? = null,
|
||||||
|
var ForceDaemonSet: Boolean? = null,
|
||||||
|
var HostnameSet: Boolean? = null,
|
||||||
|
var InternalExitNodePriorSet: Boolean? = null,
|
||||||
|
) {
|
||||||
|
|
||||||
|
var ControlURL: String? = null
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
ControlURLSet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var RouteAll: Boolean? = null
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
RouteAllSet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var CorpDNS: Boolean? = null
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
CorpDNSSet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var ExitNodeID: StableNodeID? = null
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
ExitNodeIDSet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var InternalExitNodePrior: String? = null
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
InternalExitNodePriorSet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var ExitNodeAllowLANAccess: Boolean? = null
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
ExitNodeAllowLANAccessSet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var WantRunning: Boolean? = null
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
WantRunningSet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var ShieldsUp: Boolean? = null
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
ShieldsUpSet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var AdvertiseRoutes: List<String>? = null
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
AdvertiseRoutesSet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var ForceDaemon: Boolean? = null
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
ForceDaemonSet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var Hostname: String? = null
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
HostnameSet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AutoUpdatePrefs(
|
||||||
|
var Check: Boolean? = null,
|
||||||
|
var Apply: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class EngineStatus(
|
||||||
|
val RBytes: Long,
|
||||||
|
val WBytes: Long,
|
||||||
|
val NumLive: Int,
|
||||||
|
val LivePeers: Map<String, IpnState.PeerStatusLite>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PartialFile(
|
||||||
|
val Name: String,
|
||||||
|
val Started: String,
|
||||||
|
val DeclaredSize: Long,
|
||||||
|
val Received: Long,
|
||||||
|
val PartialPath: String? = null,
|
||||||
|
var FinalPath: String? = null,
|
||||||
|
val Done: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OutgoingFile(
|
||||||
|
val ID: String = "",
|
||||||
|
val Name: String,
|
||||||
|
val PeerID: StableNodeID = "",
|
||||||
|
val Started: String = "",
|
||||||
|
val DeclaredSize: Long,
|
||||||
|
val Sent: Long = 0L,
|
||||||
|
val PartialPath: String? = null,
|
||||||
|
var FinalPath: String? = null,
|
||||||
|
val Finished: Boolean = false,
|
||||||
|
val Succeeded: Boolean = false,
|
||||||
|
) {
|
||||||
|
@Transient lateinit var uri: Uri // only used on client
|
||||||
|
|
||||||
|
fun prepare(peerId: StableNodeID): OutgoingFile {
|
||||||
|
val f = copy(ID = UUID.randomUUID().toString(), PeerID = peerId)
|
||||||
|
f.uri = uri
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable data class FileTarget(var Node: Tailcfg.Node, var PeerAPIURL: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Options(
|
||||||
|
var FrontendLogID: String? = null,
|
||||||
|
var UpdatePrefs: Prefs? = null,
|
||||||
|
var AuthKey: String? = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Persist {
|
||||||
|
@Serializable
|
||||||
|
data class Persist(
|
||||||
|
var PrivateMachineKey: String =
|
||||||
|
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
var PrivateNodeKey: String =
|
||||||
|
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
var OldPrivateNodeKey: String =
|
||||||
|
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
var Provider: String = "",
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,131 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
class IpnState {
|
||||||
|
@Serializable
|
||||||
|
data class PeerStatusLite(
|
||||||
|
val RxBytes: Long,
|
||||||
|
val TxBytes: Long,
|
||||||
|
val LastHandshake: String,
|
||||||
|
val NodeKey: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PeerStatus(
|
||||||
|
val ID: StableNodeID,
|
||||||
|
val HostName: String,
|
||||||
|
val DNSName: String,
|
||||||
|
val TailscaleIPs: List<Addr>? = null,
|
||||||
|
val Tags: List<String>? = null,
|
||||||
|
val PrimaryRoutes: List<String>? = null,
|
||||||
|
val Addrs: List<String>? = null,
|
||||||
|
val CurAddr: String? = null,
|
||||||
|
val Relay: String? = null,
|
||||||
|
val Online: Boolean,
|
||||||
|
val ExitNode: Boolean,
|
||||||
|
val ExitNodeOption: Boolean,
|
||||||
|
val Active: Boolean,
|
||||||
|
val PeerAPIURL: List<String>? = null,
|
||||||
|
val Capabilities: List<String>? = null,
|
||||||
|
val SSH_HostKeys: List<String>? = null,
|
||||||
|
val ShareeNode: Boolean? = null,
|
||||||
|
val Expired: Boolean? = null,
|
||||||
|
val Location: Tailcfg.Location? = null,
|
||||||
|
) {
|
||||||
|
fun computedName(status: Status): String {
|
||||||
|
val name = DNSName
|
||||||
|
val suffix = status.CurrentTailnet?.MagicDNSSuffix
|
||||||
|
|
||||||
|
suffix ?: return name
|
||||||
|
|
||||||
|
if (!(name.endsWith("." + suffix + "."))) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
return name.dropLast(suffix.count() + 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ExitNodeStatus(
|
||||||
|
val ID: StableNodeID,
|
||||||
|
val Online: Boolean,
|
||||||
|
val TailscaleIPs: List<Prefix>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TailnetStatus(
|
||||||
|
val Name: String,
|
||||||
|
val MagicDNSSuffix: String,
|
||||||
|
val MagicDNSEnabled: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Status(
|
||||||
|
val Version: String,
|
||||||
|
val TUN: Boolean,
|
||||||
|
val BackendState: String,
|
||||||
|
val AuthURL: String,
|
||||||
|
val TailscaleIPs: List<Addr>? = null,
|
||||||
|
val Self: PeerStatus? = null,
|
||||||
|
val ExitNodeStatus: ExitNodeStatus? = null,
|
||||||
|
val Health: List<String>? = null,
|
||||||
|
val CurrentTailnet: TailnetStatus? = null,
|
||||||
|
val CertDomains: List<String>? = null,
|
||||||
|
val Peer: Map<String, PeerStatus>? = null,
|
||||||
|
val User: Map<String, Tailcfg.UserProfile>? = null,
|
||||||
|
val ClientVersion: Tailcfg.ClientVersion? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NetworkLockStatus(
|
||||||
|
var Enabled: Boolean? = null,
|
||||||
|
var PublicKey: String? = null,
|
||||||
|
var NodeKey: String? = null,
|
||||||
|
var NodeKeySigned: Boolean? = null,
|
||||||
|
var FilteredPeers: List<TKAFilteredPeer>? = null,
|
||||||
|
var StateID: ULong? = null,
|
||||||
|
var TrustedKeys: List<TKAKey>? = null
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun IsPublicKeyTrusted(): Boolean {
|
||||||
|
return TrustedKeys?.any { it.Key == PublicKey } == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TKAFilteredPeer(
|
||||||
|
var Name: String,
|
||||||
|
var TailscaleIPs: List<Addr>,
|
||||||
|
var NodeKey: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable data class TKAKey(var Key: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PingResult(
|
||||||
|
var IP: Addr,
|
||||||
|
var Err: String,
|
||||||
|
var LatencySeconds: Double,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class IpnLocal {
|
||||||
|
@Serializable
|
||||||
|
data class LoginProfile(
|
||||||
|
var ID: String,
|
||||||
|
val Name: String,
|
||||||
|
val Key: String,
|
||||||
|
val UserProfile: Tailcfg.UserProfile,
|
||||||
|
val NetworkProfile: Tailcfg.NetworkProfile? = null,
|
||||||
|
val LocalUserID: String,
|
||||||
|
) {
|
||||||
|
fun isEmpty(): Boolean {
|
||||||
|
return ID.isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,55 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
class Netmap {
|
||||||
|
@Serializable
|
||||||
|
data class NetworkMap(
|
||||||
|
var SelfNode: Tailcfg.Node,
|
||||||
|
var NodeKey: KeyNodePublic,
|
||||||
|
var Peers: List<Tailcfg.Node>? = null,
|
||||||
|
var Expiry: Time,
|
||||||
|
var Domain: String,
|
||||||
|
var UserProfiles: Map<String, Tailcfg.UserProfile>,
|
||||||
|
var TKAEnabled: Boolean,
|
||||||
|
var DNS: Tailcfg.DNSConfig? = null
|
||||||
|
) {
|
||||||
|
// Keys are tailcfg.UserIDs thet get stringified
|
||||||
|
// Helpers
|
||||||
|
fun currentUserProfile(): Tailcfg.UserProfile? {
|
||||||
|
return userProfile(User())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun User(): UserID {
|
||||||
|
return SelfNode.User
|
||||||
|
}
|
||||||
|
|
||||||
|
fun userProfile(id: Long): Tailcfg.UserProfile? {
|
||||||
|
return UserProfiles[id.toString()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPeer(id: StableNodeID): Tailcfg.Node? {
|
||||||
|
if (id == SelfNode.StableID) {
|
||||||
|
return SelfNode
|
||||||
|
}
|
||||||
|
return Peers?.find { it.StableID == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other !is NetworkMap) return false
|
||||||
|
|
||||||
|
return SelfNode == other.SelfNode &&
|
||||||
|
NodeKey == other.NodeKey &&
|
||||||
|
Peers == other.Peers &&
|
||||||
|
Expiry == other.Expiry &&
|
||||||
|
User() == other.User() &&
|
||||||
|
Domain == other.Domain &&
|
||||||
|
UserProfiles == other.UserProfiles &&
|
||||||
|
TKAEnabled == other.TKAEnabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,90 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.model
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
|
import com.google.accompanist.permissions.PermissionState
|
||||||
|
import com.google.accompanist.permissions.isGranted
|
||||||
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||||
|
import com.google.accompanist.permissions.shouldShowRationale
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
|
||||||
|
object Permissions {
|
||||||
|
/** Permissions to prompt for on MainView. */
|
||||||
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
|
val prompt: List<Pair<Permission, PermissionState>>
|
||||||
|
@Composable
|
||||||
|
get() {
|
||||||
|
val permissionStates = rememberMultiplePermissionsState(permissions = all.map { it.name })
|
||||||
|
return all.zip(permissionStates.permissions).filter { (_, state) ->
|
||||||
|
!state.status.isGranted && !state.status.shouldShowRationale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All permissions with granted status. */
|
||||||
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
|
val withGrantedStatus: List<Pair<Permission, Boolean>>
|
||||||
|
@Composable
|
||||||
|
get() {
|
||||||
|
val permissionStates = rememberMultiplePermissionsState(permissions = all.map { it.name })
|
||||||
|
val result = mutableListOf<Pair<Permission, Boolean>>()
|
||||||
|
result.addAll(
|
||||||
|
all.zip(permissionStates.permissions).map { (permission, state) ->
|
||||||
|
Pair(permission, state.status.isGranted)
|
||||||
|
})
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
// On Android versions prior to 13, we have to programmatically check if notifications are
|
||||||
|
// being allowed.
|
||||||
|
val notificationsEnabled =
|
||||||
|
NotificationManagerCompat.from(LocalContext.current).areNotificationsEnabled()
|
||||||
|
result.add(
|
||||||
|
Pair(
|
||||||
|
Permission(
|
||||||
|
"",
|
||||||
|
R.string.permission_post_notifications,
|
||||||
|
R.string.permission_post_notifications_needed),
|
||||||
|
notificationsEnabled))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All permissions that Tailscale requires. MainView takes care of prompting for permissions, and
|
||||||
|
* PermissionsView provides a list of permissions with corresponding statuses and a link to the
|
||||||
|
* application settings.
|
||||||
|
*
|
||||||
|
* When new permissions are needed, just add them to this list and the necessary strings to
|
||||||
|
* strings.xml and the rest should take care of itself.
|
||||||
|
*/
|
||||||
|
private val all: List<Permission> by lazy {
|
||||||
|
val result = mutableListOf<Permission>()
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||||
|
result.add(
|
||||||
|
Permission(
|
||||||
|
Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
||||||
|
R.string.permission_write_external_storage,
|
||||||
|
R.string.permission_write_external_storage_needed,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
result.add(
|
||||||
|
Permission(
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS,
|
||||||
|
R.string.permission_post_notifications,
|
||||||
|
R.string.permission_post_notifications_needed))
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Permission(
|
||||||
|
val name: String,
|
||||||
|
val title: Int,
|
||||||
|
val description: Int,
|
||||||
|
)
|
@ -0,0 +1,205 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.model
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.Links
|
||||||
|
import com.tailscale.ipn.ui.theme.off
|
||||||
|
import com.tailscale.ipn.ui.theme.on
|
||||||
|
import com.tailscale.ipn.ui.util.ComposableStringFormatter
|
||||||
|
import com.tailscale.ipn.ui.util.DisplayAddress
|
||||||
|
import com.tailscale.ipn.ui.util.TimeUtil
|
||||||
|
import com.tailscale.ipn.ui.util.flag
|
||||||
|
import com.tailscale.ipn.ui.viewModel.PeerSettingInfo
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
class Tailcfg {
|
||||||
|
@Serializable
|
||||||
|
data class ClientVersion(
|
||||||
|
var RunningLatest: Boolean? = null,
|
||||||
|
var LatestVersion: String? = null,
|
||||||
|
var UrgentSecurityUpdate: Boolean? = null,
|
||||||
|
var Notify: Boolean? = null,
|
||||||
|
var NotifyURL: String? = null,
|
||||||
|
var NotifyText: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UserProfile(
|
||||||
|
val ID: Long,
|
||||||
|
val DisplayName: String,
|
||||||
|
val LoginName: String,
|
||||||
|
val ProfilePicURL: String? = null,
|
||||||
|
) {
|
||||||
|
fun isTaggedDevice(): Boolean {
|
||||||
|
return LoginName == "tagged-devices"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Hostinfo(
|
||||||
|
var IPNVersion: String? = null,
|
||||||
|
var FrontendLogID: String? = null,
|
||||||
|
var BackendLogID: String? = null,
|
||||||
|
var OS: String? = null,
|
||||||
|
var OSVersion: String? = null,
|
||||||
|
var Env: String? = null,
|
||||||
|
var Distro: String? = null,
|
||||||
|
var DistroVersion: String? = null,
|
||||||
|
var DistroCodeName: String? = null,
|
||||||
|
var Desktop: Boolean? = null,
|
||||||
|
var Package: String? = null,
|
||||||
|
var DeviceModel: String? = null,
|
||||||
|
var ShareeNode: Boolean? = null,
|
||||||
|
var Hostname: String? = null,
|
||||||
|
var ShieldsUp: Boolean? = null,
|
||||||
|
var NoLogsNoSupport: Boolean? = null,
|
||||||
|
var Machine: String? = null,
|
||||||
|
var RoutableIPs: List<Prefix>? = null,
|
||||||
|
var Services: List<Service>? = null,
|
||||||
|
var Location: Location? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Node(
|
||||||
|
var ID: NodeID,
|
||||||
|
var StableID: StableNodeID,
|
||||||
|
var Name: String,
|
||||||
|
var User: UserID,
|
||||||
|
var Sharer: UserID? = null,
|
||||||
|
var Key: KeyNodePublic,
|
||||||
|
var KeyExpiry: String,
|
||||||
|
var Machine: MachineKey,
|
||||||
|
var Addresses: List<Prefix>? = null,
|
||||||
|
var AllowedIPs: List<Prefix>? = null,
|
||||||
|
var Endpoints: List<String>? = null,
|
||||||
|
var Hostinfo: Hostinfo,
|
||||||
|
var Created: Time,
|
||||||
|
var LastSeen: Time? = null,
|
||||||
|
var Online: Boolean? = null,
|
||||||
|
var Capabilities: List<String>? = null,
|
||||||
|
var CapMap: Map<String, JsonElement?>? = null,
|
||||||
|
var ComputedName: String?,
|
||||||
|
var ComputedNameWithHost: String?
|
||||||
|
) {
|
||||||
|
val isAdmin: Boolean
|
||||||
|
get() =
|
||||||
|
Capabilities?.contains("https://tailscale.com/cap/is-admin") == true ||
|
||||||
|
CapMap?.contains("https://tailscale.com/cap/is-admin") == true
|
||||||
|
|
||||||
|
// Derives the url to directly administer a node
|
||||||
|
val nodeAdminUrl: String
|
||||||
|
get() = primaryIPv4Address?.let { "${Links.ADMIN_URL}/machines/${it}" } ?: Links.ADMIN_URL
|
||||||
|
|
||||||
|
val primaryIPv4Address: String?
|
||||||
|
get() = displayAddresses.firstOrNull { it.type == DisplayAddress.addrType.V4 }?.address
|
||||||
|
|
||||||
|
val primaryIPv6Address: String?
|
||||||
|
get() = displayAddresses.firstOrNull { it.type == DisplayAddress.addrType.V6 }?.address
|
||||||
|
|
||||||
|
// isExitNode reproduces the Go logic in local.go peerStatusFromNode
|
||||||
|
val isExitNode: Boolean =
|
||||||
|
AllowedIPs?.contains("0.0.0.0/0") ?: false && AllowedIPs?.contains("::/0") ?: false
|
||||||
|
|
||||||
|
val isMullvadNode: Boolean
|
||||||
|
get() = Name.endsWith(".mullvad.ts.net.")
|
||||||
|
|
||||||
|
val displayName: String
|
||||||
|
get() = ComputedName ?: Name
|
||||||
|
|
||||||
|
val exitNodeName: String
|
||||||
|
get() {
|
||||||
|
if (isMullvadNode &&
|
||||||
|
Hostinfo.Location?.Country != null &&
|
||||||
|
Hostinfo.Location?.City != null &&
|
||||||
|
Hostinfo.Location?.CountryCode != null) {
|
||||||
|
return "${Hostinfo.Location!!.CountryCode!!.flag()} ${Hostinfo.Location!!.Country!!}: ${Hostinfo.Location!!.City!!}"
|
||||||
|
}
|
||||||
|
return displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
val keyDoesNotExpire: Boolean
|
||||||
|
get() = KeyExpiry == "0001-01-01T00:00:00Z"
|
||||||
|
|
||||||
|
fun isSelfNode(netmap: Netmap.NetworkMap): Boolean = StableID == netmap.SelfNode.StableID
|
||||||
|
|
||||||
|
fun connectedOrSelfNode(nm: Netmap.NetworkMap?) =
|
||||||
|
Online == true || StableID == nm?.SelfNode?.StableID
|
||||||
|
|
||||||
|
fun connectedStrRes(nm: Netmap.NetworkMap?) =
|
||||||
|
if (connectedOrSelfNode(nm)) R.string.connected else R.string.not_connected
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun connectedColor(nm: Netmap.NetworkMap?) =
|
||||||
|
if (connectedOrSelfNode(nm)) MaterialTheme.colorScheme.on else MaterialTheme.colorScheme.off
|
||||||
|
|
||||||
|
val nameWithoutTrailingDot = Name.trimEnd('.')
|
||||||
|
|
||||||
|
val displayAddresses: List<DisplayAddress>
|
||||||
|
get() {
|
||||||
|
var addresses = mutableListOf<DisplayAddress>()
|
||||||
|
addresses.add(DisplayAddress(nameWithoutTrailingDot))
|
||||||
|
Addresses?.let { addresses.addAll(it.map { addr -> DisplayAddress(addr) }) }
|
||||||
|
return addresses
|
||||||
|
}
|
||||||
|
|
||||||
|
val info: List<PeerSettingInfo>
|
||||||
|
get() {
|
||||||
|
val result = mutableListOf<PeerSettingInfo>()
|
||||||
|
if (Hostinfo.OS?.isNotEmpty() == true) {
|
||||||
|
result.add(
|
||||||
|
PeerSettingInfo(R.string.os, ComposableStringFormatter(Hostinfo.OS!!)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (keyDoesNotExpire) {
|
||||||
|
result.add(
|
||||||
|
PeerSettingInfo(
|
||||||
|
R.string.key_expiry, ComposableStringFormatter(R.string.deviceKeyNeverExpires)))
|
||||||
|
} else {
|
||||||
|
result.add(PeerSettingInfo(R.string.key_expiry, TimeUtil.keyExpiryFromGoTime(KeyExpiry)))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun expiryLabel(): String {
|
||||||
|
if (KeyExpiry == GoZeroTimeString) {
|
||||||
|
return stringResource(R.string.deviceKeyNeverExpires)
|
||||||
|
}
|
||||||
|
|
||||||
|
val expDate = TimeUtil.dateFromGoString(KeyExpiry)
|
||||||
|
val template = if (expDate > Date()) R.string.deviceKeyExpires else R.string.deviceKeyExpired
|
||||||
|
return stringResource(template, TimeUtil.keyExpiryFromGoTime(KeyExpiry).getString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Service(var Proto: String, var Port: Int, var Description: String? = null)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NetworkProfile(var MagicDNSName: String? = null, var DomainName: String? = null)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Location(
|
||||||
|
var Country: String? = null,
|
||||||
|
var CountryCode: String? = null,
|
||||||
|
var City: String? = null,
|
||||||
|
var CityCode: String? = null,
|
||||||
|
var Priority: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DNSConfig(
|
||||||
|
var Resolvers: List<DnsType.Resolver>? = null,
|
||||||
|
var Routes: Map<String, List<DnsType.Resolver>?>? = null,
|
||||||
|
var FallbackResolvers: List<DnsType.Resolver>? = null,
|
||||||
|
var Domains: List<String>? = null,
|
||||||
|
var Nameservers: List<Addr>? = null
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,36 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
typealias Addr = String
|
||||||
|
|
||||||
|
typealias Prefix = String
|
||||||
|
|
||||||
|
typealias NodeID = Long
|
||||||
|
|
||||||
|
typealias KeyNodePublic = String
|
||||||
|
|
||||||
|
typealias MachineKey = String
|
||||||
|
|
||||||
|
typealias UserID = Long
|
||||||
|
|
||||||
|
typealias Time = String
|
||||||
|
|
||||||
|
typealias StableNodeID = String
|
||||||
|
|
||||||
|
typealias BugReportID = String
|
||||||
|
|
||||||
|
val GoZeroTimeString = "0001-01-01T00:00:00Z"
|
||||||
|
|
||||||
|
// Represents and empty message with a single 'property' field.
|
||||||
|
class Empty {
|
||||||
|
@Serializable data class Message(val property: String = "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsable errors returned by localApiService
|
||||||
|
class Errors {
|
||||||
|
@Serializable data class GenericError(val error: String)
|
||||||
|
}
|
@ -0,0 +1,140 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.notifier
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.tailscale.ipn.App
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.UninitializedApp.Companion.notificationManager
|
||||||
|
import com.tailscale.ipn.ui.model.Health
|
||||||
|
import com.tailscale.ipn.ui.model.Health.UnhealthyState
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import com.tailscale.ipn.util.TSLog
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
class HealthNotifier(
|
||||||
|
healthStateFlow: StateFlow<Health.State?>,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val HEALTH_CHANNEL_ID = "tailscale-health"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val TAG = "Health"
|
||||||
|
private val ignoredWarnableCodes: Set<String> =
|
||||||
|
setOf(
|
||||||
|
// Ignored on Android because installing unstable takes quite some effort
|
||||||
|
"is-using-unstable-version",
|
||||||
|
|
||||||
|
// Ignored on Android because we already have a dedicated connected/not connected
|
||||||
|
// notification
|
||||||
|
"wantrunning-false")
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
healthStateFlow
|
||||||
|
.distinctUntilChanged { old, new -> old?.Warnings?.count() == new?.Warnings?.count() }
|
||||||
|
.debounce(5000)
|
||||||
|
.collect { health ->
|
||||||
|
TSLog.d(TAG, "Health updated: ${health?.Warnings?.keys?.sorted()}")
|
||||||
|
health?.Warnings?.let {
|
||||||
|
notifyHealthUpdated(it.values.mapNotNull { it }.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentWarnings: StateFlow<Set<UnhealthyState>> = MutableStateFlow(setOf())
|
||||||
|
val currentIcon: StateFlow<Int?> = MutableStateFlow(null)
|
||||||
|
|
||||||
|
private fun notifyHealthUpdated(warnings: Array<UnhealthyState>) {
|
||||||
|
val warningsBeforeAdd = currentWarnings.value
|
||||||
|
val currentWarnableCodes = warnings.map { it.WarnableCode }.toSet()
|
||||||
|
val addedWarnings: MutableSet<UnhealthyState> = mutableSetOf()
|
||||||
|
val isWarmingUp = warnings.any { it.WarnableCode == "warming-up" }
|
||||||
|
|
||||||
|
for (warning in warnings) {
|
||||||
|
if (ignoredWarnableCodes.contains(warning.WarnableCode)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
addedWarnings.add(warning)
|
||||||
|
|
||||||
|
if (this.currentWarnings.value.contains(warning)) {
|
||||||
|
// Already notified, skip
|
||||||
|
continue
|
||||||
|
} else if (warning.hiddenByDependencies(currentWarnableCodes)) {
|
||||||
|
// Ignore this warning because a dependency is also unhealthy
|
||||||
|
TSLog.d(TAG, "Ignoring ${warning.WarnableCode} because of dependency")
|
||||||
|
continue
|
||||||
|
} else if (!isWarmingUp) {
|
||||||
|
TSLog.d(TAG, "Adding health warning: ${warning.WarnableCode}")
|
||||||
|
this.currentWarnings.set(this.currentWarnings.value + warning)
|
||||||
|
if (warning.Severity == Health.Severity.high) {
|
||||||
|
this.sendNotification(warning.Title, warning.Text, warning.WarnableCode)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TSLog.d(TAG, "Ignoring ${warning.WarnableCode} because warming up")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val warningsToDrop = warningsBeforeAdd.minus(addedWarnings)
|
||||||
|
if (warningsToDrop.isNotEmpty()) {
|
||||||
|
TSLog.d(TAG, "Dropping health warnings with codes $warningsToDrop")
|
||||||
|
this.removeNotifications(warningsToDrop)
|
||||||
|
}
|
||||||
|
currentWarnings.set(this.currentWarnings.value.subtract(warningsToDrop))
|
||||||
|
this.updateIcon()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateIcon() {
|
||||||
|
if (currentWarnings.value.isEmpty()) {
|
||||||
|
this.currentIcon.set(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentWarnings.value.any {
|
||||||
|
(it.Severity == Health.Severity.high || it.ImpactsConnectivity == true)
|
||||||
|
}) {
|
||||||
|
this.currentIcon.set(R.drawable.warning_rounded)
|
||||||
|
} else {
|
||||||
|
this.currentIcon.set(R.drawable.info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendNotification(title: String, text: String, code: String) {
|
||||||
|
TSLog.d(TAG, "Sending notification for $code")
|
||||||
|
val notification =
|
||||||
|
NotificationCompat.Builder(App.get().applicationContext, HEALTH_CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.build()
|
||||||
|
if (ActivityCompat.checkSelfPermission(
|
||||||
|
App.get().applicationContext, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||||
|
PackageManager.PERMISSION_GRANTED) {
|
||||||
|
TSLog.d(TAG, "Notification permission not granted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notificationManager.notify(code.hashCode(), notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeNotifications(warnings: Set<UnhealthyState>) {
|
||||||
|
TSLog.d(TAG, "Removing notifications for $warnings")
|
||||||
|
for (warning in warnings) {
|
||||||
|
notificationManager.cancel(warning.WarnableCode.hashCode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,116 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.notifier
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.tailscale.ipn.App
|
||||||
|
import com.tailscale.ipn.ui.model.Empty
|
||||||
|
import com.tailscale.ipn.ui.model.Health
|
||||||
|
import com.tailscale.ipn.ui.model.Ipn
|
||||||
|
import com.tailscale.ipn.ui.model.Ipn.Notify
|
||||||
|
import com.tailscale.ipn.ui.model.Netmap
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.decodeFromStream
|
||||||
|
import com.tailscale.ipn.util.TSLog
|
||||||
|
|
||||||
|
// Notifier is a wrapper around the IPN Bus notifier. It provides a way to watch
|
||||||
|
// for changes in various parts of the Tailscale engine. You will typically only use
|
||||||
|
// a single Notifier per instance of your application which lasts for the lifetime of
|
||||||
|
// the process.
|
||||||
|
//
|
||||||
|
// The primary entry point here is watchIPNBus which will start a watcher on the IPN bus
|
||||||
|
// and return you the session Id. When you are done with your watcher, you must call
|
||||||
|
// unwatchIPNBus with the sessionId.
|
||||||
|
object Notifier {
|
||||||
|
private val TAG = Notifier::class.simpleName
|
||||||
|
private val decoder = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
// General IPN Bus State
|
||||||
|
private val _state = MutableStateFlow(Ipn.State.NoState)
|
||||||
|
val state: StateFlow<Ipn.State> = _state
|
||||||
|
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
|
||||||
|
val prefs: StateFlow<Ipn.Prefs?> = MutableStateFlow(null)
|
||||||
|
val engineStatus: StateFlow<Ipn.EngineStatus?> = MutableStateFlow(null)
|
||||||
|
val tailFSShares: StateFlow<Map<String, String>?> = MutableStateFlow(null)
|
||||||
|
val browseToURL: StateFlow<String?> = MutableStateFlow(null)
|
||||||
|
val loginFinished: StateFlow<String?> = MutableStateFlow(null)
|
||||||
|
val version: StateFlow<String?> = MutableStateFlow(null)
|
||||||
|
val health: StateFlow<Health.State?> = MutableStateFlow(null)
|
||||||
|
|
||||||
|
// Taildrop-specific State
|
||||||
|
val outgoingFiles: StateFlow<List<Ipn.OutgoingFile>?> = MutableStateFlow(null)
|
||||||
|
val incomingFiles: StateFlow<List<Ipn.PartialFile>?> = MutableStateFlow(null)
|
||||||
|
val filesWaiting: StateFlow<Empty.Message?> = MutableStateFlow(null)
|
||||||
|
|
||||||
|
private lateinit var app: libtailscale.Application
|
||||||
|
private var manager: libtailscale.NotificationManager? = null
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun setApp(newApp: libtailscale.Application) {
|
||||||
|
app = newApp
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
fun start(scope: CoroutineScope) {
|
||||||
|
TSLog.d(TAG, "Starting Notifier")
|
||||||
|
if (!::app.isInitialized) {
|
||||||
|
App.get()
|
||||||
|
}
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val mask =
|
||||||
|
NotifyWatchOpt.Netmap.value or
|
||||||
|
NotifyWatchOpt.Prefs.value or
|
||||||
|
NotifyWatchOpt.InitialState.value or
|
||||||
|
NotifyWatchOpt.InitialHealthState.value
|
||||||
|
manager =
|
||||||
|
app.watchNotifications(mask.toLong()) { notification ->
|
||||||
|
val notify = decoder.decodeFromStream<Notify>(notification.inputStream())
|
||||||
|
notify.State?.let { state.set(Ipn.State.fromInt(it)) }
|
||||||
|
notify.NetMap?.let(netmap::set)
|
||||||
|
notify.Prefs?.let(prefs::set)
|
||||||
|
notify.Engine?.let(engineStatus::set)
|
||||||
|
notify.TailFSShares?.let(tailFSShares::set)
|
||||||
|
notify.BrowseToURL?.let(browseToURL::set)
|
||||||
|
notify.LoginFinished?.let { loginFinished.set(it.property) }
|
||||||
|
notify.Version?.let(version::set)
|
||||||
|
notify.OutgoingFiles?.let(outgoingFiles::set)
|
||||||
|
notify.FilesWaiting?.let(filesWaiting::set)
|
||||||
|
notify.IncomingFiles?.let(incomingFiles::set)
|
||||||
|
notify.Health?.let(health::set)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
TSLog.d(TAG, "Stopping Notifier")
|
||||||
|
manager?.let {
|
||||||
|
it.stop()
|
||||||
|
manager = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which
|
||||||
|
// what we want to see on the Notify bus
|
||||||
|
private enum class NotifyWatchOpt(val value: Int) {
|
||||||
|
EngineUpdates(1),
|
||||||
|
InitialState(2),
|
||||||
|
Prefs(4),
|
||||||
|
Netmap(8),
|
||||||
|
NoPrivateKey(16),
|
||||||
|
InitialTailFSShares(32),
|
||||||
|
InitialOutgoingFiles(64),
|
||||||
|
InitialHealthState(128),
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setState(newState: Ipn.State) {
|
||||||
|
_state.value = newState
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
// TODO: replace references to these with references to material theme
|
||||||
|
val ts_color_light_blue = Color(0xFF4B70CC)
|
@ -0,0 +1,467 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.ButtonColors
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.ColorScheme
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ListItemColors
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.TextFieldColors
|
||||||
|
import androidx.compose.material3.TopAppBarColors
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
|
||||||
|
val colors =
|
||||||
|
if (useDarkTheme) {
|
||||||
|
DarkColors
|
||||||
|
} else {
|
||||||
|
LightColors
|
||||||
|
}
|
||||||
|
|
||||||
|
val typography =
|
||||||
|
Typography(
|
||||||
|
// titleMedium is styled to be slightly larger than bodyMedium for emphasis
|
||||||
|
titleMedium =
|
||||||
|
MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp, lineHeight = 26.sp),
|
||||||
|
// bodyMedium is styled to use same line height as titleMedium to ensure even vertical
|
||||||
|
// margins in list items.
|
||||||
|
bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp))
|
||||||
|
|
||||||
|
// TODO: Migrate to Activity.enableEdgeToEdge
|
||||||
|
@Suppress("deprecation") val systemUiController = rememberSystemUiController()
|
||||||
|
|
||||||
|
DisposableEffect(systemUiController, useDarkTheme) {
|
||||||
|
systemUiController.setStatusBarColor(color = colors.surfaceContainer)
|
||||||
|
systemUiController.setNavigationBarColor(color = Color.Black)
|
||||||
|
onDispose {}
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(colorScheme = colors, typography = typography, content = content)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val LightColors =
|
||||||
|
lightColorScheme(
|
||||||
|
primary = Color(0xFF4B70CC), // blue-500
|
||||||
|
onPrimary = Color(0xFFFFFFFF), // white
|
||||||
|
primaryContainer = Color(0xFFF0F5FF), // blue-0
|
||||||
|
onPrimaryContainer = Color(0xFF3E5DB3), // blue-600
|
||||||
|
error = Color(0xFFB22C30), // red-500
|
||||||
|
onError = Color(0xFFFFFFFF), // white
|
||||||
|
errorContainer = Color(0xFFFEF6F3), // red-0
|
||||||
|
onErrorContainer = Color(0xFF930921), // red-600
|
||||||
|
surfaceDim = Color(0xFFF7F5F4), // gray-100
|
||||||
|
surface = Color(0xFFFFFFFF), // white,
|
||||||
|
background = Color(0xFFF7F5F4), // gray-100
|
||||||
|
surfaceBright = Color(0xFFFFFFFF), // white
|
||||||
|
surfaceContainerLowest = Color(0xFFFFFFFF), // white
|
||||||
|
surfaceContainerLow = Color(0xFFF7F5F4), // gray-100
|
||||||
|
surfaceContainer = Color(0xFFF7F5F4), // gray-100
|
||||||
|
surfaceContainerHigh = Color(0xFFF7F5F4), // gray-100
|
||||||
|
surfaceContainerHighest = Color(0xFFF7F5F4), // gray-100
|
||||||
|
surfaceVariant = Color(0xFFF7F5F4), // gray-100,
|
||||||
|
onSurface = Color(0xFF232222), // gray-800
|
||||||
|
onSurfaceVariant = Color(0xFF706E6D), // gray-500
|
||||||
|
outline = Color(0xFF706E6D), // gray-500
|
||||||
|
outlineVariant = Color(0xFFEDEBEA), // gray-200
|
||||||
|
inverseSurface = Color(0xFF232222), // gray-800
|
||||||
|
inverseOnSurface = Color(0xFFFFFFFF), // white
|
||||||
|
scrim = Color(0xAA000000), // black
|
||||||
|
)
|
||||||
|
|
||||||
|
private val DarkColors =
|
||||||
|
darkColorScheme(
|
||||||
|
primary = Color(0xFF3E5DB3), // blue-600
|
||||||
|
onPrimary = Color(0xFFFFFFFF), // white
|
||||||
|
primaryContainer = Color(0xFFf0f5ff), // blue-0
|
||||||
|
onPrimaryContainer = Color(0xFF5A82DC), // blue-400
|
||||||
|
error = Color(0xFFEF5350), // red-400
|
||||||
|
onError = Color(0xFFFFFFFF), // white
|
||||||
|
errorContainer = Color(0xFFfff6f4), // red-0
|
||||||
|
onErrorContainer = Color(0xFF940822), // red-600
|
||||||
|
surfaceDim = Color(0xFF1f1e1e), // gray-900
|
||||||
|
surface = Color(0xFF232222), // gray-800
|
||||||
|
background = Color(0xFF181717), // gray-1000
|
||||||
|
surfaceBright = Color(0xFF444342), // gray-600
|
||||||
|
surfaceContainerLowest = Color(0xFF1f1e1e), // gray-900
|
||||||
|
surfaceContainerLow = Color(0xFF232222), // gray-800
|
||||||
|
surfaceContainer = Color(0xFF181717), // gray-1000
|
||||||
|
surfaceContainerHigh = Color(0xFF232222), // gray-800
|
||||||
|
surfaceContainerHighest = Color(0xFF2e2d2d), // gray-700
|
||||||
|
surfaceVariant = Color(0xFF1f1e1e), // gray-900
|
||||||
|
onSurface = Color(0xFFfaf9f8), // gray-0
|
||||||
|
onSurfaceVariant = Color(0xFFafacab), // gray-400
|
||||||
|
outline = Color(0xFF706E6D), // gray-500
|
||||||
|
outlineVariant = Color(0xFF2E2D2D), // gray-700
|
||||||
|
inverseSurface = Color(0xFFEDEBEA), // gray-200
|
||||||
|
inverseOnSurface = Color(0xFF000000), // black
|
||||||
|
scrim = Color(0xAA000000), // black
|
||||||
|
)
|
||||||
|
|
||||||
|
val ColorScheme.warning: Color
|
||||||
|
@Composable
|
||||||
|
get() =
|
||||||
|
if (isSystemInDarkTheme()) {
|
||||||
|
Color(0xFFBB5504) // yellow-400
|
||||||
|
} else {
|
||||||
|
Color(0xFFD97917) // yellow-300
|
||||||
|
}
|
||||||
|
|
||||||
|
val ColorScheme.onWarning: Color
|
||||||
|
get() = Color(0xFFFFFFFF) // white
|
||||||
|
|
||||||
|
val ColorScheme.warningContainer: Color
|
||||||
|
get() = Color(0xFFFFFAEE) // orange-0
|
||||||
|
|
||||||
|
val ColorScheme.onWarningContainer: Color
|
||||||
|
get() = Color(0xFF7E1E22) // orange-600
|
||||||
|
|
||||||
|
val ColorScheme.success: Color
|
||||||
|
get() = Color(0xFF0A825D) // green-400
|
||||||
|
|
||||||
|
val ColorScheme.onSuccess: Color
|
||||||
|
get() = Color(0xFFFFFFFF) // white
|
||||||
|
|
||||||
|
val ColorScheme.successContainer: Color
|
||||||
|
get() = Color(0xFFEFFEEC) // green-0
|
||||||
|
|
||||||
|
val ColorScheme.onSuccessContainer: Color
|
||||||
|
get() = Color(0xFF0E4B3B) // green-600
|
||||||
|
|
||||||
|
val ColorScheme.on: Color
|
||||||
|
get() = Color(0xFF1CA672) // green-300
|
||||||
|
|
||||||
|
val ColorScheme.off: Color
|
||||||
|
@Composable
|
||||||
|
get() =
|
||||||
|
if (isSystemInDarkTheme()) {
|
||||||
|
Color(0xFF444342) // gray-600
|
||||||
|
} else {
|
||||||
|
Color(0xFFD9D6D5) // gray-300
|
||||||
|
}
|
||||||
|
|
||||||
|
val ColorScheme.link: Color
|
||||||
|
get() = onPrimaryContainer
|
||||||
|
|
||||||
|
val ColorScheme.customError: Color
|
||||||
|
@Composable
|
||||||
|
get() =
|
||||||
|
if (isSystemInDarkTheme()) {
|
||||||
|
Color(0xFF940821) // red-600
|
||||||
|
} else {
|
||||||
|
Color(0xFFB22D30) // red-500
|
||||||
|
}
|
||||||
|
|
||||||
|
val ColorScheme.customErrorContainer: Color
|
||||||
|
@Composable
|
||||||
|
get() =
|
||||||
|
if (isSystemInDarkTheme()) {
|
||||||
|
Color(0xFF760012) // red-700
|
||||||
|
} else {
|
||||||
|
Color(0xFF940821) // red-600
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main color scheme for list items, uses onPrimaryContainer color for leading and trailing icons.
|
||||||
|
*/
|
||||||
|
val ColorScheme.listItem: ListItemColors
|
||||||
|
@Composable
|
||||||
|
get() {
|
||||||
|
val default = ListItemDefaults.colors()
|
||||||
|
return ListItemColors(
|
||||||
|
containerColor = default.containerColor,
|
||||||
|
headlineColor = default.headlineColor,
|
||||||
|
leadingIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
overlineColor = default.overlineColor,
|
||||||
|
supportingTextColor = default.supportingTextColor,
|
||||||
|
trailingIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
disabledHeadlineColor = default.disabledHeadlineColor,
|
||||||
|
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
||||||
|
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like listItem, but with the overline content using the onSurface color. */
|
||||||
|
val ColorScheme.titledListItem: ListItemColors
|
||||||
|
@Composable
|
||||||
|
get() {
|
||||||
|
val default = listItem
|
||||||
|
return ListItemColors(
|
||||||
|
containerColor = default.containerColor,
|
||||||
|
headlineColor = default.headlineColor,
|
||||||
|
leadingIconColor = default.leadingIconColor,
|
||||||
|
overlineColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
supportingTextColor = default.supportingTextColor,
|
||||||
|
trailingIconColor = default.trailingIconColor,
|
||||||
|
disabledHeadlineColor = default.disabledHeadlineColor,
|
||||||
|
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
||||||
|
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Color scheme for disabled list items. */
|
||||||
|
val ColorScheme.disabledListItem: ListItemColors
|
||||||
|
@Composable
|
||||||
|
get() {
|
||||||
|
val default = ListItemDefaults.colors()
|
||||||
|
return ListItemColors(
|
||||||
|
containerColor = default.containerColor,
|
||||||
|
headlineColor = MaterialTheme.colorScheme.disabled,
|
||||||
|
leadingIconColor = default.leadingIconColor,
|
||||||
|
overlineColor = default.overlineColor,
|
||||||
|
supportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
trailingIconColor = default.trailingIconColor,
|
||||||
|
disabledHeadlineColor = default.disabledHeadlineColor,
|
||||||
|
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
||||||
|
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Color scheme for list items that should be styled as a surface container. */
|
||||||
|
val ColorScheme.surfaceContainerListItem: ListItemColors
|
||||||
|
@Composable
|
||||||
|
get() {
|
||||||
|
val default = ListItemDefaults.colors()
|
||||||
|
return ListItemColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
headlineColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
leadingIconColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
overlineColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
supportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
trailingIconColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
disabledHeadlineColor = default.disabledHeadlineColor,
|
||||||
|
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
||||||
|
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Color scheme for list items that should be styled as a primary item. */
|
||||||
|
val ColorScheme.primaryListItem: ListItemColors
|
||||||
|
@Composable
|
||||||
|
get() {
|
||||||
|
val default = ListItemDefaults.colors()
|
||||||
|
return ListItemColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
headlineColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
|
||||||
|
supportingTextColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
disabledHeadlineColor = default.disabledHeadlineColor,
|
||||||
|
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
||||||
|
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Color scheme for list items that should be styled as a warning item. */
|
||||||
|
val ColorScheme.warningListItem: ListItemColors
|
||||||
|
@Composable
|
||||||
|
get() {
|
||||||
|
val default = ListItemDefaults.colors()
|
||||||
|
return ListItemColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.warning,
|
||||||
|
headlineColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f),
|
||||||
|
supportingTextColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
disabledHeadlineColor = default.disabledHeadlineColor,
|
||||||
|
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
||||||
|
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Color scheme for list items that should be styled as an error item. */
|
||||||
|
val ColorScheme.errorListItem: ListItemColors
|
||||||
|
@Composable
|
||||||
|
get() {
|
||||||
|
val default = ListItemDefaults.colors()
|
||||||
|
return ListItemColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.customError,
|
||||||
|
headlineColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f),
|
||||||
|
supportingTextColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
disabledHeadlineColor = default.disabledHeadlineColor,
|
||||||
|
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
||||||
|
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Main color scheme for top app bar, styles it as a surface container. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
val ColorScheme.topAppBar: TopAppBarColors
|
||||||
|
@Composable
|
||||||
|
get() =
|
||||||
|
TopAppBarDefaults.topAppBarColors()
|
||||||
|
.copy(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
|
||||||
|
val ColorScheme.secondaryButton: ButtonColors
|
||||||
|
@Composable
|
||||||
|
get() {
|
||||||
|
val defaults = ButtonDefaults.buttonColors()
|
||||||
|
if (isSystemInDarkTheme()) {
|
||||||
|
return ButtonColors(
|
||||||
|
containerColor = Color(0xFF4B70CC), // blue-500
|
||||||
|
contentColor = Color(0xFFFFFFFF), // white
|
||||||
|
disabledContainerColor = defaults.disabledContainerColor,
|
||||||
|
disabledContentColor = defaults.disabledContentColor)
|
||||||
|
} else {
|
||||||
|
return ButtonColors(
|
||||||
|
containerColor = Color(0xFF5A82DC), // blue-400
|
||||||
|
contentColor = Color(0xFFFFFFFF), // white
|
||||||
|
disabledContainerColor = defaults.disabledContainerColor,
|
||||||
|
disabledContentColor = defaults.disabledContentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val ColorScheme.errorButton: ButtonColors
|
||||||
|
@Composable
|
||||||
|
get() {
|
||||||
|
val defaults = ButtonDefaults.buttonColors()
|
||||||
|
if (isSystemInDarkTheme()) {
|
||||||
|
return ButtonColors(
|
||||||
|
containerColor = Color(0xFFB22D30), // red-500
|
||||||
|
contentColor = Color(0xFFFFFFFF), // white
|
||||||
|
disabledContainerColor = defaults.disabledContainerColor,
|
||||||
|
disabledContentColor = defaults.disabledContentColor)
|
||||||
|
} else {
|
||||||
|
return ButtonColors(
|
||||||
|
containerColor = Color(0xFFD04841), // red-400
|
||||||
|
contentColor = Color(0xFFFFFFFF), // white
|
||||||
|
disabledContainerColor = defaults.disabledContainerColor,
|
||||||
|
disabledContentColor = defaults.disabledContentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val ColorScheme.warningButton: ButtonColors
|
||||||
|
@Composable
|
||||||
|
get() {
|
||||||
|
val defaults = ButtonDefaults.buttonColors()
|
||||||
|
if (isSystemInDarkTheme()) {
|
||||||
|
return ButtonColors(
|
||||||
|
containerColor = Color(0xFFD97917), // yellow-300
|
||||||
|
contentColor = Color(0xFFFFFFFF), // white
|
||||||
|
disabledContainerColor = defaults.disabledContainerColor,
|
||||||
|
disabledContentColor = defaults.disabledContentColor)
|
||||||
|
} else {
|
||||||
|
return ButtonColors(
|
||||||
|
containerColor = Color(0xFFE5993E), // yellow-200
|
||||||
|
contentColor = Color(0xFFFFFFFF), // white
|
||||||
|
disabledContainerColor = defaults.disabledContainerColor,
|
||||||
|
disabledContentColor = defaults.disabledContentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val ColorScheme.defaultTextColor: Color
|
||||||
|
@Composable
|
||||||
|
get() =
|
||||||
|
if (isSystemInDarkTheme()) {
|
||||||
|
Color.White
|
||||||
|
} else {
|
||||||
|
Color.Black
|
||||||
|
}
|
||||||
|
|
||||||
|
val ColorScheme.logoBackground: Color
|
||||||
|
@Composable
|
||||||
|
get() =
|
||||||
|
if (isSystemInDarkTheme()) {
|
||||||
|
Color(0xFFFFFFFF) // white
|
||||||
|
} else {
|
||||||
|
Color(0xFF1F1E1E)
|
||||||
|
}
|
||||||
|
|
||||||
|
val ColorScheme.standaloneLogoDotEnabled: Color
|
||||||
|
@Composable
|
||||||
|
get() =
|
||||||
|
if (isSystemInDarkTheme()) {
|
||||||
|
Color(0xFFFFFFFF)
|
||||||
|
} else {
|
||||||
|
Color(0xFF000000)
|
||||||
|
}
|
||||||
|
|
||||||
|
val ColorScheme.standaloneLogoDotDisabled: Color
|
||||||
|
@Composable
|
||||||
|
get() =
|
||||||
|
if (isSystemInDarkTheme()) {
|
||||||
|
Color(0x66FFFFFF)
|
||||||
|
} else {
|
||||||
|
Color(0x661F1E1E)
|
||||||
|
}
|
||||||
|
|
||||||
|
val ColorScheme.onBackgroundLogoDotEnabled: Color
|
||||||
|
@Composable
|
||||||
|
get() =
|
||||||
|
if (isSystemInDarkTheme()) {
|
||||||
|
Color(0xFF141414)
|
||||||
|
} else {
|
||||||
|
Color(0xFFFFFFFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
val ColorScheme.onBackgroundLogoDotDisabled: Color
|
||||||
|
@Composable
|
||||||
|
get() =
|
||||||
|
if (isSystemInDarkTheme()) {
|
||||||
|
Color(0x66141414)
|
||||||
|
} else {
|
||||||
|
Color(0x66FFFFFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
val ColorScheme.exitNodeToggleButton: ButtonColors
|
||||||
|
@Composable
|
||||||
|
get() {
|
||||||
|
val defaults = ButtonDefaults.buttonColors()
|
||||||
|
return if (isSystemInDarkTheme()) {
|
||||||
|
ButtonColors(
|
||||||
|
containerColor = Color(0xFF444342), // grey-600
|
||||||
|
contentColor = Color(0xFFFFFFFF), // white
|
||||||
|
disabledContainerColor = defaults.disabledContainerColor,
|
||||||
|
disabledContentColor = defaults.disabledContentColor)
|
||||||
|
} else {
|
||||||
|
ButtonColors(
|
||||||
|
containerColor = Color(0xFFEDEBEA), // grey-300
|
||||||
|
contentColor = Color(0xFF000000), // black
|
||||||
|
disabledContainerColor = defaults.disabledContainerColor,
|
||||||
|
disabledContentColor = defaults.disabledContentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val ColorScheme.disabled: Color
|
||||||
|
get() = Color(0xFFAFACAB) // gray-400
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
val ColorScheme.searchBarColors: TextFieldColors
|
||||||
|
@Composable
|
||||||
|
get() {
|
||||||
|
return OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedLeadingIconColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
unfocusedLeadingIconColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
focusedTextColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
disabledTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
disabledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
focusedBorderColor = Color.Transparent,
|
||||||
|
unfocusedBorderColor = Color.Transparent)
|
||||||
|
}
|
||||||
|
|
||||||
|
val TextStyle.short: TextStyle
|
||||||
|
get() = copy(lineHeight = 20.sp)
|
||||||
|
|
||||||
|
val Typography.minTextSize: TextUnit
|
||||||
|
get() = 10.sp
|
@ -0,0 +1,24 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
import com.tailscale.ipn.ui.model.Ipn
|
||||||
|
|
||||||
|
class AdvertisedRoutesHelper {
|
||||||
|
companion object {
|
||||||
|
fun exitNodeOnFromPrefs(prefs: Ipn.Prefs): Boolean {
|
||||||
|
var v4 = false
|
||||||
|
var v6 = false
|
||||||
|
prefs.AdvertiseRoutes?.forEach {
|
||||||
|
if (it == "0.0.0.0/0") {
|
||||||
|
v4 = true
|
||||||
|
}
|
||||||
|
if (it == "::/0") {
|
||||||
|
v6 = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v4 && v6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.tailscale.ipn.UninitializedApp
|
||||||
|
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
|
||||||
|
|
||||||
|
object AndroidTVUtil {
|
||||||
|
fun isAndroidTV(): Boolean {
|
||||||
|
val pm = UninitializedApp.get().packageManager
|
||||||
|
return (pm.hasSystemFeature(@Suppress("deprecation") PackageManager.FEATURE_TELEVISION) ||
|
||||||
|
pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies a letterbox effect iff we're running on Android TV to reduce the overall width
|
||||||
|
// of the UI.
|
||||||
|
fun Modifier.universalFit(): Modifier {
|
||||||
|
return when (isAndroidTV()) {
|
||||||
|
true -> this.padding(horizontal = 150.dp, vertical = 10.dp).clip(RoundedCornerShape(10.dp))
|
||||||
|
false -> this
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,78 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.TextLayoutResult
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
|
||||||
|
// AutoResizingText automatically resizes text up to the specified minFontSize in order to avoid
|
||||||
|
// overflowing. It is based on https://stackoverflow.com/a/66090448 licensed under CC BY-SA 4.0.
|
||||||
|
@Composable
|
||||||
|
fun AutoResizingText(
|
||||||
|
text: String,
|
||||||
|
minFontSize: TextUnit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
color: Color = Color.Unspecified,
|
||||||
|
fontSize: TextUnit = TextUnit.Unspecified,
|
||||||
|
fontStyle: FontStyle? = null,
|
||||||
|
fontWeight: FontWeight? = null,
|
||||||
|
fontFamily: FontFamily? = null,
|
||||||
|
letterSpacing: TextUnit = TextUnit.Unspecified,
|
||||||
|
textDecoration: TextDecoration? = null,
|
||||||
|
textAlign: TextAlign? = null,
|
||||||
|
lineHeight: TextUnit = TextUnit.Unspecified,
|
||||||
|
overflow: TextOverflow = TextOverflow.Clip,
|
||||||
|
maxLines: Int = 1,
|
||||||
|
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
|
||||||
|
style: TextStyle = LocalTextStyle.current
|
||||||
|
) {
|
||||||
|
var textStyle = remember { mutableStateOf(style) }
|
||||||
|
var textOverflow = remember { mutableStateOf(TextOverflow.Clip) }
|
||||||
|
var readyToDraw = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
modifier = modifier.drawWithContent { if (readyToDraw.value) drawContent() },
|
||||||
|
color = color,
|
||||||
|
fontSize = fontSize,
|
||||||
|
fontStyle = fontStyle,
|
||||||
|
fontWeight = fontWeight,
|
||||||
|
fontFamily = fontFamily,
|
||||||
|
letterSpacing = letterSpacing,
|
||||||
|
textDecoration = textDecoration,
|
||||||
|
textAlign = textAlign,
|
||||||
|
lineHeight = lineHeight,
|
||||||
|
overflow = textOverflow.value,
|
||||||
|
maxLines = maxLines,
|
||||||
|
softWrap = false,
|
||||||
|
style = textStyle.value,
|
||||||
|
onTextLayout = { result ->
|
||||||
|
if (result.didOverflowWidth) {
|
||||||
|
var newSize = textStyle.value.fontSize * 0.9
|
||||||
|
if (newSize < minFontSize) {
|
||||||
|
newSize = minFontSize
|
||||||
|
textOverflow.value = overflow
|
||||||
|
}
|
||||||
|
textStyle.value = textStyle.value.copy(fontSize = newSize)
|
||||||
|
} else {
|
||||||
|
readyToDraw.value = true
|
||||||
|
}
|
||||||
|
onTextLayout?.let { it(result) }
|
||||||
|
})
|
||||||
|
}
|
@ -0,0 +1,54 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.theme.titledListItem
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ClipboardValueView(value: String, title: String? = null, subtitle: String? = null) {
|
||||||
|
val localClipboardManager = LocalClipboardManager.current
|
||||||
|
val modifier =
|
||||||
|
Modifier.focusable()
|
||||||
|
.clickable {
|
||||||
|
localClipboardManager.setText(AnnotatedString(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
colors = MaterialTheme.colorScheme.titledListItem,
|
||||||
|
modifier = modifier,
|
||||||
|
overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } },
|
||||||
|
headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) },
|
||||||
|
supportingContent =
|
||||||
|
subtitle?.let {
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.clipboard),
|
||||||
|
stringResource(R.string.copy_to_clipboard),
|
||||||
|
modifier = Modifier.width(24.dp).height(24.dp))
|
||||||
|
})
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
|
||||||
|
// Convenience wrapper for passing formatted strings to Composables
|
||||||
|
class ComposableStringFormatter(
|
||||||
|
@StringRes val stringRes: Int = R.string.template,
|
||||||
|
private vararg val params: Any
|
||||||
|
) {
|
||||||
|
|
||||||
|
// Convenience constructor for passing a non-formatted string directly
|
||||||
|
constructor(string: String) : this(stringRes = R.string.template, string)
|
||||||
|
|
||||||
|
// Returns the fully formatted string
|
||||||
|
@Composable fun getString(): String = stringResource(id = stringRes, *params)
|
||||||
|
}
|
@ -0,0 +1,53 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.theme.on
|
||||||
|
|
||||||
|
sealed class ConnectionMode {
|
||||||
|
class NotConnected : ConnectionMode()
|
||||||
|
|
||||||
|
class Derp(val relayName: String) : ConnectionMode()
|
||||||
|
|
||||||
|
class Direct : ConnectionMode()
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun titleString(): String {
|
||||||
|
return when (this) {
|
||||||
|
is NotConnected -> stringResource(id = R.string.not_connected)
|
||||||
|
is Derp -> stringResource(R.string.relayed_connection, relayName)
|
||||||
|
is Direct -> stringResource(R.string.direct_connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun contentKey(): String {
|
||||||
|
return when (this) {
|
||||||
|
is NotConnected -> "NotConnected"
|
||||||
|
is Derp -> "Derp($relayName)"
|
||||||
|
is Direct -> "Direct"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun iconDrawable(): Int {
|
||||||
|
return when (this) {
|
||||||
|
is NotConnected -> R.drawable.xmark_circle
|
||||||
|
is Derp -> R.drawable.link_off
|
||||||
|
is Direct -> R.drawable.link
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun color(): Color {
|
||||||
|
return when (this) {
|
||||||
|
is NotConnected -> MaterialTheme.colorScheme.onPrimary
|
||||||
|
is Derp -> MaterialTheme.colorScheme.error
|
||||||
|
is Direct -> MaterialTheme.colorScheme.on
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,46 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
class DisplayAddress(ip: String) {
|
||||||
|
enum class addrType {
|
||||||
|
V4,
|
||||||
|
V6,
|
||||||
|
MagicDNS
|
||||||
|
}
|
||||||
|
|
||||||
|
val type: addrType =
|
||||||
|
when {
|
||||||
|
ip.isIPV6() -> addrType.V6
|
||||||
|
ip.isIPV4() -> addrType.V4
|
||||||
|
else -> addrType.MagicDNS
|
||||||
|
}
|
||||||
|
|
||||||
|
val typeString: String =
|
||||||
|
when (type) {
|
||||||
|
addrType.V4 -> "IPv4"
|
||||||
|
addrType.V6 -> "IPv6"
|
||||||
|
addrType.MagicDNS -> "MagicDNS"
|
||||||
|
}
|
||||||
|
|
||||||
|
val address: String =
|
||||||
|
when (type) {
|
||||||
|
addrType.MagicDNS -> ip
|
||||||
|
else -> ip.split("/").first()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.isIPV6(): Boolean {
|
||||||
|
return this.contains(":")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.isIPV4(): Boolean {
|
||||||
|
val parts = this.split("/").first().split(".")
|
||||||
|
if (parts.size != 4) return false
|
||||||
|
for (part in parts) {
|
||||||
|
val value = part.toIntOrNull() ?: return false
|
||||||
|
if (value !in 0..255) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
@ -0,0 +1,33 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Code adapted from
|
||||||
|
* https://github.com/piashcse/Compose-museum/blob/master/app/src/main/java/com/piashcse/compose_museum/screens/CountryList.kt#L75
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Copyright 2023 piashcse (Mehedi Hassan Piash)
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
/** Flag turns an ISO3166 country code into a flag emoji. */
|
||||||
|
fun String.flag(): String {
|
||||||
|
val caps = this.uppercase()
|
||||||
|
val flagOffset = 0x1F1E6
|
||||||
|
val asciiOffset = 0x41
|
||||||
|
val firstChar = Character.codePointAt(caps, 0) - asciiOffset + flagOffset
|
||||||
|
val secondChar = Character.codePointAt(caps, 1) - asciiOffset + flagOffset
|
||||||
|
return String(Character.toChars(firstChar)) + String(Character.toChars(secondChar))
|
||||||
|
}
|
@ -0,0 +1,21 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
class InputStreamAdapter(private val inputStream: InputStream) : libtailscale.InputStream {
|
||||||
|
override fun read(): ByteArray? {
|
||||||
|
val b = ByteArray(4096)
|
||||||
|
val i = inputStream.read(b)
|
||||||
|
if (i == -1) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return b.sliceArray(0 ..< i)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
inputStream.close()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,34 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
|
||||||
|
data class InstalledApp(val name: String, val packageName: String)
|
||||||
|
|
||||||
|
class InstalledAppsManager(
|
||||||
|
val packageManager: PackageManager,
|
||||||
|
) {
|
||||||
|
fun fetchInstalledApps(): List<InstalledApp> {
|
||||||
|
return packageManager
|
||||||
|
.getInstalledApplications(PackageManager.GET_META_DATA)
|
||||||
|
.filter(appIsIncluded)
|
||||||
|
.map {
|
||||||
|
InstalledApp(
|
||||||
|
name = it.loadLabel(packageManager).toString(),
|
||||||
|
packageName = it.packageName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sortedBy { it.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val appIsIncluded: (ApplicationInfo) -> Boolean = { app ->
|
||||||
|
app.packageName != "com.tailscale.ipn" &&
|
||||||
|
// Only show apps that can access the Internet
|
||||||
|
packageManager.checkPermission(Manifest.permission.INTERNET, app.packageName) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,125 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyItemScope
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.text.ClickableText
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
object Lists {
|
||||||
|
@Composable
|
||||||
|
fun SectionDivider(title: String? = null) {
|
||||||
|
Box(Modifier.size(0.dp, 16.dp))
|
||||||
|
title?.let { LargeTitle(title) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ItemDivider() {
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LargeTitle(
|
||||||
|
title: String,
|
||||||
|
bottomPadding: Dp = 0.dp,
|
||||||
|
style: TextStyle = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight: FontWeight? = null,
|
||||||
|
focusable: Boolean = false
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.background(color = MaterialTheme.colorScheme.surface, shape = RectangleShape)) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
modifier =
|
||||||
|
Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding)
|
||||||
|
.focusable(focusable),
|
||||||
|
style = style,
|
||||||
|
fontWeight = fontWeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MutedHeader(text: String) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.background(color = MaterialTheme.colorScheme.surface, shape = RectangleShape)) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 16.dp),
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun InfoItem(text: CharSequence, onClick: (() -> Unit)? = null) {
|
||||||
|
val style =
|
||||||
|
MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Box(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||||
|
onClick?.let {
|
||||||
|
ClickableText(text = text as AnnotatedString, style = style, onClick = { onClick() })
|
||||||
|
} ?: run { Text(text as String, style = style) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MultilineDescription(headlineContent: @Composable () -> Unit) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Box(modifier = Modifier.padding(vertical = 8.dp)) { headlineContent() }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Similar to items() but includes a horizontal divider between items. */
|
||||||
|
|
||||||
|
/** Similar to items() but includes a horizontal divider between items. */
|
||||||
|
inline fun <T> LazyListScope.itemsWithDividers(
|
||||||
|
items: List<T>,
|
||||||
|
noinline key: ((item: T) -> Any)? = null,
|
||||||
|
forceLeading: Boolean = false,
|
||||||
|
crossinline contentType: (item: T) -> Any? = { _ -> null },
|
||||||
|
crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
|
||||||
|
) =
|
||||||
|
items(
|
||||||
|
count = items.size,
|
||||||
|
key = if (key != null) { index: Int -> key(items[index]) } else null,
|
||||||
|
contentType = { index -> contentType(items[index]) }) {
|
||||||
|
if (forceLeading && it == 0 || it > 0 && it < items.size) {
|
||||||
|
Lists.ItemDivider()
|
||||||
|
}
|
||||||
|
itemContent(items[it])
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <T> LazyListScope.itemsWithDividers(
|
||||||
|
items: Array<T>,
|
||||||
|
noinline key: ((item: T) -> Any)? = null,
|
||||||
|
forceLeading: Boolean = false,
|
||||||
|
crossinline contentType: (item: T) -> Any? = { _ -> null },
|
||||||
|
crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
|
||||||
|
) = itemsWithDividers(items.toList(), key, forceLeading, contentType, itemContent)
|
@ -0,0 +1,65 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.tailscale.ipn.ui.view.TailscaleLogoView
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
|
object LoadingIndicator {
|
||||||
|
private val loading = MutableStateFlow(false)
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
loading.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Wrap(content: @Composable () -> Unit) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
val isLoading by loading.collectAsState()
|
||||||
|
if (isLoading) {
|
||||||
|
Box(Modifier.clickable {}.matchParentSize().background(Color.Gray.copy(alpha = 0.0f)))
|
||||||
|
|
||||||
|
val showSpinner: State<Boolean> =
|
||||||
|
produceState(initialValue = false) {
|
||||||
|
delay(300)
|
||||||
|
value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSpinner.value) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
TailscaleLogoView(
|
||||||
|
true, usesOnBackgroundColors = false, Modifier.size(72.dp).alpha(0.4f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,129 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
import androidx.compose.ui.util.fastAny
|
||||||
|
import com.tailscale.ipn.mdm.MDMSettings
|
||||||
|
import com.tailscale.ipn.ui.model.Netmap
|
||||||
|
import com.tailscale.ipn.ui.model.Tailcfg
|
||||||
|
import com.tailscale.ipn.ui.model.UserID
|
||||||
|
|
||||||
|
data class PeerSet(val user: Tailcfg.UserProfile?, val peers: List<Tailcfg.Node>)
|
||||||
|
|
||||||
|
class PeerCategorizer {
|
||||||
|
var peerSets: List<PeerSet> = emptyList()
|
||||||
|
var lastSearchResult: List<PeerSet> = emptyList()
|
||||||
|
var lastSearchTerm: String = ""
|
||||||
|
|
||||||
|
fun regenerateGroupedPeers(netmap: Netmap.NetworkMap) {
|
||||||
|
val peers: List<Tailcfg.Node> = netmap.Peers ?: return
|
||||||
|
val selfNode = netmap.SelfNode
|
||||||
|
var grouped = mutableMapOf<UserID, MutableList<Tailcfg.Node>>()
|
||||||
|
|
||||||
|
val mdm = MDMSettings.hiddenNetworkDevices.flow.value.value
|
||||||
|
val hideMyDevices = mdm?.contains("current-user") ?: false
|
||||||
|
val hideOtherDevices = mdm?.contains("other-users") ?: false
|
||||||
|
val hideTaggedDevices = mdm?.contains("tagged-devices") ?: false
|
||||||
|
|
||||||
|
val me = netmap.currentUserProfile()
|
||||||
|
|
||||||
|
for (peer in (peers + selfNode)) {
|
||||||
|
|
||||||
|
val userId = peer.User
|
||||||
|
val profile = netmap.userProfile(userId)
|
||||||
|
|
||||||
|
// Mullvad nodes should not be shown in the peer list
|
||||||
|
if (peer.isMullvadNode) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide devices based on MDM settings
|
||||||
|
if (hideMyDevices && userId == me?.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hideOtherDevices && userId != me?.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hideTaggedDevices && (profile?.isTaggedDevice() == true)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!grouped.containsKey(userId)) {
|
||||||
|
grouped[userId] = mutableListOf()
|
||||||
|
}
|
||||||
|
grouped[userId]?.add(peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
peerSets =
|
||||||
|
grouped
|
||||||
|
.map { (userId, peers) ->
|
||||||
|
val profile = netmap.userProfile(userId)
|
||||||
|
PeerSet(
|
||||||
|
profile,
|
||||||
|
peers.sortedWith { a, b ->
|
||||||
|
when {
|
||||||
|
a.StableID == b.StableID -> 0
|
||||||
|
a.isSelfNode(netmap) -> -1
|
||||||
|
b.isSelfNode(netmap) -> 1
|
||||||
|
else ->
|
||||||
|
(a.ComputedName?.lowercase() ?: "").compareTo(
|
||||||
|
b.ComputedName?.lowercase() ?: "")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.sortedBy {
|
||||||
|
if (it.user?.ID == me?.ID) {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
it.user?.DisplayName?.lowercase() ?: "unknown user"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun groupedAndFilteredPeers(searchTerm: String = ""): List<PeerSet> {
|
||||||
|
if (searchTerm.isEmpty()) {
|
||||||
|
return peerSets
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchTerm == this.lastSearchTerm) {
|
||||||
|
return lastSearchResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// We can optimize out typing... If the search term starts with the last search term, we can
|
||||||
|
// just search the last result
|
||||||
|
val setsToSearch =
|
||||||
|
if (this.lastSearchTerm.isNotEmpty() && searchTerm.startsWith(this.lastSearchTerm))
|
||||||
|
lastSearchResult
|
||||||
|
else peerSets
|
||||||
|
this.lastSearchTerm = searchTerm
|
||||||
|
|
||||||
|
val matchingSets =
|
||||||
|
setsToSearch
|
||||||
|
.map { peerSet ->
|
||||||
|
val user = peerSet.user
|
||||||
|
val peers = peerSet.peers
|
||||||
|
|
||||||
|
val userMatches = user?.DisplayName?.contains(searchTerm, ignoreCase = true) ?: false
|
||||||
|
if (userMatches) {
|
||||||
|
return@map peerSet
|
||||||
|
}
|
||||||
|
|
||||||
|
val matchingPeers =
|
||||||
|
peers.filter {
|
||||||
|
it.displayName.contains(searchTerm, ignoreCase = true) ||
|
||||||
|
(it.Addresses ?: emptyList()).fastAny { addr -> addr.contains(searchTerm) }
|
||||||
|
}
|
||||||
|
if (matchingPeers.isNotEmpty()) {
|
||||||
|
PeerSet(user, matchingPeers)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.filterNotNull()
|
||||||
|
lastSearchResult = matchingSets
|
||||||
|
return matchingSets
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/** Provides a way to expose a MutableStateFlow as an immutable StateFlow. */
|
||||||
|
fun <T> StateFlow<T>.set(v: T) {
|
||||||
|
(this as MutableStateFlow<T>).value = v
|
||||||
|
}
|
@ -0,0 +1,124 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.util.TSLog
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
object TimeUtil {
|
||||||
|
val TAG = "TimeUtil"
|
||||||
|
|
||||||
|
fun keyExpiryFromGoTime(goTime: String?): ComposableStringFormatter {
|
||||||
|
|
||||||
|
val time = goTime ?: return ComposableStringFormatter(R.string.empty)
|
||||||
|
val expTime = epochMillisFromGoTime(time)
|
||||||
|
val now = Instant.now().toEpochMilli()
|
||||||
|
|
||||||
|
var diff = (expTime - now) / 1000
|
||||||
|
|
||||||
|
// Rather than use plurals here, we'll just use the singular form for everything and
|
||||||
|
// double the minimum. "in 70 minutes" instead of "in 1 hour". 121 minutes becomes
|
||||||
|
// 2 hours, as does 179 minutes... Close enough for what this is used for.
|
||||||
|
|
||||||
|
// Key is already expired (x minutes ago)
|
||||||
|
if (diff < 0) {
|
||||||
|
diff = -diff
|
||||||
|
return when (diff) {
|
||||||
|
in 0..60 -> ComposableStringFormatter(R.string.under_a_minute)
|
||||||
|
in 61..7200 ->
|
||||||
|
ComposableStringFormatter(R.string.ago_x_minutes, diff / 60) // 1 minute to 1 hour
|
||||||
|
in 7201..172800 ->
|
||||||
|
ComposableStringFormatter(R.string.ago_x_hours, diff / 3600) // 2 hours to 24 hours
|
||||||
|
in 172801..5184000 ->
|
||||||
|
ComposableStringFormatter(R.string.ago_x_days, diff / 86400) // 2 Days to 60 days
|
||||||
|
in 5184001..124416000 ->
|
||||||
|
ComposableStringFormatter(R.string.ago_x_months, diff / 2592000) // ~2 months to 2 years
|
||||||
|
else ->
|
||||||
|
ComposableStringFormatter(
|
||||||
|
R.string.ago_x_years,
|
||||||
|
diff.toDouble() / 31536000.0) // 2 years to n years (in decimal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key is not expired (in x minutes)
|
||||||
|
return when (diff) {
|
||||||
|
in 0..60 -> ComposableStringFormatter(R.string.under_a_minute)
|
||||||
|
in 61..7200 ->
|
||||||
|
ComposableStringFormatter(R.string.in_x_minutes, diff / 60) // 1 minute to 1 hour
|
||||||
|
in 7201..172800 ->
|
||||||
|
ComposableStringFormatter(R.string.in_x_hours, diff / 3600) // 2 hours to 24 hours
|
||||||
|
in 172801..5184000 ->
|
||||||
|
ComposableStringFormatter(R.string.in_x_days, diff / 86400) // 2 Days to 60 days
|
||||||
|
in 5184001..124416000 ->
|
||||||
|
ComposableStringFormatter(R.string.in_x_months, diff / 2592000) // ~2 months to 2 years
|
||||||
|
else ->
|
||||||
|
ComposableStringFormatter(
|
||||||
|
R.string.in_x_years, diff.toDouble() / 31536000.0) // 2 years to n years (in decimal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun epochMillisFromGoTime(goTime: String): Long {
|
||||||
|
val ta = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(goTime)
|
||||||
|
val i = Instant.from(ta)
|
||||||
|
return i.toEpochMilli()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dateFromGoString(goTime: String): Date {
|
||||||
|
val ta = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(goTime)
|
||||||
|
val i = Instant.from(ta)
|
||||||
|
return Date.from(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if the given Go time string is in the past, or will occur within the given
|
||||||
|
// duration from now.
|
||||||
|
fun isWithinExpiryNotificationWindow(window: Duration, goTime: String): Boolean {
|
||||||
|
val expTime = epochMillisFromGoTime(goTime)
|
||||||
|
val now = Instant.now().toEpochMilli()
|
||||||
|
return (expTime - now) / 1000 < window.seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses a Go duration string (e.g. "2h3.2m4s") and returns a Java Duration object.
|
||||||
|
// Returns null if the input string is not a valid Go duration or contains
|
||||||
|
// units other than y,w,d,h,m,s (ms and us are explicitly not supported).
|
||||||
|
fun duration(goDuration: String): Duration? {
|
||||||
|
if (goDuration.contains("ms") || goDuration.contains("us")) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
var duration = 0.0
|
||||||
|
var valStr = ""
|
||||||
|
for (c in goDuration) {
|
||||||
|
// Scan digits and decimal points
|
||||||
|
if (c.isDigit() || c == '.') {
|
||||||
|
valStr += c
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
val durationFragment = valStr.toDouble()
|
||||||
|
duration +=
|
||||||
|
when (c) {
|
||||||
|
'y' -> durationFragment * 31536000.0 // 365 days
|
||||||
|
'w' -> durationFragment * 604800.0
|
||||||
|
'd' -> durationFragment * 86400.0
|
||||||
|
'h' -> durationFragment * 3600.0
|
||||||
|
'm' -> durationFragment * 60.0
|
||||||
|
's' -> durationFragment
|
||||||
|
else -> {
|
||||||
|
TSLog.e(TAG, "Invalid duration string: $goDuration")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
TSLog.e(TAG, "Invalid duration string: $goDuration")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
valStr = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Duration.ofSeconds(duration.toLong())
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,98 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.tailscale.ipn.BuildConfig
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.Links
|
||||||
|
import com.tailscale.ipn.ui.theme.logoBackground
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AboutView(backToSettings: BackNavigation) {
|
||||||
|
val localClipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
|
Scaffold(topBar = { Header(R.string.about_view_header, onBack = backToSettings) }) { innerPadding
|
||||||
|
->
|
||||||
|
Column(
|
||||||
|
verticalArrangement =
|
||||||
|
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.verticalScroll(rememberScrollState())) {
|
||||||
|
TailscaleLogoView(
|
||||||
|
usesOnBackgroundColors = true,
|
||||||
|
modifier =
|
||||||
|
Modifier.width(100.dp)
|
||||||
|
.height(100.dp)
|
||||||
|
.clip(RoundedCornerShape(50))
|
||||||
|
.background(MaterialTheme.colorScheme.logoBackground)
|
||||||
|
.padding(25.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement =
|
||||||
|
Arrangement.spacedBy(space = 2.dp, alignment = Alignment.CenterVertically),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.about_view_title),
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = MaterialTheme.typography.titleLarge.fontSize)
|
||||||
|
Text(
|
||||||
|
modifier =
|
||||||
|
Modifier.clickable {
|
||||||
|
localClipboardManager.setText(AnnotatedString(BuildConfig.VERSION_NAME))
|
||||||
|
},
|
||||||
|
text = "${stringResource(R.string.version)} ${BuildConfig.VERSION_NAME}",
|
||||||
|
fontWeight = MaterialTheme.typography.bodyMedium.fontWeight,
|
||||||
|
fontSize = MaterialTheme.typography.bodyMedium.fontSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
OpenURLButton(stringResource(R.string.acknowledgements), Links.LICENSES_URL)
|
||||||
|
OpenURLButton(stringResource(R.string.privacy_policy), Links.PRIVACY_POLICY_URL)
|
||||||
|
OpenURLButton(stringResource(R.string.terms_of_service), Links.TERMS_URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.about_view_footnotes),
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = MaterialTheme.typography.labelMedium.fontSize,
|
||||||
|
textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun AboutPreview() {
|
||||||
|
AboutView({})
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.annotation.ExperimentalCoilApi
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.model.IpnLocal
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoilApi::class)
|
||||||
|
@Composable
|
||||||
|
fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50, action: (() -> Unit)? = null) {
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size.dp).clip(CircleShape)) {
|
||||||
|
var modifier = Modifier.size((size * .8f).dp)
|
||||||
|
action?.let {
|
||||||
|
modifier =
|
||||||
|
modifier.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = rememberRipple(bounded = false),
|
||||||
|
onClick = action)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Person,
|
||||||
|
contentDescription = stringResource(R.string.settings_title),
|
||||||
|
modifier = modifier)
|
||||||
|
|
||||||
|
profile?.UserProfile?.ProfilePicURL?.let { url ->
|
||||||
|
AsyncImage(model = url, modifier = Modifier.size((size * 1.2f).dp), contentDescription = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,94 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.ClickableText
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.Links
|
||||||
|
import com.tailscale.ipn.ui.theme.defaultTextColor
|
||||||
|
import com.tailscale.ipn.ui.theme.link
|
||||||
|
import com.tailscale.ipn.ui.util.ClipboardValueView
|
||||||
|
import com.tailscale.ipn.ui.util.Lists
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import com.tailscale.ipn.ui.viewModel.BugReportViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BugReportView(backToSettings: BackNavigation, model: BugReportViewModel = viewModel()) {
|
||||||
|
val handler = LocalUriHandler.current
|
||||||
|
val bugReportID by model.bugReportID.collectAsState()
|
||||||
|
|
||||||
|
Scaffold(topBar = { Header(R.string.bug_report_title, onBack = backToSettings) }) { innerPadding
|
||||||
|
->
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier.padding(innerPadding)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight()
|
||||||
|
.verticalScroll(rememberScrollState())) {
|
||||||
|
Lists.MultilineDescription {
|
||||||
|
ClickableText(
|
||||||
|
text = contactText(),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
onClick = { handler.openUri(Links.SUPPORT_URL) })
|
||||||
|
}
|
||||||
|
|
||||||
|
ClipboardValueView(bugReportID, title = stringResource(R.string.bug_report_id))
|
||||||
|
|
||||||
|
Lists.InfoItem(stringResource(id = R.string.bug_report_id_desc))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun contactText(): AnnotatedString {
|
||||||
|
val annotatedString = buildAnnotatedString {
|
||||||
|
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
|
||||||
|
append(stringResource(id = R.string.bug_report_instructions_prefix))
|
||||||
|
}
|
||||||
|
|
||||||
|
pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL)
|
||||||
|
withStyle(
|
||||||
|
style =
|
||||||
|
SpanStyle(
|
||||||
|
color = MaterialTheme.colorScheme.link,
|
||||||
|
textDecoration = TextDecoration.Underline)) {
|
||||||
|
append(stringResource(id = R.string.bug_report_instructions_linktext))
|
||||||
|
}
|
||||||
|
pop()
|
||||||
|
|
||||||
|
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
|
||||||
|
append(stringResource(id = R.string.bug_report_instructions_suffix))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return annotatedString
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun BugReportPreview() {
|
||||||
|
val vm = BugReportViewModel()
|
||||||
|
vm.bugReportID.set("12345678ABCDEF-12345678ABCDEF")
|
||||||
|
BugReportView({}, vm)
|
||||||
|
}
|
@ -0,0 +1,41 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.tailscale.ipn.ui.theme.link
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PrimaryActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
contentPadding = PaddingValues(vertical = 12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
content = content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OpenURLButton(title: String, url: String) {
|
||||||
|
val handler = LocalUriHandler.current
|
||||||
|
|
||||||
|
TextButton(onClick = { handler.openUri(url) }) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.link,
|
||||||
|
textDecoration = TextDecoration.Underline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,157 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.theme.listItem
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import com.tailscale.ipn.ui.viewModel.LoginWithAuthKeyViewModel
|
||||||
|
import com.tailscale.ipn.ui.viewModel.LoginWithCustomControlURLViewModel
|
||||||
|
|
||||||
|
data class LoginViewStrings(
|
||||||
|
var title: String,
|
||||||
|
var explanation: String,
|
||||||
|
var inputTitle: String,
|
||||||
|
var placeholder: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginWithCustomControlURLView(
|
||||||
|
onNavigateHome: BackNavigation,
|
||||||
|
backToSettings: BackNavigation,
|
||||||
|
viewModel: LoginWithCustomControlURLViewModel = LoginWithCustomControlURLViewModel()
|
||||||
|
) {
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
Header(
|
||||||
|
R.string.add_account,
|
||||||
|
onBack = backToSettings,
|
||||||
|
)
|
||||||
|
}) { innerPadding ->
|
||||||
|
val error by viewModel.errorDialog.collectAsState()
|
||||||
|
val strings =
|
||||||
|
LoginViewStrings(
|
||||||
|
title = stringResource(id = R.string.custom_control_menu),
|
||||||
|
explanation = stringResource(id = R.string.custom_control_menu_desc),
|
||||||
|
inputTitle = stringResource(id = R.string.custom_control_url_title),
|
||||||
|
placeholder = stringResource(id = R.string.custom_control_placeholder),
|
||||||
|
)
|
||||||
|
|
||||||
|
error?.let { ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) }) }
|
||||||
|
|
||||||
|
LoginView(
|
||||||
|
innerPadding = innerPadding,
|
||||||
|
strings = strings,
|
||||||
|
onSubmitAction = { viewModel.setControlURL(it, onNavigateHome) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginWithAuthKeyView(
|
||||||
|
onNavigateHome: BackNavigation,
|
||||||
|
backToSettings: BackNavigation,
|
||||||
|
viewModel: LoginWithAuthKeyViewModel = LoginWithAuthKeyViewModel()
|
||||||
|
) {
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
Header(
|
||||||
|
R.string.add_account,
|
||||||
|
onBack = backToSettings,
|
||||||
|
)
|
||||||
|
}) { innerPadding ->
|
||||||
|
val error by viewModel.errorDialog.collectAsState()
|
||||||
|
val strings =
|
||||||
|
LoginViewStrings(
|
||||||
|
title = stringResource(id = R.string.auth_key_title),
|
||||||
|
explanation = stringResource(id = R.string.auth_key_explanation),
|
||||||
|
inputTitle = stringResource(id = R.string.auth_key_input_title),
|
||||||
|
placeholder = stringResource(id = R.string.auth_key_placeholder),
|
||||||
|
)
|
||||||
|
// Show the error overlay if need be
|
||||||
|
error?.let { ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) }) }
|
||||||
|
|
||||||
|
LoginView(
|
||||||
|
innerPadding = innerPadding,
|
||||||
|
strings = strings,
|
||||||
|
onSubmitAction = { viewModel.setAuthKey(it, onNavigateHome) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginView(
|
||||||
|
innerPadding: PaddingValues = PaddingValues(16.dp),
|
||||||
|
strings: LoginViewStrings,
|
||||||
|
onSubmitAction: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
|
||||||
|
var textVal by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier.padding(innerPadding)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)) {
|
||||||
|
ListItem(
|
||||||
|
colors = MaterialTheme.colorScheme.listItem,
|
||||||
|
headlineContent = { Text(text = strings.title) },
|
||||||
|
supportingContent = { Text(text = strings.explanation) })
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
colors = MaterialTheme.colorScheme.listItem,
|
||||||
|
headlineContent = { Text(text = strings.inputTitle) },
|
||||||
|
supportingContent = {
|
||||||
|
OutlinedTextField(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedContainerColor = Color.Transparent),
|
||||||
|
textStyle = MaterialTheme.typography.bodyMedium,
|
||||||
|
value = textVal,
|
||||||
|
onValueChange = { textVal = it },
|
||||||
|
placeholder = {
|
||||||
|
Text(strings.placeholder, style = MaterialTheme.typography.bodySmall)
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
colors = MaterialTheme.colorScheme.listItem,
|
||||||
|
headlineContent = {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Button(
|
||||||
|
onClick = { onSubmitAction(textVal) },
|
||||||
|
content = { Text(stringResource(id = R.string.add_account_short)) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,116 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.mdm.MDMSettings
|
||||||
|
import com.tailscale.ipn.ui.model.DnsType
|
||||||
|
import com.tailscale.ipn.ui.notifier.Notifier
|
||||||
|
import com.tailscale.ipn.ui.util.ClipboardValueView
|
||||||
|
import com.tailscale.ipn.ui.util.Lists
|
||||||
|
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||||
|
import com.tailscale.ipn.ui.util.itemsWithDividers
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import com.tailscale.ipn.ui.viewModel.DNSEnablementState
|
||||||
|
import com.tailscale.ipn.ui.viewModel.DNSSettingsViewModel
|
||||||
|
import com.tailscale.ipn.ui.viewModel.DNSSettingsViewModelFactory
|
||||||
|
|
||||||
|
data class ViewableRoute(val name: String, val resolvers: List<DnsType.Resolver>)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun DNSSettingsView(
|
||||||
|
backToSettings: BackNavigation,
|
||||||
|
model: DNSSettingsViewModel = viewModel(factory = DNSSettingsViewModelFactory())
|
||||||
|
) {
|
||||||
|
val state: DNSEnablementState by model.enablementState.collectAsState()
|
||||||
|
val resolvers = model.dnsConfig.collectAsState().value?.Resolvers ?: emptyList()
|
||||||
|
val domains = model.dnsConfig.collectAsState().value?.Domains ?: emptyList()
|
||||||
|
val routes: List<ViewableRoute> =
|
||||||
|
model.dnsConfig.collectAsState().value?.Routes?.mapNotNull { entry ->
|
||||||
|
entry.value?.let { resolvers -> ViewableRoute(name = entry.key, resolvers) } ?: run { null }
|
||||||
|
} ?: emptyList()
|
||||||
|
val useCorpDNS = Notifier.prefs.collectAsState().value?.CorpDNS == true
|
||||||
|
val dnsSettingsMDMDisposition by MDMSettings.useTailscaleDNSSettings.flow.collectAsState()
|
||||||
|
|
||||||
|
Scaffold(topBar = { Header(R.string.dns_settings, onBack = backToSettings) }) { innerPadding ->
|
||||||
|
LoadingIndicator.Wrap {
|
||||||
|
LazyColumn(Modifier.padding(innerPadding)) {
|
||||||
|
item("state") {
|
||||||
|
ListItem(
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(state.symbolDrawable),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = state.tint(),
|
||||||
|
modifier = Modifier.size(36.dp))
|
||||||
|
},
|
||||||
|
headlineContent = {
|
||||||
|
Text(stringResource(state.title), style = MaterialTheme.typography.titleMedium)
|
||||||
|
},
|
||||||
|
supportingContent = { Text(stringResource(state.caption)) })
|
||||||
|
|
||||||
|
if (!dnsSettingsMDMDisposition.value.hiddenFromUser) {
|
||||||
|
Lists.ItemDivider()
|
||||||
|
Setting.Switch(
|
||||||
|
R.string.use_ts_dns,
|
||||||
|
isOn = useCorpDNS,
|
||||||
|
onToggle = {
|
||||||
|
LoadingIndicator.start()
|
||||||
|
model.toggleCorpDNS { LoadingIndicator.stop() }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvers.isNotEmpty()) {
|
||||||
|
item("resolversHeader") { Lists.SectionDivider(stringResource(R.string.resolvers)) }
|
||||||
|
|
||||||
|
itemsWithDividers(resolvers) { resolver -> ClipboardValueView(resolver.Addr.orEmpty()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domains.isNotEmpty()) {
|
||||||
|
item("domainsHeader") { Lists.SectionDivider(stringResource(R.string.search_domains)) }
|
||||||
|
|
||||||
|
itemsWithDividers(domains) { domain -> ClipboardValueView(domain) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routes.isNotEmpty()) {
|
||||||
|
routes.forEach { route ->
|
||||||
|
item { Lists.SectionDivider("Route: ${route.name}") }
|
||||||
|
|
||||||
|
itemsWithDividers(route.resolvers) { resolver ->
|
||||||
|
ClipboardValueView(resolver.Addr.orEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun DNSSettingsViewPreview() {
|
||||||
|
val vm = DNSSettingsViewModel()
|
||||||
|
vm.enablementState.set(DNSEnablementState.ENABLED)
|
||||||
|
DNSSettingsView(backToSettings = {}, vm)
|
||||||
|
}
|
@ -0,0 +1,82 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.theme.AppTheme
|
||||||
|
|
||||||
|
|
||||||
|
enum class ErrorDialogType {
|
||||||
|
INVALID_CUSTOM_URL,
|
||||||
|
LOGOUT_FAILED,
|
||||||
|
SWITCH_USER_FAILED,
|
||||||
|
ADD_PROFILE_FAILED,
|
||||||
|
SHARE_DEVICE_NOT_CONNECTED,
|
||||||
|
SHARE_FAILED,
|
||||||
|
INVALID_AUTH_KEY;
|
||||||
|
|
||||||
|
val message: Int
|
||||||
|
get() {
|
||||||
|
return when (this) {
|
||||||
|
INVALID_CUSTOM_URL -> R.string.invalidCustomUrl
|
||||||
|
LOGOUT_FAILED -> R.string.logout_failed
|
||||||
|
SWITCH_USER_FAILED -> R.string.switch_user_failed
|
||||||
|
ADD_PROFILE_FAILED -> R.string.add_profile_failed
|
||||||
|
SHARE_DEVICE_NOT_CONNECTED -> R.string.share_device_not_connected
|
||||||
|
SHARE_FAILED -> R.string.taildrop_share_failed
|
||||||
|
INVALID_AUTH_KEY -> R.string.invalidAuthKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val title: Int
|
||||||
|
get() {
|
||||||
|
return when (this) {
|
||||||
|
INVALID_CUSTOM_URL -> R.string.invalidCustomURLTitle
|
||||||
|
LOGOUT_FAILED -> R.string.logout_failed_title
|
||||||
|
SWITCH_USER_FAILED -> R.string.switch_user_failed_title
|
||||||
|
ADD_PROFILE_FAILED -> R.string.add_profile_failed_title
|
||||||
|
SHARE_DEVICE_NOT_CONNECTED -> R.string.share_device_not_connected_title
|
||||||
|
SHARE_FAILED -> R.string.taildrop_share_failed_title
|
||||||
|
INVALID_AUTH_KEY -> R.string.invalidAuthKeyTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val buttonText: Int = R.string.ok
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ErrorDialog(type: ErrorDialogType, action: () -> Unit = {}) {
|
||||||
|
ErrorDialog(
|
||||||
|
title = type.title, message = type.message, buttonText = type.buttonText, onDismiss = action)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ErrorDialog(
|
||||||
|
@StringRes title: Int = R.string.error,
|
||||||
|
@StringRes message: Int,
|
||||||
|
@StringRes buttonText: Int = R.string.ok,
|
||||||
|
onDismiss: () -> Unit = {}
|
||||||
|
) {
|
||||||
|
AppTheme {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(text = stringResource(id = title)) },
|
||||||
|
text = { Text(text = stringResource(id = message)) },
|
||||||
|
confirmButton = {
|
||||||
|
PrimaryActionButton(onClick = onDismiss) { Text(text = stringResource(id = buttonText)) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun ErrorDialogPreview() {
|
||||||
|
ErrorDialog(ErrorDialogType.LOGOUT_FAILED)
|
||||||
|
}
|
@ -0,0 +1,225 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Check
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.mdm.MDMSettings
|
||||||
|
import com.tailscale.ipn.mdm.ShowHide
|
||||||
|
import com.tailscale.ipn.ui.notifier.Notifier
|
||||||
|
import com.tailscale.ipn.ui.theme.disabledListItem
|
||||||
|
import com.tailscale.ipn.ui.theme.listItem
|
||||||
|
import com.tailscale.ipn.ui.util.Lists
|
||||||
|
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||||
|
import com.tailscale.ipn.ui.util.itemsWithDividers
|
||||||
|
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
|
||||||
|
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
|
||||||
|
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
|
||||||
|
import com.tailscale.ipn.ui.viewModel.selected
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ExitNodePicker(
|
||||||
|
nav: ExitNodePickerNav,
|
||||||
|
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
|
||||||
|
) {
|
||||||
|
LoadingIndicator.Wrap {
|
||||||
|
Scaffold(topBar = { Header(R.string.choose_exit_node, onBack = nav.onNavigateBackHome) }) {
|
||||||
|
innerPadding ->
|
||||||
|
val tailnetExitNodes by model.tailnetExitNodes.collectAsState()
|
||||||
|
val mullvadExitNodesByCountryCode by model.mullvadExitNodesByCountryCode.collectAsState()
|
||||||
|
val mullvadExitNodeCount by model.mullvadExitNodeCount.collectAsState()
|
||||||
|
val anyActive by model.anyActive.collectAsState()
|
||||||
|
val shouldShowMullvadInfo by model.shouldShowMullvadInfo.collectAsState()
|
||||||
|
val allowLANAccess = Notifier.prefs.collectAsState().value?.ExitNodeAllowLANAccess == true
|
||||||
|
val showRunAsExitNode by MDMSettings.runExitNode.flow.collectAsState()
|
||||||
|
val allowLanAccessMDMDisposition by MDMSettings.exitNodeAllowLANAccess.flow.collectAsState()
|
||||||
|
val managedByOrganization by model.managedByOrganization.collectAsState()
|
||||||
|
val forcedExitNodeId = MDMSettings.exitNodeID.flow.collectAsState().value.value
|
||||||
|
|
||||||
|
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||||
|
item(key = "header") {
|
||||||
|
if (forcedExitNodeId != null) {
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
managedByOrganization.value?.let {
|
||||||
|
stringResource(R.string.exit_node_mdm_orgname, it)
|
||||||
|
} ?: stringResource(R.string.exit_node_mdm),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp))
|
||||||
|
} else {
|
||||||
|
ExitNodeItem(
|
||||||
|
model,
|
||||||
|
ExitNodePickerViewModel.ExitNode(
|
||||||
|
label = stringResource(R.string.none),
|
||||||
|
online = MutableStateFlow(true),
|
||||||
|
selected = !anyActive,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if (showRunAsExitNode.value == ShowHide.Show) {
|
||||||
|
Lists.ItemDivider()
|
||||||
|
RunAsExitNodeItem(nav = nav, viewModel = model, anyActive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "divider1") { Lists.SectionDivider() }
|
||||||
|
|
||||||
|
itemsWithDividers(tailnetExitNodes, key = { it.id!! }) { node -> ExitNodeItem(model, node) }
|
||||||
|
|
||||||
|
if (mullvadExitNodeCount > 0) {
|
||||||
|
item(key = "mullvad") {
|
||||||
|
Lists.SectionDivider()
|
||||||
|
MullvadItem(
|
||||||
|
nav, mullvadExitNodesByCountryCode.size, mullvadExitNodesByCountryCode.selected)
|
||||||
|
}
|
||||||
|
} else if (shouldShowMullvadInfo) {
|
||||||
|
item(key = "mullvad_info") {
|
||||||
|
Lists.SectionDivider()
|
||||||
|
MullvadInfoItem(nav)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowLanAccessMDMDisposition.value.hiddenFromUser) {
|
||||||
|
item(key = "allowLANAccess") {
|
||||||
|
Lists.SectionDivider()
|
||||||
|
|
||||||
|
Setting.Switch(R.string.allow_lan_access, isOn = allowLANAccess) {
|
||||||
|
LoadingIndicator.start()
|
||||||
|
model.toggleAllowLANAccess { LoadingIndicator.stop() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ExitNodeItem(
|
||||||
|
viewModel: ExitNodePickerViewModel,
|
||||||
|
node: ExitNodePickerViewModel.ExitNode,
|
||||||
|
) {
|
||||||
|
val online by node.online.collectAsState()
|
||||||
|
val isRunningExitNode = viewModel.isRunningExitNode.collectAsState().value
|
||||||
|
val forcedExitNodeId = MDMSettings.exitNodeID.flow.collectAsState().value.value
|
||||||
|
|
||||||
|
Box {
|
||||||
|
var modifier: Modifier = Modifier
|
||||||
|
if (online && !isRunningExitNode && forcedExitNodeId == null) {
|
||||||
|
modifier = modifier.clickable { viewModel.setExitNode(node) }
|
||||||
|
}
|
||||||
|
ListItem(
|
||||||
|
modifier = modifier,
|
||||||
|
colors =
|
||||||
|
if (online && !isRunningExitNode) MaterialTheme.colorScheme.listItem
|
||||||
|
else MaterialTheme.colorScheme.disabledListItem,
|
||||||
|
headlineContent = {
|
||||||
|
Text(node.city.ifEmpty { node.label }, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
if (!online)
|
||||||
|
Text(stringResource(R.string.offline), style = MaterialTheme.typography.bodyMedium)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Row {
|
||||||
|
if (node.selected) {
|
||||||
|
Icon(Icons.Outlined.Check, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MullvadItem(nav: ExitNodePickerNav, count: Int, selected: Boolean) {
|
||||||
|
Box {
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { nav.onNavigateToMullvad() },
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.mullvad_exit_nodes),
|
||||||
|
style = MaterialTheme.typography.bodyMedium)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
"$count ${stringResource(R.string.countries)}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
if (selected) {
|
||||||
|
Icon(Icons.Outlined.Check, null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MullvadInfoItem(nav: ExitNodePickerNav) {
|
||||||
|
Box {
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { nav.onNavigateToMullvadInfo() },
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.mullvad_exit_nodes),
|
||||||
|
style = MaterialTheme.typography.bodyMedium)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.enable_in_the_admin_console),
|
||||||
|
style = MaterialTheme.typography.bodyMedium)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RunAsExitNodeItem(
|
||||||
|
nav: ExitNodePickerNav,
|
||||||
|
viewModel: ExitNodePickerViewModel,
|
||||||
|
anyActive: Boolean
|
||||||
|
) {
|
||||||
|
val isRunningExitNode = viewModel.isRunningExitNode.collectAsState().value
|
||||||
|
|
||||||
|
Box {
|
||||||
|
var modifier: Modifier = Modifier
|
||||||
|
if (!anyActive) {
|
||||||
|
modifier = modifier.clickable { nav.onNavigateToRunAsExitNode() }
|
||||||
|
}
|
||||||
|
ListItem(
|
||||||
|
modifier = modifier,
|
||||||
|
colors =
|
||||||
|
if (!anyActive) MaterialTheme.colorScheme.listItem
|
||||||
|
else MaterialTheme.colorScheme.disabledListItem,
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(id = R.string.run_as_exit_node),
|
||||||
|
style = MaterialTheme.typography.bodyMedium)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
if (isRunningExitNode) {
|
||||||
|
Text(stringResource(R.string.enabled))
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.disabled))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,99 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.model.Health
|
||||||
|
import com.tailscale.ipn.ui.theme.success
|
||||||
|
import com.tailscale.ipn.ui.viewModel.HealthViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HealthView(backToSettings: BackNavigation, model: HealthViewModel = viewModel()) {
|
||||||
|
val warnings by model.warnings.collectAsState()
|
||||||
|
|
||||||
|
Scaffold(topBar = { Header(titleRes = R.string.health_warnings, onBack = backToSettings) }) {
|
||||||
|
innerPadding ->
|
||||||
|
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||||
|
if (warnings.isEmpty()) {
|
||||||
|
item("allGood") {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.Top),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.check_circle),
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
contentDescription = "A green checkmark",
|
||||||
|
tint = MaterialTheme.colorScheme.success)
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement =
|
||||||
|
Arrangement.spacedBy(2.dp, alignment = Alignment.CenterVertically),
|
||||||
|
modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.no_issues_found),
|
||||||
|
fontSize = MaterialTheme.typography.titleMedium.fontSize,
|
||||||
|
fontWeight = MaterialTheme.typography.titleMedium.fontWeight)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.tailscale_is_operating_normally),
|
||||||
|
color = MaterialTheme.colorScheme.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(warnings) { HealthWarningView(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HealthWarningView(warning: Health.UnhealthyState) {
|
||||||
|
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainerLow)) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
|
||||||
|
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
|
||||||
|
.fillMaxWidth()) {
|
||||||
|
ListItem(
|
||||||
|
colors = warning.Severity.listItemColors(),
|
||||||
|
headlineContent = {
|
||||||
|
if (warning.Title.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
warning.Title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(warning.Text, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,68 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.theme.AppTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IntroView(onContinue: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxHeight().fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center) {
|
||||||
|
TailscaleLogoView(modifier = Modifier.width(60.dp).height(60.dp))
|
||||||
|
Spacer(modifier = Modifier.height(40.dp))
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.padding(start = 40.dp, end = 40.dp, bottom = 40.dp),
|
||||||
|
text = stringResource(R.string.welcome1),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center)
|
||||||
|
|
||||||
|
Button(onClick = onContinue) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.getStarted),
|
||||||
|
fontSize = MaterialTheme.typography.titleMedium.fontSize)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(40.dp))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxHeight().padding(start = 20.dp, end = 20.dp, bottom = 40.dp),
|
||||||
|
contentAlignment = Alignment.BottomCenter) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.welcome2),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@Preview
|
||||||
|
fun IntroViewPreview() {
|
||||||
|
AppTheme { Surface { IntroView({}) } }
|
||||||
|
}
|
@ -0,0 +1,88 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.theme.AppTheme
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import com.tailscale.ipn.ui.viewModel.LoginQRViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel()) {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.scrim, modifier = Modifier.fillMaxSize()) {
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
val image by model.qrCode.collectAsState()
|
||||||
|
val numCode by model.numCode.collectAsState()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
.padding(20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.scan_to_connect_to_your_tailnet),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.size(200.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.onSurface)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center) {
|
||||||
|
image?.let {
|
||||||
|
Image(
|
||||||
|
bitmap = it,
|
||||||
|
contentDescription = "Scan to login",
|
||||||
|
modifier = Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
numCode?.let { it ->
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.enter_code_to_connect_to_tailnet, it),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
}
|
||||||
|
Button(onClick = onDismiss) { Text(text = stringResource(R.string.dismiss)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@Preview
|
||||||
|
fun LoginQRViewPreview() {
|
||||||
|
val vm = LoginQRViewModel()
|
||||||
|
vm.qrCode.set(vm.generateQRCode("https://tailscale.com", 200, 0))
|
||||||
|
vm.numCode.set("123456789")
|
||||||
|
AppTheme { LoginQRView({}, vm) }
|
||||||
|
}
|
@ -0,0 +1,61 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.mdm.MDMSetting
|
||||||
|
import com.tailscale.ipn.mdm.MDMSettings
|
||||||
|
import com.tailscale.ipn.ui.util.itemsWithDividers
|
||||||
|
import com.tailscale.ipn.ui.viewModel.IpnViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MDMSettingsDebugView(
|
||||||
|
backToSettings: BackNavigation,
|
||||||
|
@Suppress("UNUSED_PARAMETER") model: IpnViewModel = viewModel()
|
||||||
|
) {
|
||||||
|
Scaffold(topBar = { Header(R.string.current_mdm_settings, onBack = backToSettings) }) {
|
||||||
|
innerPadding ->
|
||||||
|
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||||
|
itemsWithDividers(MDMSettings.allSettings.sortedBy { "${it::class.java.name}|${it.key}" }) {
|
||||||
|
setting ->
|
||||||
|
MDMSettingView(setting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MDMSettingView(setting: MDMSetting<*>) {
|
||||||
|
val value by setting.flow.collectAsState()
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(setting.localizedTitle, maxLines = 3) },
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
setting.key,
|
||||||
|
fontSize = MaterialTheme.typography.labelSmall.fontSize,
|
||||||
|
fontFamily = FontFamily.Monospace)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Text(
|
||||||
|
if (value.isSet) value.value.toString() else "[not set]",
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
maxLines = 1,
|
||||||
|
fontWeight = FontWeight.SemiBold)
|
||||||
|
})
|
||||||
|
}
|
@ -0,0 +1,761 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.ArrowDropDown
|
||||||
|
import androidx.compose.material.icons.outlined.Clear
|
||||||
|
import androidx.compose.material.icons.outlined.Close
|
||||||
|
import androidx.compose.material.icons.outlined.Lock
|
||||||
|
import androidx.compose.material.icons.outlined.Search
|
||||||
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
|
import com.tailscale.ipn.App
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.mdm.MDMSettings
|
||||||
|
import com.tailscale.ipn.mdm.ShowHide
|
||||||
|
import com.tailscale.ipn.ui.model.Ipn
|
||||||
|
import com.tailscale.ipn.ui.model.IpnLocal
|
||||||
|
import com.tailscale.ipn.ui.model.Netmap
|
||||||
|
import com.tailscale.ipn.ui.model.Permissions
|
||||||
|
import com.tailscale.ipn.ui.model.Tailcfg
|
||||||
|
import com.tailscale.ipn.ui.theme.customErrorContainer
|
||||||
|
import com.tailscale.ipn.ui.theme.disabled
|
||||||
|
import com.tailscale.ipn.ui.theme.errorButton
|
||||||
|
import com.tailscale.ipn.ui.theme.errorListItem
|
||||||
|
import com.tailscale.ipn.ui.theme.exitNodeToggleButton
|
||||||
|
import com.tailscale.ipn.ui.theme.listItem
|
||||||
|
import com.tailscale.ipn.ui.theme.minTextSize
|
||||||
|
import com.tailscale.ipn.ui.theme.primaryListItem
|
||||||
|
import com.tailscale.ipn.ui.theme.searchBarColors
|
||||||
|
import com.tailscale.ipn.ui.theme.secondaryButton
|
||||||
|
import com.tailscale.ipn.ui.theme.short
|
||||||
|
import com.tailscale.ipn.ui.theme.surfaceContainerListItem
|
||||||
|
import com.tailscale.ipn.ui.theme.warningButton
|
||||||
|
import com.tailscale.ipn.ui.theme.warningListItem
|
||||||
|
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
|
||||||
|
import com.tailscale.ipn.ui.util.AutoResizingText
|
||||||
|
import com.tailscale.ipn.ui.util.Lists
|
||||||
|
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||||
|
import com.tailscale.ipn.ui.util.PeerSet
|
||||||
|
import com.tailscale.ipn.ui.util.itemsWithDividers
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import com.tailscale.ipn.ui.viewModel.IpnViewModel.NodeState
|
||||||
|
import com.tailscale.ipn.ui.viewModel.MainViewModel
|
||||||
|
import com.tailscale.ipn.ui.viewModel.VpnViewModel
|
||||||
|
|
||||||
|
// Navigation actions for the MainView
|
||||||
|
data class MainViewNavigation(
|
||||||
|
val onNavigateToSettings: () -> Unit,
|
||||||
|
val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
|
||||||
|
val onNavigateToExitNodes: () -> Unit,
|
||||||
|
val onNavigateToHealth: () -> Unit
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MainView(
|
||||||
|
loginAtUrl: (String) -> Unit,
|
||||||
|
navigation: MainViewNavigation,
|
||||||
|
viewModel: MainViewModel
|
||||||
|
) {
|
||||||
|
val currentPingDevice by viewModel.pingViewModel.peer.collectAsState()
|
||||||
|
val healthIcon by viewModel.healthIcon.collectAsState()
|
||||||
|
|
||||||
|
LoadingIndicator.Wrap {
|
||||||
|
Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(paddingInsets),
|
||||||
|
verticalArrangement = Arrangement.Center) {
|
||||||
|
// Assume VPN has been prepared for optimistic UI. Whether or not it has been prepared
|
||||||
|
// cannot be known
|
||||||
|
// until permission has been granted to prepare the VPN.
|
||||||
|
val isPrepared by viewModel.isVpnPrepared.collectAsState(initial = true)
|
||||||
|
val isOn by viewModel.vpnToggleState.collectAsState(initial = false)
|
||||||
|
val state by viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
|
||||||
|
val user by viewModel.loggedInUser.collectAsState(initial = null)
|
||||||
|
val stateVal by viewModel.stateRes.collectAsState(initial = R.string.placeholder)
|
||||||
|
val stateStr = stringResource(id = stateVal)
|
||||||
|
val netmap by viewModel.netmap.collectAsState(initial = null)
|
||||||
|
val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState()
|
||||||
|
val disableToggle by MDMSettings.forceEnabled.flow.collectAsState()
|
||||||
|
val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false)
|
||||||
|
|
||||||
|
// Hide the header only on Android TV when the user needs to login
|
||||||
|
val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin)
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
colors = MaterialTheme.colorScheme.surfaceContainerListItem,
|
||||||
|
leadingContent = {
|
||||||
|
if (!hideHeader) {
|
||||||
|
TintedSwitch(
|
||||||
|
onCheckedChange = {
|
||||||
|
if (!disableToggle.value) {
|
||||||
|
viewModel.toggleVpn()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !disableToggle.value,
|
||||||
|
checked = isOn)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
headlineContent = {
|
||||||
|
user?.NetworkProfile?.DomainName?.let { domain ->
|
||||||
|
AutoResizingText(
|
||||||
|
text = domain,
|
||||||
|
style = MaterialTheme.typography.titleMedium.short,
|
||||||
|
minFontSize = MaterialTheme.typography.minTextSize,
|
||||||
|
overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
if (!hideHeader) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(text = stateStr, style = MaterialTheme.typography.bodyMedium.short)
|
||||||
|
healthIcon?.let {
|
||||||
|
Spacer(modifier = Modifier.size(4.dp))
|
||||||
|
IconButton(
|
||||||
|
onClick = { navigation.onNavigateToHealth() },
|
||||||
|
modifier = Modifier.size(16.dp)) {
|
||||||
|
Icon(
|
||||||
|
painterResource(id = it),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.weight(1f)
|
||||||
|
.focusable()
|
||||||
|
.clickable { navigation.onNavigateToSettings() }
|
||||||
|
.padding(8.dp),
|
||||||
|
contentAlignment = Alignment.CenterEnd) {
|
||||||
|
when (user) {
|
||||||
|
null -> SettingsButton { navigation.onNavigateToSettings() }
|
||||||
|
else ->
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier =
|
||||||
|
Modifier.size(42.dp).clip(CircleShape).focusable().clickable {
|
||||||
|
navigation.onNavigateToSettings()
|
||||||
|
}) {
|
||||||
|
Avatar(profile = user, size = 36) {
|
||||||
|
navigation.onNavigateToSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
when (state) {
|
||||||
|
Ipn.State.Running -> {
|
||||||
|
|
||||||
|
PromptPermissionsIfNecessary()
|
||||||
|
|
||||||
|
viewModel.showVPNPermissionLauncherIfUnauthorized()
|
||||||
|
|
||||||
|
if (showKeyExpiry) {
|
||||||
|
ExpiryNotification(netmap = netmap, action = { viewModel.login() })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showExitNodePicker.value == ShowHide.Show) {
|
||||||
|
ExitNodeStatus(
|
||||||
|
navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
PeerList(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
|
||||||
|
onSearch = { viewModel.searchPeers(it) })
|
||||||
|
}
|
||||||
|
Ipn.State.NoState,
|
||||||
|
Ipn.State.Starting -> StartingView()
|
||||||
|
else -> {
|
||||||
|
ConnectView(
|
||||||
|
state,
|
||||||
|
isPrepared,
|
||||||
|
// If Tailscale is stopping, don't automatically restart; wait for user to take
|
||||||
|
// action (eg, if the user connected to another VPN).
|
||||||
|
state != Ipn.State.Stopping,
|
||||||
|
user,
|
||||||
|
{ viewModel.toggleVpn() },
|
||||||
|
{ viewModel.login() },
|
||||||
|
loginAtUrl,
|
||||||
|
netmap?.SelfNode,
|
||||||
|
{ viewModel.showVPNPermissionLauncherIfUnauthorized() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPingDevice?.let { _ ->
|
||||||
|
ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) {
|
||||||
|
PingView(model = viewModel.pingViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
|
||||||
|
val nodeState by viewModel.nodeState.collectAsState()
|
||||||
|
val maybePrefs by viewModel.prefs.collectAsState()
|
||||||
|
val netmap by viewModel.netmap.collectAsState()
|
||||||
|
|
||||||
|
// There's nothing to render if we haven't loaded the prefs yet
|
||||||
|
val prefs = maybePrefs ?: return
|
||||||
|
|
||||||
|
// The activeExitNode is the source of truth. The selectedExitNode is only relevant if we
|
||||||
|
// don't have an active node.
|
||||||
|
val chosenExitNodeId = prefs.activeExitNodeID ?: prefs.selectedExitNodeID
|
||||||
|
|
||||||
|
val exitNodePeer = chosenExitNodeId?.let { id -> netmap?.Peers?.find { it.StableID == id } }
|
||||||
|
val name = exitNodePeer?.exitNodeName
|
||||||
|
|
||||||
|
val managedByOrganization by viewModel.managedByOrganization.collectAsState()
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surfaceContainer)) {
|
||||||
|
if (nodeState == NodeState.OFFLINE_MDM) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.padding(start = 16.dp, end = 16.dp, top = 56.dp, bottom = 16.dp)
|
||||||
|
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.customErrorContainer)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.TopCenter)) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier.padding(start = 16.dp, end = 16.dp, top = 36.dp, bottom = 16.dp)) {
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
managedByOrganization.value?.let {
|
||||||
|
stringResource(R.string.exit_node_offline_mdm_orgname, it)
|
||||||
|
} ?: stringResource(R.string.exit_node_offline_mdm),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp)
|
||||||
|
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
|
||||||
|
.fillMaxWidth()) {
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { navAction() },
|
||||||
|
colors =
|
||||||
|
when (nodeState) {
|
||||||
|
NodeState.ACTIVE_AND_RUNNING -> MaterialTheme.colorScheme.primaryListItem
|
||||||
|
NodeState.ACTIVE_NOT_RUNNING -> MaterialTheme.colorScheme.listItem
|
||||||
|
NodeState.RUNNING_AS_EXIT_NODE -> MaterialTheme.colorScheme.warningListItem
|
||||||
|
NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorListItem
|
||||||
|
NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorListItem
|
||||||
|
NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorListItem
|
||||||
|
else ->
|
||||||
|
ListItemDefaults.colors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface)
|
||||||
|
},
|
||||||
|
overlineContent = {
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
if (nodeState == NodeState.OFFLINE_ENABLED ||
|
||||||
|
nodeState == NodeState.OFFLINE_DISABLED ||
|
||||||
|
nodeState == NodeState.OFFLINE_MDM)
|
||||||
|
stringResource(R.string.exit_node_offline)
|
||||||
|
else stringResource(R.string.exit_node),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
headlineContent = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
when (nodeState) {
|
||||||
|
NodeState.NONE -> stringResource(id = R.string.none)
|
||||||
|
NodeState.RUNNING_AS_EXIT_NODE ->
|
||||||
|
stringResource(id = R.string.running_exit_node)
|
||||||
|
else -> name ?: ""
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis)
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.ArrowDropDown,
|
||||||
|
contentDescription = null,
|
||||||
|
tint =
|
||||||
|
if (nodeState == NodeState.NONE)
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
if (nodeState != NodeState.NONE) {
|
||||||
|
Button(
|
||||||
|
colors =
|
||||||
|
when (nodeState) {
|
||||||
|
NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorButton
|
||||||
|
NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorButton
|
||||||
|
NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorButton
|
||||||
|
NodeState.RUNNING_AS_EXIT_NODE ->
|
||||||
|
MaterialTheme.colorScheme.warningButton
|
||||||
|
NodeState.ACTIVE_NOT_RUNNING ->
|
||||||
|
MaterialTheme.colorScheme.exitNodeToggleButton
|
||||||
|
else -> MaterialTheme.colorScheme.secondaryButton
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
if (nodeState == NodeState.RUNNING_AS_EXIT_NODE)
|
||||||
|
viewModel.setRunningExitNode(false)
|
||||||
|
else viewModel.toggleExitNode()
|
||||||
|
}) {
|
||||||
|
Text(
|
||||||
|
when (nodeState) {
|
||||||
|
NodeState.OFFLINE_DISABLED -> stringResource(id = R.string.enable)
|
||||||
|
NodeState.ACTIVE_NOT_RUNNING ->
|
||||||
|
stringResource(id = R.string.enable)
|
||||||
|
NodeState.RUNNING_AS_EXIT_NODE ->
|
||||||
|
stringResource(id = R.string.stop)
|
||||||
|
else -> stringResource(id = R.string.disable)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsButton(action: () -> Unit) {
|
||||||
|
IconButton(modifier = Modifier.size(24.dp), onClick = { action() }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.Settings,
|
||||||
|
contentDescription = "Open settings",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StartingView() {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
TailscaleLogoView(
|
||||||
|
animated = true, usesOnBackgroundColors = false, Modifier.size(40.dp).alpha(0.3f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ConnectView(
|
||||||
|
state: Ipn.State,
|
||||||
|
isPrepared: Boolean,
|
||||||
|
shouldStartAutomatically: Boolean,
|
||||||
|
user: IpnLocal.LoginProfile?,
|
||||||
|
connectAction: () -> Unit,
|
||||||
|
loginAction: () -> Unit,
|
||||||
|
loginAtUrlAction: (String) -> Unit,
|
||||||
|
selfNode: Tailcfg.Node?,
|
||||||
|
showVPNPermissionLauncherIfUnauthorized: () -> Unit
|
||||||
|
) {
|
||||||
|
LaunchedEffect(isPrepared) {
|
||||||
|
if (!isPrepared && shouldStartAutomatically) {
|
||||||
|
showVPNPermissionLauncherIfUnauthorized()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(8.dp).fillMaxWidth(0.7f).fillMaxHeight(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
if (!isPrepared) {
|
||||||
|
TailscaleLogoView(modifier = Modifier.size(50.dp))
|
||||||
|
Spacer(modifier = Modifier.size(1.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.welcome_to_tailscale),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
textAlign = TextAlign.Center)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.give_permissions),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
textAlign = TextAlign.Center)
|
||||||
|
Spacer(modifier = Modifier.size(1.dp))
|
||||||
|
PrimaryActionButton(onClick = connectAction) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.connect),
|
||||||
|
fontSize = MaterialTheme.typography.titleMedium.fontSize)
|
||||||
|
}
|
||||||
|
} else if (state == Ipn.State.NeedsMachineAuth) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
imageVector = Icons.Outlined.Lock,
|
||||||
|
contentDescription = "Device requires authentication")
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.machine_auth_required),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
textAlign = TextAlign.Center)
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.machine_auth_explainer),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center)
|
||||||
|
Spacer(modifier = Modifier.size(1.dp))
|
||||||
|
selfNode?.let {
|
||||||
|
PrimaryActionButton(onClick = { loginAtUrlAction(it.nodeAdminUrl) }) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.open_admin_console),
|
||||||
|
fontSize = MaterialTheme.typography.titleMedium.fontSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (state != Ipn.State.NeedsLogin && user != null && !user.isEmpty()) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.power),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.disabled)
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.not_connected),
|
||||||
|
fontSize = MaterialTheme.typography.titleMedium.fontSize,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontFamily = MaterialTheme.typography.titleMedium.fontFamily)
|
||||||
|
val tailnetName = user.NetworkProfile?.DomainName ?: ""
|
||||||
|
Text(
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(stringResource(id = R.string.connect_to_tailnet_prefix))
|
||||||
|
pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||||
|
append(tailnetName)
|
||||||
|
pop()
|
||||||
|
append(stringResource(id = R.string.connect_to_tailnet_suffix))
|
||||||
|
},
|
||||||
|
fontSize = MaterialTheme.typography.titleMedium.fontSize,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(1.dp))
|
||||||
|
PrimaryActionButton(onClick = connectAction) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.connect),
|
||||||
|
fontSize = MaterialTheme.typography.titleMedium.fontSize)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TailscaleLogoView(modifier = Modifier.size(50.dp))
|
||||||
|
Spacer(modifier = Modifier.size(1.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.welcome_to_tailscale),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
textAlign = TextAlign.Center)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.login_to_join_your_tailnet),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
textAlign = TextAlign.Center)
|
||||||
|
Spacer(modifier = Modifier.size(1.dp))
|
||||||
|
PrimaryActionButton(onClick = loginAction) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.log_in),
|
||||||
|
fontSize = MaterialTheme.typography.titleMedium.fontSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun PeerList(
|
||||||
|
viewModel: MainViewModel,
|
||||||
|
onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
|
||||||
|
onSearch: (String) -> Unit
|
||||||
|
) {
|
||||||
|
val peerList by viewModel.peers.collectAsState(initial = emptyList<PeerSet>())
|
||||||
|
val searchTermStr by viewModel.searchTerm.collectAsState(initial = "")
|
||||||
|
val showNoResults =
|
||||||
|
remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value
|
||||||
|
|
||||||
|
val netmap = viewModel.netmap.collectAsState()
|
||||||
|
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
var isFocussed by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
var isListFocussed by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val expandedPeer = viewModel.expandedMenuPeer.collectAsState()
|
||||||
|
val localClipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
|
val enableSearch = !isAndroidTV()
|
||||||
|
|
||||||
|
if (enableSearch) {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surface)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 0.dp)
|
||||||
|
.onFocusChanged { isFocussed = it.isFocused },
|
||||||
|
singleLine = true,
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
colors = MaterialTheme.colorScheme.searchBarColors,
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(imageVector = Icons.Outlined.Search, contentDescription = "search")
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
if (isFocussed) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
onSearch("")
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
imageVector =
|
||||||
|
if (searchTermStr.isEmpty()) Icons.Outlined.Close
|
||||||
|
else Icons.Outlined.Clear,
|
||||||
|
contentDescription = "clear search",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.search),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
maxLines = 1)
|
||||||
|
},
|
||||||
|
value = searchTermStr,
|
||||||
|
onValueChange = { onSearch(it) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxSize()
|
||||||
|
.onFocusChanged { isListFocussed = it.isFocused }
|
||||||
|
.background(color = MaterialTheme.colorScheme.surface)) {
|
||||||
|
if (showNoResults) {
|
||||||
|
item {
|
||||||
|
Spacer(
|
||||||
|
Modifier.height(16.dp)
|
||||||
|
.fillMaxSize()
|
||||||
|
.focusable(false)
|
||||||
|
.background(color = MaterialTheme.colorScheme.surface))
|
||||||
|
|
||||||
|
Lists.LargeTitle(
|
||||||
|
stringResource(id = R.string.no_results),
|
||||||
|
bottomPadding = 8.dp,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Light)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var first = true
|
||||||
|
peerList.forEach { peerSet ->
|
||||||
|
if (!first) {
|
||||||
|
item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() }
|
||||||
|
}
|
||||||
|
first = false
|
||||||
|
|
||||||
|
// Sticky headers are a bit broken on Android TV - they hide their content
|
||||||
|
if (isAndroidTV()) {
|
||||||
|
item { NodesSectionHeader(peerSet = peerSet) }
|
||||||
|
} else {
|
||||||
|
stickyHeader { NodesSectionHeader(peerSet = peerSet) }
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer ->
|
||||||
|
ListItem(
|
||||||
|
modifier =
|
||||||
|
Modifier.combinedClickable(
|
||||||
|
onClick = { onNavigateToPeerDetails(peer) },
|
||||||
|
onLongClick = { viewModel.expandedMenuPeer.set(peer) }),
|
||||||
|
colors = MaterialTheme.colorScheme.listItem,
|
||||||
|
headlineContent = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.padding(top = 2.dp)
|
||||||
|
.size(10.dp)
|
||||||
|
.background(
|
||||||
|
color = peer.connectedColor(netmap.value),
|
||||||
|
shape = RoundedCornerShape(percent = 50))) {}
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
Text(text = peer.displayName, style = MaterialTheme.typography.titleMedium)
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = expandedPeer.value?.StableID == peer.StableID,
|
||||||
|
onDismissRequest = { viewModel.hidePeerDropdownMenu() }) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.clipboard),
|
||||||
|
contentDescription = null)
|
||||||
|
},
|
||||||
|
text = { Text(text = stringResource(R.string.copy_ip_address)) },
|
||||||
|
onClick = {
|
||||||
|
viewModel.copyIpAddress(peer, localClipboardManager)
|
||||||
|
viewModel.hidePeerDropdownMenu()
|
||||||
|
})
|
||||||
|
|
||||||
|
netmap.value?.let { netMap ->
|
||||||
|
if (!peer.isSelfNode(netMap)) {
|
||||||
|
// Don't show the ping item for the self-node
|
||||||
|
DropdownMenuItem(
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.timer),
|
||||||
|
contentDescription = null)
|
||||||
|
},
|
||||||
|
text = { Text(text = stringResource(R.string.ping)) },
|
||||||
|
onClick = {
|
||||||
|
viewModel.hidePeerDropdownMenu()
|
||||||
|
viewModel.startPing(peer)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
text = peer.Addresses?.first()?.split("/")?.first() ?: "",
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
lineHeight = MaterialTheme.typography.titleMedium.lineHeight))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NodesSectionHeader(peerSet: PeerSet) {
|
||||||
|
Spacer(Modifier.height(16.dp).fillMaxSize().background(color = MaterialTheme.colorScheme.surface))
|
||||||
|
|
||||||
|
Lists.LargeTitle(
|
||||||
|
peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user),
|
||||||
|
bottomPadding = 8.dp,
|
||||||
|
focusable = isAndroidTV(),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) {
|
||||||
|
if (netmap == null) return
|
||||||
|
|
||||||
|
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
|
||||||
|
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
|
||||||
|
.fillMaxWidth()) {
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { action() },
|
||||||
|
colors = MaterialTheme.colorScheme.warningListItem,
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
netmap.SelfNode.expiryLabel(),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(id = R.string.keyExpiryExplainer),
|
||||||
|
style = MaterialTheme.typography.bodyMedium)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
|
@Composable
|
||||||
|
fun PromptPermissionsIfNecessary() {
|
||||||
|
Permissions.prompt.forEach { (permission, state) ->
|
||||||
|
ErrorDialog(
|
||||||
|
title = permission.title,
|
||||||
|
message = permission.description,
|
||||||
|
buttonText = R.string._continue) {
|
||||||
|
state.launchPermissionRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun MainViewPreview() {
|
||||||
|
val vpnViewModel = VpnViewModel(App.get())
|
||||||
|
val vm = MainViewModel(vpnViewModel)
|
||||||
|
|
||||||
|
MainView(
|
||||||
|
{},
|
||||||
|
MainViewNavigation(
|
||||||
|
onNavigateToSettings = {},
|
||||||
|
onNavigateToPeerDetails = {},
|
||||||
|
onNavigateToExitNodes = {},
|
||||||
|
onNavigateToHealth = {}),
|
||||||
|
vm)
|
||||||
|
}
|
@ -0,0 +1,58 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.safeContentPadding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.mdm.MDMSettings
|
||||||
|
import com.tailscale.ipn.ui.viewModel.IpnViewModel
|
||||||
|
|
||||||
|
@Suppress("UNUSED_PARAMETER")
|
||||||
|
@Composable
|
||||||
|
fun ManagedByView(backToSettings: BackNavigation, model: IpnViewModel = viewModel()) {
|
||||||
|
Scaffold(topBar = { Header(R.string.managed_by, onBack = backToSettings) }) { _ ->
|
||||||
|
Column(
|
||||||
|
verticalArrangement =
|
||||||
|
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
|
||||||
|
horizontalAlignment = Alignment.Start,
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth().safeContentPadding().verticalScroll(rememberScrollState())) {
|
||||||
|
val managedByOrganization =
|
||||||
|
MDMSettings.managedByOrganizationName.flow.collectAsState().value.value
|
||||||
|
val managedByCaption = MDMSettings.managedByCaption.flow.collectAsState().value.value
|
||||||
|
val managedByURL = MDMSettings.managedByURL.flow.collectAsState().value.value
|
||||||
|
managedByOrganization?.let {
|
||||||
|
Text(stringResource(R.string.managed_by_explainer_orgName, it))
|
||||||
|
} ?: run { Text(stringResource(R.string.managed_by_explainer)) }
|
||||||
|
managedByCaption?.let {
|
||||||
|
if (it.isNotEmpty()) {
|
||||||
|
Text(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
managedByURL?.let { OpenURLButton(stringResource(R.string.open_support), it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun ManagedByViewPreview() {
|
||||||
|
val vm = IpnViewModel()
|
||||||
|
ManagedByView(backToSettings = {}, vm)
|
||||||
|
}
|
@ -0,0 +1,67 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.util.Lists
|
||||||
|
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||||
|
import com.tailscale.ipn.ui.util.flag
|
||||||
|
import com.tailscale.ipn.ui.util.itemsWithDividers
|
||||||
|
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
|
||||||
|
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
|
||||||
|
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MullvadExitNodePicker(
|
||||||
|
countryCode: String,
|
||||||
|
nav: ExitNodePickerNav,
|
||||||
|
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
|
||||||
|
) {
|
||||||
|
val mullvadExitNodes by model.mullvadExitNodesByCountryCode.collectAsState()
|
||||||
|
val bestAvailableByCountry by model.mullvadBestAvailableByCountry.collectAsState()
|
||||||
|
|
||||||
|
mullvadExitNodes[countryCode]?.toList()?.let { nodes ->
|
||||||
|
val any = nodes.first()
|
||||||
|
|
||||||
|
LoadingIndicator.Wrap {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
Header(
|
||||||
|
title = { Text("${countryCode.flag()} ${any.country}") },
|
||||||
|
onBack = nav.onNavigateBackToMullvad)
|
||||||
|
}) { innerPadding ->
|
||||||
|
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||||
|
if (nodes.size > 1) {
|
||||||
|
val bestAvailableNode = bestAvailableByCountry[countryCode]!!
|
||||||
|
item {
|
||||||
|
ExitNodeItem(
|
||||||
|
model,
|
||||||
|
ExitNodePickerViewModel.ExitNode(
|
||||||
|
id = bestAvailableNode.id,
|
||||||
|
label = stringResource(R.string.best_available),
|
||||||
|
online = bestAvailableNode.online,
|
||||||
|
selected = false,
|
||||||
|
))
|
||||||
|
Lists.SectionDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsWithDividers(nodes) { node -> ExitNodeItem(model, node) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,99 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Check
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||||
|
import com.tailscale.ipn.ui.util.flag
|
||||||
|
import com.tailscale.ipn.ui.util.itemsWithDividers
|
||||||
|
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
|
||||||
|
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
|
||||||
|
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
|
||||||
|
import com.tailscale.ipn.ui.viewModel.selected
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MullvadExitNodePickerList(
|
||||||
|
nav: ExitNodePickerNav,
|
||||||
|
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
|
||||||
|
) {
|
||||||
|
LoadingIndicator.Wrap {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
Header(R.string.choose_mullvad_exit_node, onBack = nav.onNavigateBackToExitNodes)
|
||||||
|
}) { innerPadding ->
|
||||||
|
val mullvadExitNodes by model.mullvadExitNodesByCountryCode.collectAsState()
|
||||||
|
|
||||||
|
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||||
|
val sortedCountries =
|
||||||
|
mullvadExitNodes.entries.toList().sortedBy {
|
||||||
|
it.value.first().country.lowercase()
|
||||||
|
}
|
||||||
|
itemsWithDividers(sortedCountries) { (countryCode, nodes) ->
|
||||||
|
val first = nodes.first()
|
||||||
|
|
||||||
|
// TODO(oxtoacart): the modifier on the ListItem occasionally causes a crash
|
||||||
|
// with java.lang.ClassCastException: androidx.compose.ui.ComposedModifier cannot be
|
||||||
|
// cast
|
||||||
|
// to androidx.compose.runtime.RecomposeScopeImpl
|
||||||
|
// Wrapping it in a Box eliminates this. It appears to be some kind of
|
||||||
|
// interaction between the LazyList and the modifier.
|
||||||
|
Box {
|
||||||
|
ListItem(
|
||||||
|
modifier =
|
||||||
|
Modifier.clickable {
|
||||||
|
if (nodes.size > 1) {
|
||||||
|
nav.onNavigateToMullvadCountry(countryCode)
|
||||||
|
} else {
|
||||||
|
model.setExitNode(first)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Text(
|
||||||
|
countryCode.flag(),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
headlineContent = {
|
||||||
|
Text(first.country, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
if (nodes.size == 1) first.city
|
||||||
|
else "${nodes.size} ${stringResource(R.string.cities_available)}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
if (nodes.size > 1 && nodes.selected || first.selected) {
|
||||||
|
if (nodes.selected) {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.Check,
|
||||||
|
contentDescription = stringResource(R.string.selected))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,56 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MullvadInfoView(nav: ExitNodePickerNav) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
Header(R.string.choose_mullvad_exit_node, onBack = nav.onNavigateBackToExitNodes)
|
||||||
|
}) { innerPadding ->
|
||||||
|
LazyColumn(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 48.dp),
|
||||||
|
modifier = Modifier.padding(innerPadding)) {
|
||||||
|
item {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.mullvad_logo),
|
||||||
|
contentDescription = stringResource(R.string.the_mullvad_vpn_logo))
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.mullvad_info_title),
|
||||||
|
fontFamily = MaterialTheme.typography.titleLarge.fontFamily,
|
||||||
|
fontSize = MaterialTheme.typography.titleLarge.fontSize,
|
||||||
|
fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.mullvad_info_explainer),
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,154 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.theme.listItem
|
||||||
|
import com.tailscale.ipn.ui.theme.short
|
||||||
|
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
|
||||||
|
import com.tailscale.ipn.ui.util.Lists
|
||||||
|
import com.tailscale.ipn.ui.util.itemsWithDividers
|
||||||
|
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
|
||||||
|
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModelFactory
|
||||||
|
import com.tailscale.ipn.ui.viewModel.PingViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun PeerDetails(
|
||||||
|
backToHome: BackNavigation,
|
||||||
|
nodeId: String,
|
||||||
|
pingViewModel: PingViewModel,
|
||||||
|
model: PeerDetailsViewModel =
|
||||||
|
viewModel(
|
||||||
|
factory =
|
||||||
|
PeerDetailsViewModelFactory(nodeId, LocalContext.current.filesDir, pingViewModel))
|
||||||
|
) {
|
||||||
|
val isPinging by model.isPinging.collectAsState()
|
||||||
|
|
||||||
|
model.netmap.collectAsState().value?.let { netmap ->
|
||||||
|
model.node.collectAsState().value?.let { node ->
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
Header(
|
||||||
|
title = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = node.displayName,
|
||||||
|
style = MaterialTheme.typography.titleMedium.short,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.size(8.dp)
|
||||||
|
.background(
|
||||||
|
color = node.connectedColor(netmap),
|
||||||
|
shape = RoundedCornerShape(percent = 50))) {}
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = node.connectedStrRes(netmap)),
|
||||||
|
style = MaterialTheme.typography.bodyMedium.short,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { model.startPing() }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.timer),
|
||||||
|
contentDescription = "Ping device")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onBack = backToHome)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.padding(innerPadding),
|
||||||
|
) {
|
||||||
|
item(key = "tailscaleAddresses") {
|
||||||
|
Lists.MutedHeader(stringResource(R.string.tailscale_addresses))
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsWithDividers(node.displayAddresses, key = { it.address }) {
|
||||||
|
AddressRow(address = it.address, type = it.typeString)
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "infoDivider") { Lists.SectionDivider() }
|
||||||
|
|
||||||
|
itemsWithDividers(node.info, key = { "info_${it.titleRes}" }) {
|
||||||
|
ValueRow(title = stringResource(id = it.titleRes), value = it.value.getString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isPinging) {
|
||||||
|
ModalBottomSheet(onDismissRequest = { model.onPingDismissal() }) {
|
||||||
|
PingView(model = model.pingViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AddressRow(address: String, type: String) {
|
||||||
|
val localClipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
|
// Android TV doesn't have a clipboard, nor any way to use the values, so visible only.
|
||||||
|
val modifier =
|
||||||
|
if (isAndroidTV()) {
|
||||||
|
Modifier.focusable(false)
|
||||||
|
} else {
|
||||||
|
Modifier.clickable { localClipboardManager.setText(AnnotatedString(address)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
modifier = modifier,
|
||||||
|
colors = MaterialTheme.colorScheme.listItem,
|
||||||
|
headlineContent = { Text(text = address) },
|
||||||
|
supportingContent = { Text(text = type) },
|
||||||
|
trailingContent = {
|
||||||
|
// TODO: there is some overlap with other uses of clipboard, DRY
|
||||||
|
if (!isAndroidTV()) {
|
||||||
|
Icon(painter = painterResource(id = R.drawable.clipboard), null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ValueRow(title: String, value: String) {
|
||||||
|
ListItem(
|
||||||
|
colors = MaterialTheme.colorScheme.listItem,
|
||||||
|
headlineContent = { Text(text = title) },
|
||||||
|
supportingContent = { Text(text = value) })
|
||||||
|
}
|
@ -0,0 +1,66 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.tailscale.ipn.ui.model.Ipn
|
||||||
|
import com.tailscale.ipn.ui.model.Tailcfg
|
||||||
|
import com.tailscale.ipn.ui.theme.off
|
||||||
|
import com.tailscale.ipn.ui.theme.on
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PeerView(
|
||||||
|
peer: Tailcfg.Node,
|
||||||
|
selfPeer: String? = null,
|
||||||
|
stateVal: Ipn.State? = null,
|
||||||
|
subtitle: () -> String = { peer.primaryIPv4Address ?: peer.primaryIPv6Address ?: "" },
|
||||||
|
onClick: (Tailcfg.Node) -> Unit = {},
|
||||||
|
trailingContent: @Composable () -> Unit = {}
|
||||||
|
) {
|
||||||
|
val disabled = !(peer.Online ?: false)
|
||||||
|
val textColor = if (disabled) MaterialTheme.colorScheme.onSurfaceVariant else Color.Unspecified
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { onClick(peer) },
|
||||||
|
headlineContent = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
// By definition, SelfPeer is online since we will not show the peer list
|
||||||
|
// unless you're connected.
|
||||||
|
val isSelfAndRunning = (peer.StableID == selfPeer && stateVal == Ipn.State.Running)
|
||||||
|
val color: Color =
|
||||||
|
if ((peer.Online == true) || isSelfAndRunning) {
|
||||||
|
MaterialTheme.colorScheme.on
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.off
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.size(8.dp)
|
||||||
|
.background(color = color, shape = RoundedCornerShape(percent = 50))) {}
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
Text(
|
||||||
|
text = peer.displayName,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = textColor)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(text = subtitle(), style = MaterialTheme.typography.bodyMedium, color = textColor)
|
||||||
|
},
|
||||||
|
trailingContent = trailingContent)
|
||||||
|
}
|
@ -0,0 +1,55 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.model.Permissions
|
||||||
|
import com.tailscale.ipn.ui.theme.success
|
||||||
|
import com.tailscale.ipn.ui.util.itemsWithDividers
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
|
@Composable
|
||||||
|
fun PermissionsView(backToSettings: BackNavigation, openApplicationSettings: () -> Unit) {
|
||||||
|
val permissions = Permissions.withGrantedStatus
|
||||||
|
Scaffold(topBar = { Header(titleRes = R.string.permissions, onBack = backToSettings) }) {
|
||||||
|
innerPadding ->
|
||||||
|
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||||
|
itemsWithDividers(permissions) { (permission, granted) ->
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { openApplicationSettings() },
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
if (granted) painterResource(R.drawable.check_circle)
|
||||||
|
else painterResource(R.drawable.xmark_circle),
|
||||||
|
tint =
|
||||||
|
if (granted) MaterialTheme.colorScheme.success
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
contentDescription =
|
||||||
|
stringResource(if (granted) R.string.ok else R.string.warning))
|
||||||
|
},
|
||||||
|
headlineContent = {
|
||||||
|
Text(stringResource(permission.title), style = MaterialTheme.typography.titleMedium)
|
||||||
|
},
|
||||||
|
supportingContent = { Text(stringResource(permission.description)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,119 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||||
|
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
|
||||||
|
import com.tailscale.ipn.ui.viewModel.IpnViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RunExitNodeView(nav: ExitNodePickerNav, model: IpnViewModel = viewModel()) {
|
||||||
|
val isRunningExitNode by model.isRunningExitNode.collectAsState()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = { Header(R.string.run_as_exit_node, onBack = nav.onNavigateBackToExitNodes) }) {
|
||||||
|
innerPadding ->
|
||||||
|
LoadingIndicator.Wrap {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement =
|
||||||
|
Arrangement.spacedBy(24.dp, alignment = Alignment.CenterVertically),
|
||||||
|
modifier =
|
||||||
|
Modifier.padding(innerPadding)
|
||||||
|
.padding(24.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.verticalScroll(rememberScrollState())) {
|
||||||
|
RunExitNodeGraphic()
|
||||||
|
|
||||||
|
if (isRunningExitNode) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.running_as_exit_node),
|
||||||
|
fontFamily = MaterialTheme.typography.titleLarge.fontFamily,
|
||||||
|
fontSize = MaterialTheme.typography.titleLarge.fontSize,
|
||||||
|
fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(stringResource(R.string.run_exit_node_explainer_running))
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.run_this_device_as_an_exit_node),
|
||||||
|
fontFamily = MaterialTheme.typography.titleLarge.fontFamily,
|
||||||
|
fontSize = MaterialTheme.typography.titleLarge.fontSize,
|
||||||
|
fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(stringResource(R.string.run_exit_node_explainer))
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.run_exit_node_caution))
|
||||||
|
|
||||||
|
Button(onClick = { model.setRunningExitNode(!isRunningExitNode) }) {
|
||||||
|
if (isRunningExitNode) {
|
||||||
|
Text(stringResource(R.string.stop_running_as_exit_node))
|
||||||
|
} else {
|
||||||
|
Text(stringResource(R.string.start_running_as_exit_node))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.size(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RunExitNodeGraphic() {
|
||||||
|
@Composable
|
||||||
|
fun ArrowForward() {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Outlined.ArrowForward,
|
||||||
|
"Arrow Forward",
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(vertical = 18.dp)) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.computer),
|
||||||
|
"Computer icon",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.size(36.dp))
|
||||||
|
ArrowForward()
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.android),
|
||||||
|
"Android icon",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.size(36.dp))
|
||||||
|
ArrowForward()
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.globe),
|
||||||
|
"Globe icon",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.size(36.dp))
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,211 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.tailscale.ipn.BuildConfig
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.mdm.MDMSettings
|
||||||
|
import com.tailscale.ipn.mdm.ShowHide
|
||||||
|
import com.tailscale.ipn.ui.Links
|
||||||
|
import com.tailscale.ipn.ui.theme.link
|
||||||
|
import com.tailscale.ipn.ui.theme.listItem
|
||||||
|
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
|
||||||
|
import com.tailscale.ipn.ui.util.Lists
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import com.tailscale.ipn.ui.viewModel.SettingsNav
|
||||||
|
import com.tailscale.ipn.ui.viewModel.SettingsViewModel
|
||||||
|
import com.tailscale.ipn.ui.viewModel.VpnViewModel
|
||||||
|
import com.tailscale.ipn.ui.notifier.Notifier
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel(), vpnViewModel: VpnViewModel = viewModel()) {
|
||||||
|
val handler = LocalUriHandler.current
|
||||||
|
|
||||||
|
val user by viewModel.loggedInUser.collectAsState()
|
||||||
|
val isAdmin by viewModel.isAdmin.collectAsState()
|
||||||
|
val managedByOrganization by viewModel.managedByOrganization.collectAsState()
|
||||||
|
val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState()
|
||||||
|
val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState()
|
||||||
|
val isVPNPrepared by vpnViewModel.vpnPrepared.collectAsState()
|
||||||
|
val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
Header(titleRes = R.string.settings_title, onBack = settingsNav.onNavigateBackHome)
|
||||||
|
}) { innerPadding ->
|
||||||
|
Column(modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) {
|
||||||
|
if (isVPNPrepared) {
|
||||||
|
UserView(
|
||||||
|
profile = user,
|
||||||
|
actionState = UserActionState.NAV,
|
||||||
|
onClick = settingsNav.onNavigateToUserSwitcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAdmin && !isAndroidTV()) {
|
||||||
|
Lists.ItemDivider()
|
||||||
|
AdminTextView { handler.openUri(Links.ADMIN_URL) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Lists.SectionDivider()
|
||||||
|
Setting.Text(
|
||||||
|
R.string.dns_settings,
|
||||||
|
subtitle =
|
||||||
|
corpDNSEnabled?.let {
|
||||||
|
stringResource(
|
||||||
|
if (it) R.string.using_tailscale_dns else R.string.not_using_tailscale_dns)
|
||||||
|
},
|
||||||
|
onClick = settingsNav.onNavigateToDNSSettings)
|
||||||
|
|
||||||
|
Lists.ItemDivider()
|
||||||
|
Setting.Text(
|
||||||
|
R.string.split_tunneling,
|
||||||
|
subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale),
|
||||||
|
onClick = settingsNav.onNavigateToSplitTunneling)
|
||||||
|
|
||||||
|
if (showTailnetLock.value == ShowHide.Show) {
|
||||||
|
Lists.ItemDivider()
|
||||||
|
Setting.Text(
|
||||||
|
R.string.tailnet_lock,
|
||||||
|
subtitle =
|
||||||
|
tailnetLockEnabled?.let {
|
||||||
|
stringResource(if (it) R.string.enabled else R.string.disabled)
|
||||||
|
},
|
||||||
|
onClick = settingsNav.onNavigateToTailnetLock)
|
||||||
|
}
|
||||||
|
|
||||||
|
Lists.ItemDivider()
|
||||||
|
Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions)
|
||||||
|
|
||||||
|
managedByOrganization.value?.let {
|
||||||
|
Lists.ItemDivider()
|
||||||
|
Setting.Text(
|
||||||
|
title = stringResource(R.string.managed_by_orgName, it),
|
||||||
|
onClick = settingsNav.onNavigateToManagedBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
Lists.SectionDivider()
|
||||||
|
Setting.Text(R.string.bug_report, onClick = settingsNav.onNavigateToBugReport)
|
||||||
|
|
||||||
|
Lists.ItemDivider()
|
||||||
|
Setting.Text(
|
||||||
|
R.string.about_tailscale,
|
||||||
|
subtitle = "${stringResource(id = R.string.version)} ${BuildConfig.VERSION_NAME}",
|
||||||
|
onClick = settingsNav.onNavigateToAbout)
|
||||||
|
|
||||||
|
// TODO: put a heading for the debug section
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Lists.SectionDivider()
|
||||||
|
Lists.MutedHeader(text = stringResource(R.string.internal_debug_options))
|
||||||
|
Setting.Text(R.string.mdm_settings, onClick = settingsNav.onNavigateToMDMSettings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object Setting {
|
||||||
|
@Composable
|
||||||
|
fun Text(
|
||||||
|
titleRes: Int = 0,
|
||||||
|
title: String? = null,
|
||||||
|
subtitle: String? = null,
|
||||||
|
destructive: Boolean = false,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onClick: (() -> Unit)? = null
|
||||||
|
) {
|
||||||
|
var modifier: Modifier = Modifier
|
||||||
|
if (enabled) {
|
||||||
|
onClick?.let { modifier = modifier.clickable(onClick = it) }
|
||||||
|
}
|
||||||
|
ListItem(
|
||||||
|
modifier = modifier,
|
||||||
|
colors = MaterialTheme.colorScheme.listItem,
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
title ?: stringResource(titleRes),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified)
|
||||||
|
},
|
||||||
|
supportingContent =
|
||||||
|
subtitle?.let {
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Switch(
|
||||||
|
titleRes: Int = 0,
|
||||||
|
title: String? = null,
|
||||||
|
isOn: Boolean,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onToggle: (Boolean) -> Unit = {}
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
colors = MaterialTheme.colorScheme.listItem,
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
title ?: stringResource(titleRes),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
TintedSwitch(checked = isOn, onCheckedChange = onToggle, enabled = enabled)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
|
||||||
|
val adminStr = buildAnnotatedString {
|
||||||
|
append(stringResource(id = R.string.settings_admin_prefix))
|
||||||
|
|
||||||
|
pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL)
|
||||||
|
withStyle(
|
||||||
|
style =
|
||||||
|
SpanStyle(
|
||||||
|
color = MaterialTheme.colorScheme.link,
|
||||||
|
textDecoration = TextDecoration.Underline)) {
|
||||||
|
append(stringResource(id = R.string.settings_admin_link))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Lists.InfoItem(adminStr, onClick = onNavigateToAdminConsole)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun SettingsPreview() {
|
||||||
|
val vm = SettingsViewModel()
|
||||||
|
vm.corpDNSEnabled.set(true)
|
||||||
|
vm.tailNetLockEnabled.set(true)
|
||||||
|
vm.isAdmin.set(true)
|
||||||
|
vm.managedByOrganization.set("Tails and Scales Inc.")
|
||||||
|
SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
|
||||||
|
}
|
@ -0,0 +1,104 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.tailscale.ipn.ui.theme.topAppBar
|
||||||
|
import com.tailscale.ipn.ui.theme.ts_color_light_blue
|
||||||
|
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
|
||||||
|
|
||||||
|
typealias BackNavigation = () -> Unit
|
||||||
|
|
||||||
|
// Header view for all secondary screens
|
||||||
|
// @see TopAppBar actions for additional actions (usually a row of icons)
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun Header(
|
||||||
|
@StringRes titleRes: Int = 0,
|
||||||
|
title: (@Composable () -> Unit)? = null,
|
||||||
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
|
onBack: (() -> Unit)? = null
|
||||||
|
) {
|
||||||
|
val f = FocusRequester()
|
||||||
|
|
||||||
|
if (isAndroidTV()) {
|
||||||
|
LaunchedEffect(Unit) { f.requestFocus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
title?.let { title() }
|
||||||
|
?: Text(
|
||||||
|
stringResource(titleRes),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
},
|
||||||
|
colors = MaterialTheme.colorScheme.topAppBar,
|
||||||
|
actions = actions,
|
||||||
|
navigationIcon = { onBack?.let { BackArrow(action = it, focusRequester = f) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BackArrow(action: () -> Unit, focusRequester: FocusRequester) {
|
||||||
|
|
||||||
|
Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Go back to the previous screen",
|
||||||
|
modifier =
|
||||||
|
Modifier.focusRequester(focusRequester)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = rememberRipple(bounded = false),
|
||||||
|
onClick = { action() }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CheckedIndicator() {
|
||||||
|
Icon(Icons.Default.CheckCircle, null, tint = ts_color_light_blue)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SimpleActivityIndicator(size: Int = 32) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.width(size.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ActivityIndicator(progress: Double, size: Int = 32) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { progress.toFloat() },
|
||||||
|
modifier = Modifier.width(size.dp),
|
||||||
|
color = ts_color_light_blue,
|
||||||
|
trackColor = MaterialTheme.colorScheme.secondary,
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,111 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.tailscale.ipn.App
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.util.Lists
|
||||||
|
import com.tailscale.ipn.ui.viewModel.SplitTunnelAppPickerViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SplitTunnelAppPickerView(
|
||||||
|
backToSettings: BackNavigation,
|
||||||
|
model: SplitTunnelAppPickerViewModel = viewModel()
|
||||||
|
) {
|
||||||
|
val installedApps by model.installedApps.collectAsState()
|
||||||
|
val excludedPackageNames by model.excludedPackageNames.collectAsState()
|
||||||
|
val builtInDisallowedPackageNames: List<String> = App.get().builtInDisallowedPackageNames
|
||||||
|
val mdmIncludedPackages by model.mdmIncludedPackages.collectAsState()
|
||||||
|
val mdmExcludedPackages by model.mdmExcludedPackages.collectAsState()
|
||||||
|
|
||||||
|
Scaffold(topBar = { Header(titleRes = R.string.split_tunneling, onBack = backToSettings) }) {
|
||||||
|
innerPadding ->
|
||||||
|
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||||
|
item(key = "header") {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string
|
||||||
|
.selected_apps_will_access_the_internet_directly_without_using_tailscale))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (mdmExcludedPackages.value?.isNotEmpty() == true) {
|
||||||
|
item("mdmExcludedNotice") {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(stringResource(R.string.certain_apps_are_not_routed_via_tailscale))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (mdmIncludedPackages.value?.isNotEmpty() == true) {
|
||||||
|
item("mdmIncludedNotice") {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(stringResource(R.string.only_specific_apps_are_routed_via_tailscale))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
item("resolversHeader") {
|
||||||
|
Lists.SectionDivider(
|
||||||
|
stringResource(R.string.count_excluded_apps, excludedPackageNames.count()))
|
||||||
|
}
|
||||||
|
items(installedApps) { app ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(app.name, fontWeight = FontWeight.SemiBold) },
|
||||||
|
leadingContent = {
|
||||||
|
Image(
|
||||||
|
bitmap =
|
||||||
|
model.installedAppsManager.packageManager
|
||||||
|
.getApplicationIcon(app.packageName)
|
||||||
|
.toBitmap()
|
||||||
|
.asImageBitmap(),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.width(40.dp).height(40.dp))
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
app.packageName,
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||||
|
letterSpacing = MaterialTheme.typography.bodySmall.letterSpacing)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Checkbox(
|
||||||
|
checked = excludedPackageNames.contains(app.packageName),
|
||||||
|
enabled = !builtInDisallowedPackageNames.contains(app.packageName),
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
if (checked) {
|
||||||
|
model.exclude(packageName = app.packageName)
|
||||||
|
} else {
|
||||||
|
model.unexclude(packageName = app.packageName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
Lists.ItemDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,199 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import android.text.format.Formatter
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.model.Ipn
|
||||||
|
import com.tailscale.ipn.ui.model.Tailcfg
|
||||||
|
import com.tailscale.ipn.ui.util.Lists.SectionDivider
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import com.tailscale.ipn.ui.viewModel.TaildropViewModel
|
||||||
|
import com.tailscale.ipn.ui.viewModel.TaildropViewModelFactory
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TaildropView(
|
||||||
|
requestedTransfers: StateFlow<List<Ipn.OutgoingFile>>,
|
||||||
|
applicationScope: CoroutineScope,
|
||||||
|
viewModel: TaildropViewModel =
|
||||||
|
viewModel(factory = TaildropViewModelFactory(requestedTransfers, applicationScope))
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
contentWindowInsets = WindowInsets.Companion.statusBars,
|
||||||
|
topBar = { Header(R.string.share) }) { paddingInsets ->
|
||||||
|
val showDialog = viewModel.showDialog.collectAsState().value
|
||||||
|
|
||||||
|
// Show the error overlay
|
||||||
|
showDialog?.let { ErrorDialog(type = it, action = { viewModel.showDialog.set(null) }) }
|
||||||
|
|
||||||
|
Column(modifier = Modifier.padding(paddingInsets)) {
|
||||||
|
FileShareHeader(
|
||||||
|
fileTransfers = requestedTransfers.collectAsState().value,
|
||||||
|
totalSize = viewModel.totalSize)
|
||||||
|
|
||||||
|
when (viewModel.state.collectAsState().value) {
|
||||||
|
Ipn.State.Running -> {
|
||||||
|
val peers by viewModel.myPeers.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
FileSharePeerList(
|
||||||
|
peers = peers,
|
||||||
|
stateViewGenerator = { peerId ->
|
||||||
|
viewModel.TrailingContentForPeer(peerId = peerId)
|
||||||
|
},
|
||||||
|
onShare = { viewModel.share(context, it) })
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
FileShareConnectView { viewModel.startVPN() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FileSharePeerList(
|
||||||
|
peers: List<Tailcfg.Node>,
|
||||||
|
stateViewGenerator: @Composable (String) -> Unit,
|
||||||
|
onShare: (Tailcfg.Node) -> Unit
|
||||||
|
) {
|
||||||
|
SectionDivider(stringResource(R.string.my_devices))
|
||||||
|
|
||||||
|
when (peers.isEmpty()) {
|
||||||
|
true -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp).fillMaxHeight(),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.no_devices_to_share_with),
|
||||||
|
style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false -> {
|
||||||
|
LazyColumn {
|
||||||
|
peers.forEach { peer ->
|
||||||
|
item {
|
||||||
|
PeerView(
|
||||||
|
peer = peer,
|
||||||
|
onClick = { onShare(peer) },
|
||||||
|
subtitle = { peer.Hostinfo.OS ?: "" },
|
||||||
|
trailingContent = { stateViewGenerator(peer.StableID) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FileShareConnectView(onToggle: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp).fillMaxHeight(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp, alignment = Alignment.CenterVertically),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.connect_to_your_tailnet_to_share_files),
|
||||||
|
style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(modifier = Modifier.size(1.dp))
|
||||||
|
PrimaryActionButton(onClick = onToggle) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.connect),
|
||||||
|
fontSize = MaterialTheme.typography.titleMedium.fontSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FileShareHeader(fileTransfers: List<Ipn.OutgoingFile>, totalSize: Long) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconForTransfer(fileTransfers)
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
|
||||||
|
when (fileTransfers.isEmpty()) {
|
||||||
|
true ->
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.no_files_to_share),
|
||||||
|
style = MaterialTheme.typography.titleMedium)
|
||||||
|
false -> {
|
||||||
|
|
||||||
|
when (fileTransfers.size) {
|
||||||
|
1 -> Text(fileTransfers[0].Name, style = MaterialTheme.typography.titleMedium)
|
||||||
|
else ->
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.file_count, fileTransfers.size),
|
||||||
|
style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val size = Formatter.formatFileSize(LocalContext.current, totalSize.toLong())
|
||||||
|
Text(
|
||||||
|
size,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IconForTransfer(transfers: List<Ipn.OutgoingFile>) {
|
||||||
|
// (jonathan) TODO: Thumbnails?
|
||||||
|
when (transfers.size) {
|
||||||
|
0 ->
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.warning),
|
||||||
|
contentDescription = "no files",
|
||||||
|
modifier = Modifier.size(32.dp))
|
||||||
|
1 -> {
|
||||||
|
// Show a thumbnail for single image shares.
|
||||||
|
val context = LocalContext.current
|
||||||
|
context.contentResolver.getType(transfers[0].uri)?.let {
|
||||||
|
if (it.startsWith("image/")) {
|
||||||
|
AsyncImage(
|
||||||
|
model = transfers[0].uri,
|
||||||
|
contentDescription = "one file",
|
||||||
|
modifier = Modifier.size(40.dp))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.single_file),
|
||||||
|
contentDescription = "files",
|
||||||
|
modifier = Modifier.size(40.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else ->
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.single_file),
|
||||||
|
contentDescription = "files",
|
||||||
|
modifier = Modifier.size(40.dp))
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,132 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.ClickableText
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.Links
|
||||||
|
import com.tailscale.ipn.ui.theme.defaultTextColor
|
||||||
|
import com.tailscale.ipn.ui.theme.link
|
||||||
|
import com.tailscale.ipn.ui.util.ClipboardValueView
|
||||||
|
import com.tailscale.ipn.ui.util.Lists
|
||||||
|
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import com.tailscale.ipn.ui.viewModel.TailnetLockSetupViewModel
|
||||||
|
import com.tailscale.ipn.ui.viewModel.TailnetLockSetupViewModelFactory
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TailnetLockSetupView(
|
||||||
|
backToSettings: BackNavigation,
|
||||||
|
model: TailnetLockSetupViewModel = viewModel(factory = TailnetLockSetupViewModelFactory())
|
||||||
|
) {
|
||||||
|
val statusItems by model.statusItems.collectAsState()
|
||||||
|
val nodeKey by model.nodeKey.collectAsState()
|
||||||
|
val tailnetLockKey by model.tailnetLockKey.collectAsState()
|
||||||
|
val tailnetLockTlPubKey = tailnetLockKey.replace("nlpub", "tlpub")
|
||||||
|
|
||||||
|
Scaffold(topBar = { Header(R.string.tailnet_lock, onBack = backToSettings) }) { innerPadding ->
|
||||||
|
LoadingIndicator.Wrap {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier.padding(innerPadding)
|
||||||
|
.focusable()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.fillMaxSize()) {
|
||||||
|
ExplainerView()
|
||||||
|
|
||||||
|
statusItems.forEach { statusItem ->
|
||||||
|
Lists.ItemDivider()
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = statusItem.icon),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
},
|
||||||
|
headlineContent = { Text(stringResource(statusItem.title)) })
|
||||||
|
}
|
||||||
|
// Node key
|
||||||
|
Lists.SectionDivider()
|
||||||
|
ClipboardValueView(
|
||||||
|
value = nodeKey,
|
||||||
|
title = stringResource(R.string.node_key),
|
||||||
|
subtitle = stringResource(R.string.node_key_explainer))
|
||||||
|
|
||||||
|
// Tailnet lock key
|
||||||
|
Lists.SectionDivider()
|
||||||
|
ClipboardValueView(
|
||||||
|
value = tailnetLockTlPubKey,
|
||||||
|
title = stringResource(R.string.tailnet_lock_key),
|
||||||
|
subtitle = stringResource(R.string.tailnet_lock_key_explainer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ExplainerView() {
|
||||||
|
val handler = LocalUriHandler.current
|
||||||
|
|
||||||
|
Lists.MultilineDescription {
|
||||||
|
ClickableText(
|
||||||
|
explainerText(),
|
||||||
|
onClick = { handler.openUri(Links.TAILNET_LOCK_KB_URL) },
|
||||||
|
style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun explainerText(): AnnotatedString {
|
||||||
|
return buildAnnotatedString {
|
||||||
|
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
|
||||||
|
append(stringResource(id = R.string.tailnet_lock_explainer))
|
||||||
|
}
|
||||||
|
|
||||||
|
pushStringAnnotation(tag = "tailnetLockSupportURL", annotation = Links.TAILNET_LOCK_KB_URL)
|
||||||
|
|
||||||
|
withStyle(
|
||||||
|
style =
|
||||||
|
SpanStyle(
|
||||||
|
color = MaterialTheme.colorScheme.link,
|
||||||
|
textDecoration = TextDecoration.Underline)) {
|
||||||
|
append(stringResource(id = R.string.learn_more))
|
||||||
|
}
|
||||||
|
pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@Preview
|
||||||
|
fun TailnetLockSetupViewPreview() {
|
||||||
|
val vm = TailnetLockSetupViewModel()
|
||||||
|
vm.nodeKey.set("8BADF00D-EA7-1337-DEAD-BEEF")
|
||||||
|
vm.tailnetLockKey.set("C0FFEE-CAFE-50DA")
|
||||||
|
TailnetLockSetupView(backToSettings = {}, vm)
|
||||||
|
}
|
@ -0,0 +1,154 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import com.tailscale.ipn.ui.theme.onBackgroundLogoDotDisabled
|
||||||
|
import com.tailscale.ipn.ui.theme.onBackgroundLogoDotEnabled
|
||||||
|
import com.tailscale.ipn.ui.theme.standaloneLogoDotDisabled
|
||||||
|
import com.tailscale.ipn.ui.theme.standaloneLogoDotEnabled
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlin.concurrent.timer
|
||||||
|
|
||||||
|
// DotsMatrix represents the state of the progress indicator.
|
||||||
|
typealias DotsMatrix = List<List<Boolean>>
|
||||||
|
|
||||||
|
// The initial DotsMatrix that represents the Tailscale logo (T-shaped).
|
||||||
|
val logoDotsMatrix: DotsMatrix =
|
||||||
|
listOf(
|
||||||
|
listOf(false, false, false),
|
||||||
|
listOf(true, true, true),
|
||||||
|
listOf(false, true, false),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TailscaleLogoView(
|
||||||
|
animated: Boolean = false,
|
||||||
|
usesOnBackgroundColors: Boolean = false,
|
||||||
|
modifier: Modifier
|
||||||
|
) {
|
||||||
|
|
||||||
|
val primaryColor: Color =
|
||||||
|
if (usesOnBackgroundColors) {
|
||||||
|
MaterialTheme.colorScheme.onBackgroundLogoDotEnabled
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.standaloneLogoDotEnabled
|
||||||
|
}
|
||||||
|
val secondaryColor: Color =
|
||||||
|
if (usesOnBackgroundColors) {
|
||||||
|
MaterialTheme.colorScheme.onBackgroundLogoDotDisabled
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.standaloneLogoDotDisabled
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentDotsMatrix: StateFlow<DotsMatrix> = MutableStateFlow(logoDotsMatrix)
|
||||||
|
var currentDotsMatrixIndex = 0
|
||||||
|
fun advanceToNextMatrix() {
|
||||||
|
currentDotsMatrixIndex = (currentDotsMatrixIndex + 1) % gameOfLife.size
|
||||||
|
val newMatrix =
|
||||||
|
if (animated) {
|
||||||
|
gameOfLife[currentDotsMatrixIndex]
|
||||||
|
} else {
|
||||||
|
logoDotsMatrix
|
||||||
|
}
|
||||||
|
currentDotsMatrix.set(newMatrix)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animated) {
|
||||||
|
timer(period = 300L) { advanceToNextMatrix() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EnabledDot(modifier: Modifier) {
|
||||||
|
Canvas(modifier = modifier, onDraw = { drawCircle(primaryColor) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DisabledDot(modifier: Modifier) {
|
||||||
|
Canvas(modifier = modifier, onDraw = { drawCircle(secondaryColor) })
|
||||||
|
}
|
||||||
|
|
||||||
|
BoxWithConstraints(modifier) {
|
||||||
|
val currentMatrix = currentDotsMatrix.collectAsState().value
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
|
||||||
|
for (y in 0..2) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
|
||||||
|
for (x in 0..2) {
|
||||||
|
if (currentMatrix[y][x]) {
|
||||||
|
EnabledDot(Modifier.size(this@BoxWithConstraints.maxWidth.div(4)))
|
||||||
|
} else {
|
||||||
|
DisabledDot(Modifier.size(this@BoxWithConstraints.maxWidth.div(4)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val gameOfLife: List<DotsMatrix> =
|
||||||
|
listOf(
|
||||||
|
listOf(
|
||||||
|
listOf(false, true, true),
|
||||||
|
listOf(true, false, true),
|
||||||
|
listOf(false, false, true),
|
||||||
|
),
|
||||||
|
listOf(
|
||||||
|
listOf(false, true, true),
|
||||||
|
listOf(false, false, true),
|
||||||
|
listOf(false, true, false),
|
||||||
|
),
|
||||||
|
listOf(
|
||||||
|
listOf(false, true, true),
|
||||||
|
listOf(false, false, false),
|
||||||
|
listOf(false, false, true),
|
||||||
|
),
|
||||||
|
listOf(
|
||||||
|
listOf(false, false, true),
|
||||||
|
listOf(false, true, false),
|
||||||
|
listOf(false, false, false),
|
||||||
|
),
|
||||||
|
listOf(
|
||||||
|
listOf(false, true, false),
|
||||||
|
listOf(false, false, false),
|
||||||
|
listOf(false, false, false),
|
||||||
|
),
|
||||||
|
listOf(
|
||||||
|
listOf(false, false, false),
|
||||||
|
listOf(false, false, true),
|
||||||
|
listOf(false, false, false),
|
||||||
|
),
|
||||||
|
listOf(
|
||||||
|
listOf(false, false, false),
|
||||||
|
listOf(false, false, false),
|
||||||
|
listOf(false, false, false),
|
||||||
|
),
|
||||||
|
listOf(
|
||||||
|
listOf(false, false, true),
|
||||||
|
listOf(false, false, false),
|
||||||
|
listOf(false, false, false),
|
||||||
|
),
|
||||||
|
listOf(
|
||||||
|
listOf(false, false, false),
|
||||||
|
listOf(false, false, false),
|
||||||
|
listOf(true, false, false),
|
||||||
|
),
|
||||||
|
listOf(listOf(false, false, false), listOf(false, false, false), listOf(true, true, false)),
|
||||||
|
listOf(listOf(false, false, false), listOf(true, false, false), listOf(true, true, false)),
|
||||||
|
listOf(listOf(false, false, false), listOf(true, true, false), listOf(false, true, false)),
|
||||||
|
listOf(listOf(false, false, false), listOf(true, true, false), listOf(false, true, true)),
|
||||||
|
listOf(listOf(false, false, false), listOf(true, true, true), listOf(false, false, true)),
|
||||||
|
listOf(listOf(false, true, false), listOf(true, true, true), listOf(true, false, true)))
|
@ -0,0 +1,12 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TintedSwitch(checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?, enabled: Boolean = true) {
|
||||||
|
Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
|
||||||
|
}
|
@ -0,0 +1,193 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.util.Lists
|
||||||
|
import com.tailscale.ipn.ui.util.itemsWithDividers
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import com.tailscale.ipn.ui.viewModel.UserSwitcherViewModel
|
||||||
|
|
||||||
|
data class UserSwitcherNav(
|
||||||
|
val backToSettings: BackNavigation,
|
||||||
|
val onNavigateHome: () -> Unit,
|
||||||
|
val onNavigateCustomControl: () -> Unit,
|
||||||
|
val onNavigateToAuthKey: () -> Unit
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = viewModel()) {
|
||||||
|
|
||||||
|
val users by viewModel.loginProfiles.collectAsState()
|
||||||
|
val currentUser by viewModel.loggedInUser.collectAsState()
|
||||||
|
val showHeaderMenu by viewModel.showHeaderMenu.collectAsState()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
Header(
|
||||||
|
R.string.accounts,
|
||||||
|
onBack = nav.backToSettings,
|
||||||
|
actions = {
|
||||||
|
Row {
|
||||||
|
FusMenu(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onAuthKeyClick = nav.onNavigateToAuthKey,
|
||||||
|
onCustomClick = nav.onNavigateCustomControl)
|
||||||
|
IconButton(onClick = { viewModel.showHeaderMenu.set(!showHeaderMenu) }) {
|
||||||
|
Icon(Icons.Default.MoreVert, "menu")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(innerPadding).fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
val showErrorDialog by viewModel.errorDialog.collectAsState()
|
||||||
|
|
||||||
|
// Show the error overlay if need be
|
||||||
|
showErrorDialog?.let {
|
||||||
|
ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// When switch is invoked, this stores the ID of the user we're trying to switch to
|
||||||
|
// so we can decorate it with a spinner. The actual logged in user will not change
|
||||||
|
// until
|
||||||
|
// we get our first netmap update back with the new userId for SelfNode.
|
||||||
|
// (jonathan) TODO: This user switch is not immediate. We may need to represent the
|
||||||
|
// "switching users" state globally (if ipnState is insufficient)
|
||||||
|
val nextUserId = remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
LazyColumn {
|
||||||
|
itemsWithDividers(users ?: emptyList()) { user ->
|
||||||
|
if (user.ID == currentUser?.ID) {
|
||||||
|
UserView(profile = user, actionState = UserActionState.CURRENT)
|
||||||
|
} else {
|
||||||
|
val state =
|
||||||
|
if (user.ID == nextUserId.value) UserActionState.SWITCHING
|
||||||
|
else UserActionState.NONE
|
||||||
|
UserView(
|
||||||
|
profile = user,
|
||||||
|
actionState = state,
|
||||||
|
onClick = {
|
||||||
|
nextUserId.value = user.ID
|
||||||
|
viewModel.switchProfile(user) {
|
||||||
|
if (it.isFailure) {
|
||||||
|
viewModel.errorDialog.set(ErrorDialogType.LOGOUT_FAILED)
|
||||||
|
nextUserId.value = null
|
||||||
|
} else {
|
||||||
|
nav.onNavigateHome()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Lists.SectionDivider()
|
||||||
|
Setting.Text(R.string.add_account) {
|
||||||
|
viewModel.addProfile {
|
||||||
|
if (it.isFailure) {
|
||||||
|
viewModel.errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Lists.ItemDivider()
|
||||||
|
Setting.Text(R.string.reauthenticate) { viewModel.login() }
|
||||||
|
|
||||||
|
if (currentUser != null) {
|
||||||
|
Lists.ItemDivider()
|
||||||
|
Setting.Text(
|
||||||
|
R.string.log_out,
|
||||||
|
destructive = true,
|
||||||
|
onClick = {
|
||||||
|
viewModel.logout {
|
||||||
|
it.onSuccess { nav.onNavigateHome() }
|
||||||
|
.onFailure {
|
||||||
|
viewModel.errorDialog.set(ErrorDialogType.LOGOUT_FAILED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FusMenu(
|
||||||
|
onCustomClick: () -> Unit,
|
||||||
|
onAuthKeyClick: () -> Unit,
|
||||||
|
viewModel: UserSwitcherViewModel
|
||||||
|
) {
|
||||||
|
val expanded by viewModel.showHeaderMenu.collectAsState()
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { viewModel.showHeaderMenu.set(false) },
|
||||||
|
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)) {
|
||||||
|
MenuItem(
|
||||||
|
onClick = {
|
||||||
|
onCustomClick()
|
||||||
|
viewModel.showHeaderMenu.set(false)
|
||||||
|
},
|
||||||
|
text = stringResource(id = R.string.custom_control_menu))
|
||||||
|
MenuItem(
|
||||||
|
onClick = {
|
||||||
|
onAuthKeyClick()
|
||||||
|
viewModel.showHeaderMenu.set(false)
|
||||||
|
},
|
||||||
|
text = stringResource(id = R.string.auth_key_menu))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MenuItem(text: String, onClick: () -> Unit) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 0.dp),
|
||||||
|
onClick = onClick,
|
||||||
|
text = { Text(text = text) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@Preview
|
||||||
|
fun UserSwitcherViewPreview() {
|
||||||
|
val vm = UserSwitcherViewModel()
|
||||||
|
val nav =
|
||||||
|
UserSwitcherNav(
|
||||||
|
backToSettings = {},
|
||||||
|
onNavigateHome = {},
|
||||||
|
onNavigateCustomControl = {},
|
||||||
|
onNavigateToAuthKey = {})
|
||||||
|
UserSwitcherView(nav, vm)
|
||||||
|
}
|
@ -0,0 +1,91 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemColors
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.model.IpnLocal
|
||||||
|
import com.tailscale.ipn.ui.theme.minTextSize
|
||||||
|
import com.tailscale.ipn.ui.theme.short
|
||||||
|
import com.tailscale.ipn.ui.util.AutoResizingText
|
||||||
|
|
||||||
|
// Used to decorate UserViews.
|
||||||
|
// NONE indicates no decoration
|
||||||
|
// CURRENT indicates the user is the current user and will be "checked"
|
||||||
|
// SWITCHING indicates the user is being switched to and will be "loading"
|
||||||
|
// NAV will show a chevron
|
||||||
|
enum class UserActionState {
|
||||||
|
CURRENT,
|
||||||
|
SWITCHING,
|
||||||
|
NAV,
|
||||||
|
NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UserView(
|
||||||
|
profile: IpnLocal.LoginProfile?,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
colors: ListItemColors = ListItemDefaults.colors(),
|
||||||
|
actionState: UserActionState = UserActionState.NONE,
|
||||||
|
) {
|
||||||
|
Box {
|
||||||
|
var modifier: Modifier = Modifier
|
||||||
|
onClick?.let { modifier = modifier.clickable { it() } }
|
||||||
|
profile?.let {
|
||||||
|
ListItem(
|
||||||
|
modifier = modifier,
|
||||||
|
colors = colors,
|
||||||
|
leadingContent = { Avatar(profile = profile, size = 36) },
|
||||||
|
headlineContent = {
|
||||||
|
AutoResizingText(
|
||||||
|
text = profile.UserProfile.DisplayName,
|
||||||
|
style = MaterialTheme.typography.titleMedium.short,
|
||||||
|
minFontSize = MaterialTheme.typography.minTextSize,
|
||||||
|
overflow = TextOverflow.Ellipsis)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
AutoResizingText(
|
||||||
|
text = profile.NetworkProfile?.DomainName ?: "",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.short,
|
||||||
|
minFontSize = MaterialTheme.typography.minTextSize,
|
||||||
|
overflow = TextOverflow.Ellipsis)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
when (actionState) {
|
||||||
|
UserActionState.CURRENT -> CheckedIndicator()
|
||||||
|
UserActionState.SWITCHING -> SimpleActivityIndicator(size = 26)
|
||||||
|
UserActionState.NAV ->
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.KeyboardArrowRight, null, Modifier.offset(x = 6.dp))
|
||||||
|
UserActionState.NONE -> Unit
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
?: run {
|
||||||
|
ListItem(
|
||||||
|
modifier = modifier,
|
||||||
|
colors = colors,
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.accounts),
|
||||||
|
style = MaterialTheme.typography.titleMedium)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.viewModel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.tailscale.ipn.ui.localapi.Client
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
class BugReportViewModel : ViewModel() {
|
||||||
|
val bugReportID: StateFlow<String> = MutableStateFlow("")
|
||||||
|
|
||||||
|
init {
|
||||||
|
Client(viewModelScope).bugReportId { result ->
|
||||||
|
result
|
||||||
|
.onSuccess { bugReportID.set(it.trim()) }
|
||||||
|
.onFailure { bugReportID.set("(Error fetching ID)") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,54 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.viewModel
|
||||||
|
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import com.tailscale.ipn.ui.view.ErrorDialogType
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
const val AUTH_KEY_LENGTH = 16
|
||||||
|
|
||||||
|
open class CustomLoginViewModel : IpnViewModel() {
|
||||||
|
val errorDialog: StateFlow<ErrorDialogType?> = MutableStateFlow(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoginWithAuthKeyViewModel : CustomLoginViewModel() {
|
||||||
|
// Sets the auth key and invokes the login flow
|
||||||
|
fun setAuthKey(authKey: String, onSuccess: () -> Unit) {
|
||||||
|
// The most basic of checks for auth key syntax
|
||||||
|
if (authKey.isEmpty()) {
|
||||||
|
errorDialog.set(ErrorDialogType.INVALID_AUTH_KEY)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loginWithAuthKey(authKey) {
|
||||||
|
it.onFailure { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) }
|
||||||
|
it.onSuccess { onSuccess() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoginWithCustomControlURLViewModel : CustomLoginViewModel() {
|
||||||
|
// Sets the custom control URL and invokes the login flow
|
||||||
|
fun setControlURL(urlStr: String, onSuccess: () -> Unit) {
|
||||||
|
// Some basic checks that the entered URL is "reasonable". The underlying
|
||||||
|
// localAPIClient will use the default server if we give it a broken URL,
|
||||||
|
// but we can make sure we can construct a URL from the input string and
|
||||||
|
// ensure it has an http/https scheme
|
||||||
|
when (urlStr.startsWith("http", ignoreCase = true) &&
|
||||||
|
urlStr.contains("://") &&
|
||||||
|
urlStr.length > 7) {
|
||||||
|
false -> {
|
||||||
|
errorDialog.set(ErrorDialogType.INVALID_CUSTOM_URL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
true -> {
|
||||||
|
loginWithCustomControlURL(urlStr) {
|
||||||
|
it.onFailure { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) }
|
||||||
|
it.onSuccess { onSuccess() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,94 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.viewModel
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.localapi.Client
|
||||||
|
import com.tailscale.ipn.ui.model.Ipn
|
||||||
|
import com.tailscale.ipn.ui.model.Tailcfg
|
||||||
|
import com.tailscale.ipn.ui.notifier.Notifier
|
||||||
|
import com.tailscale.ipn.ui.theme.off
|
||||||
|
import com.tailscale.ipn.ui.theme.success
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import com.tailscale.ipn.util.TSLog
|
||||||
|
|
||||||
|
class DNSSettingsViewModelFactory : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
return DNSSettingsViewModel() as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DNSSettingsViewModel : IpnViewModel() {
|
||||||
|
val enablementState: StateFlow<DNSEnablementState> =
|
||||||
|
MutableStateFlow(DNSEnablementState.NOT_RUNNING)
|
||||||
|
val dnsConfig: StateFlow<Tailcfg.DNSConfig?> = MutableStateFlow(null)
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
Notifier.netmap
|
||||||
|
.combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) }
|
||||||
|
.stateIn(viewModelScope)
|
||||||
|
.collect { (netmap, prefs) ->
|
||||||
|
TSLog.d("DNSSettingsViewModel", "prefs: CorpDNS=" + prefs?.CorpDNS.toString())
|
||||||
|
prefs?.let {
|
||||||
|
if (it.CorpDNS) {
|
||||||
|
enablementState.set(DNSEnablementState.ENABLED)
|
||||||
|
} else {
|
||||||
|
enablementState.set(DNSEnablementState.DISABLED)
|
||||||
|
}
|
||||||
|
} ?: run { enablementState.set(DNSEnablementState.NOT_RUNNING) }
|
||||||
|
netmap?.let { dnsConfig.set(netmap.DNS) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleCorpDNS(callback: (Result<Ipn.Prefs>) -> Unit) {
|
||||||
|
val prefs =
|
||||||
|
Notifier.prefs.value
|
||||||
|
?: run {
|
||||||
|
callback(Result.failure(Exception("no prefs")))
|
||||||
|
return@toggleCorpDNS
|
||||||
|
}
|
||||||
|
|
||||||
|
val prefsOut = Ipn.MaskedPrefs()
|
||||||
|
prefsOut.CorpDNS = !prefs.CorpDNS
|
||||||
|
Client(viewModelScope).editPrefs(prefsOut, callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class DNSEnablementState(
|
||||||
|
@StringRes val title: Int,
|
||||||
|
@StringRes val caption: Int,
|
||||||
|
val symbolDrawable: Int,
|
||||||
|
val tint: @Composable () -> Color
|
||||||
|
) {
|
||||||
|
NOT_RUNNING(
|
||||||
|
R.string.not_running,
|
||||||
|
R.string.tailscale_is_not_running_this_device_is_using_the_system_dns_resolver,
|
||||||
|
R.drawable.xmark_circle,
|
||||||
|
{ MaterialTheme.colorScheme.off }),
|
||||||
|
ENABLED(
|
||||||
|
R.string.using_tailscale_dns,
|
||||||
|
R.string.this_device_is_using_tailscale_to_resolve_dns_names,
|
||||||
|
R.drawable.check_circle,
|
||||||
|
{ MaterialTheme.colorScheme.success }),
|
||||||
|
DISABLED(
|
||||||
|
R.string.not_using_tailscale_dns,
|
||||||
|
R.string.this_device_is_using_the_system_dns_resolver,
|
||||||
|
R.drawable.xmark_circle,
|
||||||
|
{ MaterialTheme.colorScheme.error })
|
||||||
|
}
|
@ -0,0 +1,174 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.viewModel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.tailscale.ipn.ui.localapi.Client
|
||||||
|
import com.tailscale.ipn.ui.model.Ipn
|
||||||
|
import com.tailscale.ipn.ui.model.StableNodeID
|
||||||
|
import com.tailscale.ipn.ui.notifier.Notifier
|
||||||
|
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.TreeMap
|
||||||
|
|
||||||
|
data class ExitNodePickerNav(
|
||||||
|
val onNavigateBackHome: () -> Unit,
|
||||||
|
val onNavigateBackToExitNodes: () -> Unit,
|
||||||
|
val onNavigateToMullvad: () -> Unit,
|
||||||
|
val onNavigateToMullvadInfo: () -> Unit,
|
||||||
|
val onNavigateBackToMullvad: () -> Unit,
|
||||||
|
val onNavigateToMullvadCountry: (String) -> Unit,
|
||||||
|
val onNavigateToRunAsExitNode: () -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
class ExitNodePickerViewModelFactory(private val nav: ExitNodePickerNav) :
|
||||||
|
ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
return ExitNodePickerViewModel(nav) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel() {
|
||||||
|
data class ExitNode(
|
||||||
|
val id: StableNodeID? = null,
|
||||||
|
val label: String,
|
||||||
|
val online: StateFlow<Boolean>,
|
||||||
|
val selected: Boolean,
|
||||||
|
val mullvad: Boolean = false,
|
||||||
|
val priority: Int = 0,
|
||||||
|
val countryCode: String = "",
|
||||||
|
val country: String = "",
|
||||||
|
val city: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
val tailnetExitNodes: StateFlow<List<ExitNode>> = MutableStateFlow(emptyList())
|
||||||
|
val mullvadExitNodesByCountryCode: StateFlow<Map<String, List<ExitNode>>> =
|
||||||
|
MutableStateFlow(TreeMap())
|
||||||
|
val mullvadBestAvailableByCountry: StateFlow<Map<String, ExitNode>> = MutableStateFlow(TreeMap())
|
||||||
|
val mullvadExitNodeCount: StateFlow<Int> = MutableStateFlow(0)
|
||||||
|
val anyActive: StateFlow<Boolean> = MutableStateFlow(false)
|
||||||
|
val shouldShowMullvadInfo: StateFlow<Boolean> = MutableStateFlow(false)
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
Notifier.netmap
|
||||||
|
.combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) }
|
||||||
|
.stateIn(viewModelScope)
|
||||||
|
.collect { (netmap, prefs) ->
|
||||||
|
val exitNodeId = prefs?.activeExitNodeID ?: prefs?.selectedExitNodeID
|
||||||
|
netmap?.Peers?.let { peers ->
|
||||||
|
val allNodes =
|
||||||
|
peers
|
||||||
|
.filter { it.isExitNode }
|
||||||
|
.map {
|
||||||
|
ExitNode(
|
||||||
|
id = it.StableID,
|
||||||
|
label = it.displayName,
|
||||||
|
online = MutableStateFlow(it.Online ?: false),
|
||||||
|
selected = it.StableID == exitNodeId,
|
||||||
|
mullvad = it.Name.endsWith(".mullvad.ts.net."),
|
||||||
|
priority = it.Hostinfo.Location?.Priority ?: 0,
|
||||||
|
countryCode = it.Hostinfo.Location?.CountryCode ?: "",
|
||||||
|
country = it.Hostinfo.Location?.Country ?: "",
|
||||||
|
city = it.Hostinfo.Location?.City ?: "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val tailnetNodes = allNodes.filter { !it.mullvad }
|
||||||
|
tailnetExitNodes.set(tailnetNodes.sortedWith { a, b -> a.label.compareTo(b.label) })
|
||||||
|
|
||||||
|
val allMullvadExitNodes =
|
||||||
|
allNodes.filter { node ->
|
||||||
|
// Pick all mullvad nodes that are online or the currently selected
|
||||||
|
val online = node.online.value
|
||||||
|
node.mullvad && (node.selected || online)
|
||||||
|
}
|
||||||
|
val mullvadExitNodes =
|
||||||
|
allMullvadExitNodes
|
||||||
|
.groupBy {
|
||||||
|
// Group by countryCode
|
||||||
|
it.countryCode
|
||||||
|
}
|
||||||
|
.mapValues { (_, nodes) ->
|
||||||
|
// Group by city
|
||||||
|
nodes
|
||||||
|
.groupBy { it.city }
|
||||||
|
.mapValues { (_, nodes) ->
|
||||||
|
// Pick one node per city, either the selected one or the best
|
||||||
|
// available
|
||||||
|
nodes
|
||||||
|
.sortedWith { a, b ->
|
||||||
|
if (a.selected && !b.selected) {
|
||||||
|
-1
|
||||||
|
} else if (b.selected && !a.selected) {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
b.priority.compareTo(a.priority)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.first()
|
||||||
|
}
|
||||||
|
.values
|
||||||
|
.sortedBy { it.city.lowercase() }
|
||||||
|
}
|
||||||
|
mullvadExitNodesByCountryCode.set(mullvadExitNodes)
|
||||||
|
mullvadExitNodeCount.set(allMullvadExitNodes.size)
|
||||||
|
|
||||||
|
val bestAvailableByCountry =
|
||||||
|
mullvadExitNodes.mapValues { (_, nodes) ->
|
||||||
|
nodes.minByOrNull { -1 * it.priority }!!
|
||||||
|
}
|
||||||
|
mullvadBestAvailableByCountry.set(bestAvailableByCountry)
|
||||||
|
|
||||||
|
anyActive.set(allNodes.any { it.selected })
|
||||||
|
|
||||||
|
prefs?.let { prefs ->
|
||||||
|
// Only show the Mullvad info view if the user is an admin and is using a Tailscale
|
||||||
|
// control server, as it wouldn't be actionable information otherwise.
|
||||||
|
shouldShowMullvadInfo.set(
|
||||||
|
netmap.SelfNode.isAdmin && prefs.ControlURL.endsWith(".tailscale.com"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setExitNode(node: ExitNode) {
|
||||||
|
LoadingIndicator.start()
|
||||||
|
val prefsOut = Ipn.MaskedPrefs()
|
||||||
|
prefsOut.ExitNodeID = node.id
|
||||||
|
|
||||||
|
Client(viewModelScope).editPrefs(prefsOut) {
|
||||||
|
nav.onNavigateBackHome()
|
||||||
|
LoadingIndicator.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleAllowLANAccess(callback: (Result<Ipn.Prefs>) -> Unit) {
|
||||||
|
val prefs =
|
||||||
|
Notifier.prefs.value
|
||||||
|
?: run {
|
||||||
|
callback(Result.failure(Exception("no prefs")))
|
||||||
|
return@toggleAllowLANAccess
|
||||||
|
}
|
||||||
|
|
||||||
|
val prefsOut = Ipn.MaskedPrefs()
|
||||||
|
prefsOut.ExitNodeAllowLANAccess = !prefs.ExitNodeAllowLANAccess
|
||||||
|
Client(viewModelScope).editPrefs(prefsOut, callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val List<ExitNodePickerViewModel.ExitNode>.selected
|
||||||
|
get() = this.any { it.selected }
|
||||||
|
|
||||||
|
val Map<String, List<ExitNodePickerViewModel.ExitNode>>.selected
|
||||||
|
get() = this.any { it.value.selected }
|
@ -0,0 +1,23 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.viewModel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.tailscale.ipn.App
|
||||||
|
import com.tailscale.ipn.ui.model.Health
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class HealthViewModel : ViewModel() {
|
||||||
|
val warnings: StateFlow<List<Health.UnhealthyState>> = MutableStateFlow(listOf())
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
App.get().healthNotifier?.currentWarnings?.collect { set -> warnings.set(set.sorted()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue