Compare commits
836 Commits
Author | SHA1 | Date |
---|---|---|
kari-ts | 08a062bfcf | 3 days ago |
kari-ts | 4c4148bd8e | 7 days ago |
kari-ts | 18ca09d0f3 | 7 days ago |
kari-ts | bd745b5254 | 1 week ago |
kari-ts | ba306bf883 | 2 weeks ago |
James Tucker | e89c259749 | 2 weeks ago |
Andrea Gottardo | c1ef8b5f20 | 2 weeks ago |
James Tucker | e7325f7d5f | 2 weeks ago |
kari-ts | c7b1362451 | 2 weeks ago |
kari-ts | 0bd4ef932b | 3 weeks ago |
kari-ts | cafb114ae0 | 3 weeks ago |
kari-ts | af98b14770 | 3 weeks ago |
Brad Fitzpatrick | 2e9f6b735e | 4 weeks ago |
kari-ts | 354a903ee1 | 4 weeks ago |
kari-ts | 6ec54234ef | 4 weeks ago |
kari-ts | 18b8c78754 | 1 month ago |
kari-ts | 83f3f737ad | 1 month ago |
kari-ts | 753b8d3fb4 | 1 month ago |
Jonathan Nobels | 8ff0672ec7 | 1 month ago |
Andrea Gottardo | 47cde89984 | 1 month ago |
Keli | 5f19730c7a | 1 month ago |
kari-ts | d309f31b5a | 1 month ago |
Andrea Gottardo | cd993fee43 | 1 month ago |
Keli | a32c2aa0df | 1 month ago |
Andrea Gottardo | 0126db799b | 1 month ago |
Andrea Gottardo | 4ca757bb75 | 1 month ago |
Andrea Gottardo | 2daeee584d | 1 month ago |
kari-ts | be89cb10fe | 1 month ago |
kari-ts | f5ecca3c96 | 1 month ago |
Kristoffer Dalby | 8eabe8d6dd | 2 months ago |
Kristoffer Dalby | 2f08e2f02d | 2 months ago |
kari-ts | 9572541648 | 2 months ago |
kari-ts | c10aca720b | 2 months ago |
kari-ts | 25e7681c32 | 2 months ago |
Jonathan Nobels | f8f2ee029a | 2 months ago |
kari-ts | 08ae018468 | 2 months ago |
Brad Fitzpatrick | f26a828cbd | 2 months ago |
kari-ts | 9731afd44c | 2 months ago |
kari-ts | 9654bb5d9d | 2 months ago |
kari-ts | 2ec7304092 | 2 months ago |
kari-ts | 22de0cdb7e | 2 months ago |
kari-ts | fc8ccc0057 | 2 months ago |
Jonathan Nobels | 0b2a04b475 | 2 months ago |
kari-ts | 9987dbc592 | 2 months ago |
kari-ts | 8b91b0ff0a | 2 months ago |
James Tucker | 2fcb080aa6 | 2 months ago |
James Tucker | 9e09fad087 | 2 months ago |
James Tucker | 204173d10c | 2 months ago |
James Tucker | b3a7f7f2ae | 2 months ago |
James Tucker | 209045d4f7 | 2 months ago |
James Tucker | 7888447f3f | 2 months ago |
James Tucker | 72c410465c | 2 months ago |
Andrea Gottardo | 001e79546c | 2 months ago |
Andrea Gottardo | ffbc556cde | 2 months ago |
Jonathan Nobels | e195def5e2 | 2 months ago |
Andrew Dunham | aaecc62e1c | 2 months ago |
Brad Fitzpatrick | 33f79deb3a | 2 months ago |
kari-ts | 28712da8d0 | 2 months ago |
Andrew Dunham | 45567146f4 | 2 months ago |
kari-ts | 283e1ebcd8 | 2 months ago |
Jonathan Nobels | 9f87446ab6 | 2 months ago |
Jonathan Nobels | ab7ab73736 | 2 months ago |
Anton Tolchanov | fb8a4f51dc | 2 months ago |
Anton Tolchanov | 095dae1195 | 2 months ago |
Andrea Gottardo | 19581721cf | 3 months ago |
kari-ts | 18e4b176c6 | 3 months ago |
kari-ts | 77eaadb360 | 3 months ago |
yin kaisheng | a9ff204ae4 | 3 months ago |
kari-ts | b4ca226eb7 | 3 months ago |
kari-ts | d94125e767 | 3 months ago |
kari-ts | eae8789628 | 3 months ago |
kari-ts | 29e3c187c2 | 3 months ago |
Josh Vocal | 40090f179b | 3 months ago |
Jonathan Nobels | 502eada21a | 3 months ago |
Josh Vocal | cdbd062426 | 3 months ago |
Josh Vocal | 26e5e796fa | 3 months ago |
Andrea Gottardo | 8648c2ef27 | 3 months ago |
kari-ts | 1a41ab3b66 | 3 months ago |
kari-ts | 10a4350c02 | 3 months ago |
Andrea Gottardo | 4830d8826e | 3 months ago |
Jonathan Nobels | 20a5beab3e | 3 months ago |
Andrea Gottardo | a843c93669 | 3 months ago |
kari-ts | fcfb997fde | 3 months ago |
kari-ts | c6f3239b1b | 3 months ago |
Jonathan Nobels | e6fc832494 | 3 months ago |
Andrea Gottardo | 7e5e0f25cf | 3 months ago |
Percy Wegmann | c1b957cc5f | 3 months ago |
Jonathan Nobels | 716152b57d | 3 months ago |
Andrea Gottardo | 338c13b6b5 | 3 months ago |
Andrea Gottardo | 403aa092c4 | 3 months ago |
Nick Khyl | 2a32ed1f30 | 3 months ago |
Nick Khyl | 8767fbd8d8 | 3 months ago |
Nick Khyl | 946afb6c33 | 3 months ago |
Nick Khyl | 101c9dd121 | 3 months ago |
Andrea Gottardo | ea0c1e960d | 4 months ago |
Jonathan Nobels | 76ab7eab92 | 4 months ago |
Jonathan Nobels | cb916676a4 | 4 months ago |
Jonathan Nobels | 32e48dc78e | 4 months ago |
Jonathan Nobels | 23454e9bc6 | 4 months ago |
Andrea Gottardo | 1465b2a67f | 4 months ago |
Andrea Gottardo | b9917c8647 | 4 months ago |
Jonathan Nobels | 6deb61a20e | 4 months ago |
Jonathan Nobels | b9477c64a8 | 4 months ago |
Jonathan Nobels | 2f59feef20 | 4 months ago |
Andrea Gottardo | c4a1dec8eb | 4 months ago |
Jonathan Nobels | 65a025007f | 4 months ago |
Andrea Gottardo | ca91191cc6 | 4 months ago |
Jonathan Nobels | 26b4635c11 | 4 months ago |
Jonathan Nobels | 66fa3c41a6 | 4 months ago |
Jonathan Nobels | dfda774dc0 | 4 months ago |
Jonathan Nobels | 2a8d07c5f6 | 4 months ago |
Andrea Gottardo | 9b24888c4c | 4 months ago |
Andrea Gottardo | a120eb2fe1 | 5 months ago |
Andrea Gottardo | b3a74986ac | 5 months ago |
Andrea Gottardo | 840a31d74e | 5 months ago |
Andrea Gottardo | b6cacdfd6a | 5 months ago |
Andrea Gottardo | d702d2dab8 | 5 months ago |
Jonathan Nobels | 811641f538 | 5 months ago |
Andrea Gottardo | 9ae30c06bf | 5 months ago |
Andrea Gottardo | 793a83fdc6 | 5 months ago |
Andrea Gottardo | ea928ca971 | 5 months ago |
Andrea Gottardo | 8dc1a13f77 | 5 months ago |
Jonathan Nobels | 196944d168 | 5 months ago |
Jonathan Nobels | 0ff6be6345 | 5 months ago |
Jonathan Nobels | 634d51c20b | 5 months ago |
Fred Silberberg | 864cc35bd4 | 5 months ago |
Jonathan Nobels | 23805e9d00 | 5 months ago |
Jonathan Nobels | 5b121c1876 | 5 months ago |
Jonathan Nobels | 80864fec12 | 5 months ago |
Jonathan Nobels | ef21753763 | 5 months ago |
Jonathan Nobels | 0e82e54ffb | 5 months ago |
Jonathan Nobels | 64fca2a712 | 5 months ago |
Jonathan Nobels | a74e30d4e2 | 5 months ago |
Jonathan Nobels | 2788cf7ee5 | 5 months ago |
kari-ts | d7a87e868c | 6 months ago |
kari-ts | 15da8f3797 | 6 months ago |
kari-ts | 8f62f0da79 | 6 months ago |
kari-ts | cbc47791ad | 6 months ago |
kari-ts | a6fd8a8093 | 6 months ago |
kari-ts | 0df6c61eee | 6 months ago |
Andrea Gottardo | 75db9e64c8 | 6 months ago |
kari-ts | e826a173aa | 6 months ago |
kari-ts | a05829b3c0 | 6 months ago |
kari-ts | 72f35cd318 | 6 months ago |
kari-ts | 4fa86dbf03 | 6 months ago |
Jonathan Nobels | 77c2d924ee | 6 months ago |
Jonathan Nobels | b37492a547 | 6 months ago |
kari-ts | 999c6f2357 | 6 months ago |
Andrea Gottardo | 006b1e6852 | 6 months ago |
kari-ts | 32e29c4efd | 6 months ago |
kari-ts | 9aa3a840de | 6 months ago |
kari-ts | 0ff47f7ab5 | 6 months ago |
kari-ts | 12ad295706 | 6 months ago |
kari-ts | d842ccde22 | 6 months ago |
Andrea Gottardo | cbcc773b98 | 6 months ago |
Andrea Gottardo | cbc0035dfe | 6 months ago |
kari-ts | c47ead9412 | 6 months ago |
Percy Wegmann | 46cdbb7b9b | 6 months ago |
kari-ts | 5476288100 | 6 months ago |
kari-ts | a3b356a81c | 6 months ago |
Percy Wegmann | 411d7b2597 | 6 months ago |
Percy Wegmann | 59a88ffbab | 6 months ago |
kari-ts | f684bf696d | 6 months ago |
Percy Wegmann | 698fb868a7 | 6 months ago |
Andrea Gottardo | 82c17a4d1d | 6 months ago |
Jonathan Nobels | b615eb38b4 | 6 months ago |
Andrea Gottardo | 24d6cc7a08 | 6 months ago |
kari-ts | ec1dc8b0be | 6 months ago |
Percy Wegmann | edb3f5b0c5 | 6 months ago |
kari-ts | 7f66c373ea | 6 months ago |
kari-ts | 2d7d6e1357 | 6 months ago |
Jonathan Nobels | 45fd2e0661 | 6 months ago |
Percy Wegmann | 31b0ec8865 | 6 months ago |
Will Norris | 9703d48f1a | 6 months ago |
Jonathan Nobels | 17ad0c8cc0 | 7 months ago |
Jonathan Nobels | a2471d38cb | 7 months ago |
kari-ts | e6f6d35a99 | 7 months ago |
kari-ts | 5e3236260f | 7 months ago |
kari-ts | d330726ba1 | 7 months ago |
Andrea Gottardo | 0c0853a962 | 7 months ago |
James Tucker | 3f864b28c7 | 7 months ago |
kari-ts | 22c129ee1c | 7 months ago |
Andrea Gottardo | 427e2d29b4 | 7 months ago |
kari-ts | 1c0aef5418 | 7 months ago |
kari-ts | 39628be8a6 | 7 months ago |
Brad Fitzpatrick | 9dda2cc470 | 7 months ago |
kari-ts | a6bc2244b6 | 7 months ago |
kari-ts | 24dd83090c | 7 months ago |
kari-ts | ad3b6a5a64 | 7 months ago |
Percy Wegmann | 16fa0e9b9e | 7 months ago |
Andrea Gottardo | 88b0af2c9b | 7 months ago |
Andrea Gottardo | 7119424e32 | 7 months ago |
Jonathan Nobels | b06342629f | 7 months ago |
Percy Wegmann | 07d04ca750 | 7 months ago |
Percy Wegmann | 057e25c23d | 7 months ago |
Will Norris | a54ebf75ef | 7 months ago |
Jonathan Nobels | f4d2a277a5 | 7 months ago |
kari-ts | 75e2d8983b | 7 months ago |
kari-ts | bbb3c86fa8 | 7 months ago |
Percy Wegmann | bc8985126d | 7 months ago |
Brad Fitzpatrick | eb8d731a04 | 7 months ago |
kari-ts | 81acaef5b7 | 7 months ago |
kari-ts | 19177df1e2 | 7 months ago |
Praneet Loke | 6197cb9576 | 7 months ago |
kari-ts | 253c116f9b | 7 months ago |
Jonathan Nobels | 1c3af6713c | 7 months ago |
kari-ts | 39d1d0b3c3 | 7 months ago |
Andrea Gottardo | 56da7b66d0 | 7 months ago |
kari-ts | f95428f7fa | 7 months ago |
Percy Wegmann | 0c58841350 | 7 months ago |
Andrea Gottardo | 8a7148c085 | 7 months ago |
Jonathan Nobels | 372af99c53 | 7 months ago |
Andrea Gottardo | a73025b36f | 7 months ago |
Andrea Gottardo | 4d86c1a6f6 | 7 months ago |
Andrea Gottardo | a1d97baeb0 | 7 months ago |
Matt Drollette | 9533db44b7 | 7 months ago |
Andrea Gottardo | 44ac22c29d | 7 months ago |
kari-ts | 5ad25262ad | 7 months ago |
Jonathan Nobels | be6364ca95 | 7 months ago |
kari-ts | 3e32e97261 | 7 months ago |
Andrea Gottardo | 164a243b77 | 7 months ago |
Percy Wegmann | a77edc6724 | 7 months ago |
Percy Wegmann | d396fdab27 | 7 months ago |
Percy Wegmann | 0ae9da385e | 7 months ago |
Percy Wegmann | 9054264363 | 7 months ago |
Jonathan Nobels | 11f52ad96b | 7 months ago |
Percy Wegmann | 482b350ce0 | 7 months ago |
kari-ts | c8d1b30918 | 7 months ago |
kari-ts | 6a00880f61 | 7 months ago |
Jonathan Nobels | a3638f9fc7 | 7 months ago |
Percy Wegmann | c59c8537cf | 7 months ago |
Jonathan Nobels | cc244812a6 | 7 months ago |
kari-ts | a325a90558 | 7 months ago |
kari-ts | f14836a750 | 7 months ago |
kari-ts | 38f57b4737 | 7 months ago |
Percy Wegmann | d676dca4f4 | 7 months ago |
Jonathan Nobels | 32e407d06b | 7 months ago |
Percy Wegmann | 9bfa839380 | 7 months ago |
Percy Wegmann | 2e237e375e | 7 months ago |
Percy Wegmann | 71f03cf0d2 | 7 months ago |
kari-ts | 5745854297 | 7 months ago |
Jonathan Nobels | b4c0a6931d | 8 months ago |
Jonathan Nobels | dbc809167e | 8 months ago |
kari-ts | f54e476328 | 8 months ago |
Percy Wegmann | ccda0499a7 | 8 months ago |
Percy Wegmann | e7539f5ff3 | 8 months ago |
Percy Wegmann | c0ffd5016b | 8 months ago |
Jonathan Nobels | a0e7777958 | 8 months ago |
Percy Wegmann | ef894fa8ca | 8 months ago |
Percy Wegmann | c3dac5954e | 8 months ago |
Percy Wegmann | 54dccff232 | 8 months ago |
Jonathan Nobels | 31939cc855 | 8 months ago |
Jonathan Nobels | 75ad5cfef6 | 8 months ago |
Jonathan Nobels | d188da3a24 | 8 months ago |
Jonathan Nobels | 9fcc1ddfe1 | 8 months ago |
Jonathan Nobels | 3b21a06c8b | 8 months ago |
kari-ts | 9b27516e96 | 8 months ago |
Percy Wegmann | 1719d5d558 | 8 months ago |
Percy Wegmann | d332ce049e | 8 months ago |
Percy Wegmann | 91c1a8d0f3 | 8 months ago |
Jonathan Nobels | e9465988dd | 8 months ago |
Percy Wegmann | 6e503f29a9 | 8 months ago |
Jonathan Nobels | a321d84dba | 8 months ago |
Will Norris | 77f720dba7 | 8 months ago |
kari-ts | 3f816eac4d | 8 months ago |
Percy Wegmann | 9fb742bd8b | 8 months ago |
kari-ts | dca2fc3bf4 | 8 months ago |
Jonathan Nobels | 67a9320d26 | 8 months ago |
Percy Wegmann | 4897f09e50 | 8 months ago |
Percy Wegmann | 8105271d25 | 8 months ago |
Jonathan Nobels | 2818195400 | 8 months ago |
Percy Wegmann | e024c896c1 | 8 months ago |
Percy Wegmann | cfd01af74a | 8 months ago |
kari-ts | facf6406c3 | 8 months ago |
kari-ts | af2e33d130 | 8 months ago |
Jonathan Nobels | cf56dd6793 | 8 months ago |
kari-ts | 4baec5ff80 | 8 months ago |
Jonathan Nobels | 61fb6bbf8e | 8 months ago |
Percy Wegmann | 5599f2ddeb | 8 months ago |
Jonathan Nobels | e59112a8fb | 8 months ago |
Percy Wegmann | db3ba696eb | 8 months ago |
Percy Wegmann | 44ba20a24e | 8 months ago |
Percy Wegmann | 8e063051b6 | 8 months ago |
Percy Wegmann | 7392c7086e | 8 months ago |
Percy Wegmann | 9f3e871637 | 8 months ago |
Andrea Gottardo | e511430f73 | 8 months ago |
Percy Wegmann | cf6a203f7a | 8 months ago |
Percy Wegmann | fb5635b8a5 | 8 months ago |
Andrea Gottardo | 3fea68ef2e | 8 months ago |
Andrea Gottardo | bf74edd551 | 8 months ago |
Percy Wegmann | 28d0ab4dd6 | 8 months ago |
Percy Wegmann | 6a875e8854 | 8 months ago |
Percy Wegmann | a15fdd44bf | 8 months ago |
Will Norris | 9fcdcfe630 | 8 months ago |
Andrea Gottardo | e187a8db81 | 8 months ago |
Andrea Gottardo | f96e9b923f | 8 months ago |
Andrea Gottardo | 19adff3077 | 8 months ago |
Jonathan Nobels | e953b19189 | 8 months ago |
James Tucker | 5454b34dd1 | 8 months ago |
Andrea Gottardo | 0d1a3cf415 | 8 months ago |
Andrea Gottardo | c3b62124bb | 8 months ago |
Jonathan Nobels | 910511d838 | 8 months ago |
kari-ts | b346321078 | 8 months ago |
Jonathan Nobels | 7b7f7254ba | 8 months ago |
kari-ts | 72753bb82a | 8 months ago |
kari-ts | 7470fcc173 | 8 months ago |
James Tucker | 4e923a65c1 | 8 months ago |
James Tucker | 2a14964878 | 8 months ago |
James Tucker | 244221706f | 8 months ago |
Jonathan Nobels | b4f1989b67 | 8 months ago |
Percy Wegmann | 5e7e36e3bc | 8 months ago |
kari-ts | 98a72c2963 | 8 months ago |
Jonathan Nobels | f12439f9a3 | 8 months ago |
Jonathan Nobels | 113a7c6f9d | 8 months ago |
Jonathan Nobels | e4b0e1f8cd | 8 months ago |
Percy Wegmann | e568741081 | 8 months ago |
Percy Wegmann | a1e67ff1e9 | 8 months ago |
Percy Wegmann | d42329e2e2 | 8 months ago |
Anton Tolchanov | e16303e1d8 | 8 months ago |
Percy Wegmann | 9a6aecb454 | 8 months ago |
Andrea Gottardo | 06e850bbd5 | 8 months ago |
Jonathan Nobels | 4df18951a6 | 8 months ago |
Jonathan Nobels | 2c694b7159 | 8 months ago |
Andrea Gottardo | 7c64091aab | 8 months ago |
James Tucker | bf7bf94b52 | 8 months ago |
Jonathan Nobels | 16ec19757d | 8 months ago |
Jonathan Nobels | f275656c25 | 8 months ago |
Jonathan Nobels | 1f457399b8 | 8 months ago |
Jonathan Nobels | 94a4f55eb2 | 8 months ago |
Jonathan Nobels | 0d867aedce | 8 months ago |
Jonathan Nobels | bf0e56469f | 8 months ago |
Jonathan Nobels | 3926cf4b56 | 8 months ago |
James Tucker | 87a8003d39 | 8 months ago |
Jonathan Nobels | 4f46c38c99 | 8 months ago |
Anton Tolchanov | a0f87846fd | 8 months ago |
Anton Tolchanov | 7d25cf97f8 | 8 months ago |
Jonathan Nobels | 9a206805df | 8 months ago |
kari-ts | 01ec98f29a | 8 months ago |
Aalok Kamble | f23477e796 | 8 months ago |
kari-ts | 464f089388 | 8 months ago |
kari-ts | 9492b01946 | 8 months ago |
Jonathan Nobels | bb7ea7cf9f | 9 months ago |
Percy Wegmann | 37832a5b72 | 9 months ago |
kari-ts | 89e160bd08 | 9 months ago |
kari-ts | bf9be063d7 | 9 months ago |
kari-ts | f6b0734e49 | 9 months ago |
Percy Wegmann | cbe8858427 | 9 months ago |
kari-ts | 60b9884aa2 | 9 months ago |
kari-ts | 98fe1e86e5 | 9 months ago |
Moritz Poldrack | e90f39a58c | 9 months ago |
kari-ts | f9310e7a1f | 9 months ago |
Nicola Beghin | df9c75136b | 9 months ago |
kari-ts | 915e4e3394 | 9 months ago |
kari-ts | b96df2b830 | 9 months ago |
kari-ts | 630a6069c4 | 9 months ago |
Charlotte Brandhorst-Satzkorn | 9e8dfbb2ab | 9 months ago |
Charlotte Brandhorst-Satzkorn | 3615398012 | 9 months ago |
kari-ts | 813ca8adea | 10 months ago |
David Anderson | 3255d55e39 | 10 months ago |
David Anderson | 4c7d66701f | 10 months ago |
kari-ts | a76b36506c | 10 months ago |
kari-ts | 1b42117791 | 10 months ago |
kari-ts | 99c54591e6 | 10 months ago |
Denton Gentry | 52601c0dff | 10 months ago |
Denton Gentry | dcca09fe7f | 10 months ago |
kari-ts | 7a52cae96f | 10 months ago |
kari-ts | ae647625b0 | 11 months ago |
Denton Gentry | 61453254df | 11 months ago |
Denton Gentry | 5ef7bbaff0 | 11 months ago |
Denton Gentry | a6ef5424a7 | 1 year ago |
Denton Gentry | 0a44d50e8b | 1 year ago |
Denton Gentry | 33a2eb0dee | 1 year ago |
Denton Gentry | 318065c64f | 1 year ago |
Denton Gentry | ab4a672a4e | 1 year ago |
Denton Gentry | 8f766ba087 | 1 year ago |
Denton Gentry | ea2cd2ec86 | 1 year ago |
Denton Gentry | 38d38b3af9 | 1 year ago |
Denton Gentry | b23dd78e99 | 1 year ago |
Denton Gentry | 3a305b158c | 1 year ago |
Denton Gentry | a965ae0038 | 1 year ago |
Andrew Lytvynov | 6684b3059c | 1 year ago |
Andrew Lytvynov | d5646fb2aa | 1 year ago |
James Tucker | 727e7e2b50 | 1 year ago |
Aaron Klotz | 6a142b2f50 | 1 year ago |
Aaron Klotz | 338abf2cfb | 1 year ago |
Brad Fitzpatrick | c21bbc94a1 | 1 year ago |
James Tucker | cb2e7da117 | 1 year ago |
Brad Fitzpatrick | b500bbdad6 | 1 year ago |
Maisem Ali | 50b5b851eb | 1 year ago |
Denton Gentry | c73f8533f0 | 1 year ago |
Charlotte Brandhorst-Satzkorn | aba683bb61 | 1 year ago |
Andrew Lytvynov | 88d006f6b9 | 1 year ago |
Andrew Lytvynov | 4b67f47e88 | 1 year ago |
James Tucker | 11a0d21d2e | 1 year ago |
James Tucker | 561ec860ed | 1 year ago |
Denton Gentry | 5610486051 | 1 year ago |
Brett Jenkins | 6348bb254a | 1 year ago |
James Tucker | 8e748afc47 | 1 year ago |
James Tucker | a5edb67c9d | 1 year ago |
James Tucker | 6a6e80db47 | 1 year ago |
James Tucker | 926613ddae | 1 year ago |
James Tucker | 5ca195b109 | 1 year ago |
Denton Gentry | 31f2aa8097 | 1 year ago |
kari-ts | 264aae3232 | 1 year ago |
Andrea Gottardo | bb47fa593c | 1 year ago |
kari-ts | 04b79a2206 | 1 year ago |
Brad Fitzpatrick | f44692addd | 1 year ago |
Will Norris | dc9c96278d | 1 year ago |
Charlotte Brandhorst-Satzkorn | 39717f946b | 1 year ago |
dependabot[bot] | 4789733d46 | 1 year ago |
Denton Gentry | 1e07536824 | 1 year ago |
Denton Gentry | 0ccb93e115 | 1 year ago |
Will Norris | 566a890843 | 1 year ago |
Will Norris | b68b3f2aeb | 1 year ago |
Denton Gentry | 8d6922285d | 2 years ago |
Denton Gentry | 38061656a5 | 2 years ago |
dependabot[bot] | f4489f4e0c | 2 years ago |
Brad Fitzpatrick | 6b9a11c755 | 2 years ago |
M. J. Fromberger | 049ee22764 | 2 years ago |
Brad Fitzpatrick | 00a42702cb | 2 years ago |
Brad Fitzpatrick | a7b3ae04b0 | 2 years ago |
RoboMagus | 13ecd3e34d | 2 years ago |
Denton Gentry | 0931e9b3ee | 2 years ago |
Denton Gentry | aa32919ac3 | 2 years ago |
Denton Gentry | 2118ca5b38 | 2 years ago |
Denton Gentry | 1a4a088466 | 2 years ago |
Brett Jenkins | eb9599540c | 2 years ago |
Mihai Parparita | 24ba39121f | 2 years ago |
Denton Gentry | c077c1b38a | 2 years ago |
Denton Gentry | a5346dcc26 | 2 years ago |
Mihai Parparita | e7efa7d1c2 | 2 years ago |
Mihai Parparita | 8e4a740d8e | 2 years ago |
Mihai Parparita | 68ecc44a49 | 2 years ago |
Jordan Whited | 44d083aaf1 | 2 years ago |
Gero Gerke | d316acaa3d | 2 years ago |
Jordan Whited | 2470284b31 | 2 years ago |
Denton Gentry | 71f203a493 | 2 years ago |
Denton Gentry | df47a60927 | 2 years ago |
Denton Gentry | 8f512dd7a9 | 2 years ago |
Denton Gentry | d0c45c1de1 | 2 years ago |
Denton Gentry | 6499fb845e | 2 years ago |
Denton Gentry | 814cd3c43a | 2 years ago |
Denton Gentry | 6bbc9032bf | 2 years ago |
Denton Gentry | d53da4ac65 | 2 years ago |
Brett Jenkins | 51a53e5472 | 2 years ago |
dependabot[bot] | 7245e72dcf | 2 years ago |
Denton Gentry | e7ceb58224 | 2 years ago |
Denton Gentry | 2073704cad | 2 years ago |
Denton Gentry | 813b770cdf | 2 years ago |
David Anderson | 52a0509a5d | 2 years ago |
Spencer Comfort | 2a5ced8159 | 2 years ago |
Denton Gentry | 68b6b92eaf | 2 years ago |
Denton Gentry | f139c0221e | 2 years ago |
Denton Gentry | f643488f7a | 2 years ago |
Denton Gentry | a23dbaf58e | 2 years ago |
Denton Gentry | 9562c27766 | 2 years ago |
Denton Gentry | 6f1567bac8 | 2 years ago |
Denton Gentry | 908c634a6a | 2 years ago |
Denton Gentry | 152110204c | 2 years ago |
Denton Gentry | 365b0ce6b0 | 2 years ago |
Denton Gentry | a0f2c883b4 | 2 years ago |
Denton Gentry | 30e46fb854 | 2 years ago |
Denton Gentry | 62cc5fe074 | 2 years ago |
Denton Gentry | 6f6d319048 | 2 years ago |
Denton Gentry | fd874ed58e | 2 years ago |
James Tucker | 73e3a13322 | 2 years ago |
Denton Gentry | 0244fd108d | 2 years ago |
Denton Gentry | 7f11150cb6 | 2 years ago |
Denton Gentry | c5e20b297c | 2 years ago |
Denton Gentry | 3c081e5d10 | 2 years ago |
Denton Gentry | 80b896e71c | 2 years ago |
Denton Gentry | 498e73e392 | 2 years ago |
Denton Gentry | 1181155b7d | 2 years ago |
Denton Gentry | 9ce897ed8f | 2 years ago |
Denton Gentry | bb147bf07c | 2 years ago |
Denton Gentry | 26e72f15ef | 2 years ago |
Denton Gentry | ac5e24a63d | 2 years ago |
Denton Gentry | b6c2536147 | 2 years ago |
Denton Gentry | 80dfbd8a0c | 2 years ago |
Denton Gentry | d46d247535 | 2 years ago |
Denton Gentry | f7c662ca4a | 2 years ago |
Denton Gentry | 185cc3dd8f | 2 years ago |
Denton Gentry | 98b7c9d7e5 | 2 years ago |
Denton Gentry | 1bf8c0b270 | 2 years ago |
Denton Gentry | d050f2742a | 2 years ago |
Denton Gentry | d93c6aa7f3 | 2 years ago |
Denton Gentry | ce550a4225 | 2 years ago |
Denton Gentry | 0d859fb46c | 2 years ago |
Denton Gentry | f27f4567f0 | 2 years ago |
Denton Gentry | 5ed3921ad6 | 2 years ago |
Denton Gentry | 10419aea2d | 2 years ago |
Denton Gentry | 03970952d5 | 2 years ago |
Andrew Dunham | 576b46f8f7 | 2 years ago |
Will Norris | 901c43c3b9 | 2 years ago |
Denton Gentry | 4869bb4666 | 2 years ago |
Denton Gentry | 033f7d87b4 | 2 years ago |
Denton Gentry | 5d209e6122 | 2 years ago |
Denton Gentry | bab2ac0058 | 2 years ago |
Denton Gentry | c0f9eed38e | 2 years ago |
Denton Gentry | d0812b9476 | 2 years ago |
Denton Gentry | 634431055b | 2 years ago |
Denton Gentry | f9a23978a3 | 2 years ago |
Denton Gentry | 4102b6a40a | 2 years ago |
Denton Gentry | 4738308088 | 2 years ago |
Will Norris | 9dee09156d | 2 years ago |
Will Norris | 249cab2bc6 | 2 years ago |
Denton Gentry | bbf31f568f | 2 years ago |
Denton Gentry | 548fbe21ea | 2 years ago |
Denton Gentry | 05cba1ed18 | 2 years ago |
Denton Gentry | 67f94b771b | 2 years ago |
Denton Gentry | e91d0e89d3 | 2 years ago |
Denton Gentry | e49241f40b | 2 years ago |
Denton Gentry | f2e89244a9 | 2 years ago |
Denton Gentry | aafa3432dc | 2 years ago |
Denton Gentry | 9e6ef85d26 | 2 years ago |
Brad Fitzpatrick | b1501eb5d8 | 2 years ago |
Trevor Bergeron | d8aedf721a | 2 years ago |
Denton Gentry | 4f17005954 | 2 years ago |
Brad Fitzpatrick | 140149ef87 | 2 years ago |
Brad Fitzpatrick | f77610bd62 | 2 years ago |
Denton Gentry | f225992d28 | 2 years ago |
Denton Gentry | 2b9b952d27 | 2 years ago |
Denton Gentry | bc9311c1e9 | 2 years ago |
Denton Gentry | dfd3c3d543 | 2 years ago |
Denton Gentry | d1d72859b4 | 2 years ago |
Denton Gentry | 1a0253892b | 2 years ago |
Denton Gentry | 6eca3897f8 | 2 years ago |
Denton Gentry | 07ae8a6dd5 | 2 years ago |
Denton Gentry | 283dd77bcc | 2 years ago |
Denton Gentry | 63dba694af | 2 years ago |
Denton Gentry | db31496a24 | 2 years ago |
Denton Gentry | 42f688f129 | 2 years ago |
Denton Gentry | 4223a68a2d | 2 years ago |
Denton Gentry | 8f551d0320 | 2 years ago |
Denton Gentry | 21ea21f4f0 | 2 years ago |
Denton Gentry | 2597b82c3f | 2 years ago |
Denton Gentry | 1bdfaf88d9 | 2 years ago |
Denton Gentry | b606b0b668 | 2 years ago |
Denton Gentry | 11a027269c | 2 years ago |
Denton Gentry | 8ddaf1ec5c | 2 years ago |
Denton Gentry | 4fa037b636 | 2 years ago |
Denton Gentry | 221300266f | 2 years ago |
Denton Gentry | 0c11377ca1 | 2 years ago |
Denton Gentry | 9de8d8e525 | 3 years ago |
Denton Gentry | 1845f17317 | 3 years ago |
Denton Gentry | 7697c9d300 | 3 years ago |
Denton Gentry | 536c17a3a2 | 3 years ago |
Denton Gentry | e66e57fbb0 | 3 years ago |
Denton Gentry | e73f55f5db | 3 years ago |
Denton Gentry | 40481f5ec6 | 3 years ago |
Denton Gentry | bd5ef3fd68 | 3 years ago |
Elias Naur | 090676bb74 | 3 years ago |
Brad Fitzpatrick | 751bda721c | 3 years ago |
Brad Fitzpatrick | cff9e2a772 | 3 years ago |
Brad Fitzpatrick | 8550365e52 | 3 years ago |
Denton Gentry | d1bff07fbd | 3 years ago |
Denton Gentry | b8af14c009 | 3 years ago |
Denton Gentry | e652d853d6 | 3 years ago |
Denton Gentry | 3f8df48d23 | 3 years ago |
Denton Gentry | 58e85726a4 | 3 years ago |
Denton Gentry | 4ccafba8f7 | 3 years ago |
Denton Gentry | 51fc2e7030 | 3 years ago |
Denton Gentry | cc70ae7aa6 | 3 years ago |
Elias Naur | 0ec9167cd2 | 3 years ago |
Elias Naur | b803576542 | 3 years ago |
Denton Gentry | d8ccc2387f | 3 years ago |
Denton Gentry | b4f8e7f90a | 3 years ago |
Ross Zurowski | c7afe66e9a | 3 years ago |
Ross Zurowski | 052ba2755f | 3 years ago |
Brad Fitzpatrick | 9101d9adc4 | 3 years ago |
Elias Naur | 83bfea18bb | 3 years ago |
Elias Naur | e316f3b1c2 | 3 years ago |
Elias Naur | 7c268dfc4f | 3 years ago |
Aman Karmani | d9c64011f5 | 3 years ago |
Denton Gentry | b0f1428443 | 3 years ago |
Denton Gentry | fd42b4b352 | 3 years ago |
Denton Gentry | b125fbf179 | 3 years ago |
Denton Gentry | 7203980ecc | 3 years ago |
Denton Gentry | 8c94f7975c | 3 years ago |
Denton Gentry | a8f017ddf6 | 3 years ago |
Denton Gentry | 0e5c2ec1ec | 3 years ago |
Denton Gentry | 4a1c0cb2ee | 3 years ago |
Brad Fitzpatrick | 20ea9fd17d | 3 years ago |
Denton Gentry | 69f2fe67dc | 3 years ago |
Denton Gentry | 5868fdb7b0 | 3 years ago |
Denton Gentry | 47f646d0ae | 3 years ago |
Denton Gentry | 7989a1ae2a | 3 years ago |
Denton Gentry | 467ddfc605 | 3 years ago |
Denton Gentry | 61490b1713 | 3 years ago |
Denton Gentry | aa5123b8b4 | 3 years ago |
Denton Gentry | 213009e9af | 3 years ago |
Denton Gentry | 17e6d5f653 | 3 years ago |
Denton Gentry | df73d8a419 | 3 years ago |
Denton Gentry | 6b83c6ae21 | 3 years ago |
Denton Gentry | 3709101d12 | 3 years ago |
Brad Fitzpatrick | e3f7123238 | 3 years ago |
Denton Gentry | 270b8efe97 | 3 years ago |
Denton Gentry | 039124db79 | 3 years ago |
Brad Fitzpatrick | 1be9000a6a | 3 years ago |
Brad Fitzpatrick | 9d801a42d7 | 3 years ago |
Brad Fitzpatrick | ed29f4b3d2 | 3 years ago |
Brad Fitzpatrick | 3b6b9acf2b | 3 years ago |
Brad Fitzpatrick | df09d74486 | 3 years ago |
Brad Fitzpatrick | 19822d0b94 | 3 years ago |
Denton Gentry | 33329425b8 | 3 years ago |
Denton Gentry | 2d49c9ae30 | 3 years ago |
Brad Fitzpatrick | 050f4a41d4 | 3 years ago |
Brad Fitzpatrick | af2c71c6e2 | 3 years ago |
Brad Fitzpatrick | f8176b47a9 | 3 years ago |
Denton Gentry | bbd7b61d44 | 3 years ago |
Denton Gentry | a3d2dc95db | 3 years ago |
Denton Gentry | 7cb3c9a427 | 3 years ago |
Denton Gentry | 79bb2f33d0 | 3 years ago |
Denton Gentry | 0265dcfd1b | 3 years ago |
Denton Gentry | b2665ab2ff | 3 years ago |
Denton Gentry | 665488ff4a | 3 years ago |
Denton Gentry | 184250167b | 3 years ago |
Denton Gentry | f2a104fc5f | 3 years ago |
Denton Gentry | ca696b116c | 3 years ago |
Denton Gentry | 5c9cec0064 | 3 years ago |
Denton Gentry | 0c00fa3374 | 3 years ago |
Elias Naur | 36b09f6b06 | 3 years ago |
Denton Gentry | b6fbbbbd39 | 3 years ago |
Denton Gentry | 729bf9a356 | 3 years ago |
Denton Gentry | 5c93a7a829 | 3 years ago |
Denton Gentry | da175ba221 | 3 years ago |
Denton Gentry | a045ba5ab1 | 3 years ago |
Denton Gentry | db53a314eb | 3 years ago |
Denton Gentry | c98d4dd89c | 3 years ago |
Denton Gentry | 0ecb2a2587 | 3 years ago |
Denton Gentry | 780e7515da | 3 years ago |
Denton Gentry | fe76bef85b | 3 years ago |
Denton Gentry | 61f90a1975 | 3 years ago |
Denton Gentry | f9bbd73413 | 3 years ago |
Denton Gentry | a4a3ae6eff | 3 years ago |
Denton Gentry | 82ea8df1dc | 3 years ago |
Elias Naur | a3152ae505 | 3 years ago |
Denton Gentry | 0a13b89ce0 | 3 years ago |
Denton Gentry | f0dcec6c27 | 3 years ago |
Denton Gentry | 02a6ae0e0d | 3 years ago |
Denton Gentry | 41aa0c1d02 | 3 years ago |
Brad Fitzpatrick | d0b4a09e59 | 3 years ago |
Brad Fitzpatrick | a5bed46c9c | 3 years ago |
Denton Gentry | 6518535039 | 3 years ago |
Denton Gentry | 9b52c6b357 | 3 years ago |
Denton Gentry | 54d511a9b6 | 3 years ago |
Denton Gentry | b8c3f9da9a | 3 years ago |
Denton Gentry | 59aecdb2e5 | 3 years ago |
Denton Gentry | ef96ee30fd | 3 years ago |
Denton Gentry | 0637d599af | 3 years ago |
Denton Gentry | e2128ef6d6 | 3 years ago |
Denton Gentry | 75ef65dd50 | 3 years ago |
Elias Naur | 84b484a954 | 3 years ago |
Elias Naur | f37cf72d81 | 3 years ago |
Elias Naur | db77216ead | 3 years ago |
Denton Gentry | c02078e41e | 3 years ago |
Elias Naur | 078356613f | 3 years ago |
Elias Naur | 54eab1a5d3 | 3 years ago |
Denton Gentry | d3edb004e3 | 3 years ago |
Denton Gentry | 7aa8ae9a47 | 3 years ago |
Denton Gentry | f33b98b313 | 3 years ago |
Denton Gentry | 742da3ae36 | 3 years ago |
Denton Gentry | eb24dedd81 | 3 years ago |
Denton Gentry | 5c524d2768 | 3 years ago |
Denton Gentry | ae2df12032 | 3 years ago |
Elias Naur | 6635d89292 | 3 years ago |
Denton Gentry | 9283506c04 | 3 years ago |
Denton Gentry | 30324736c9 | 3 years ago |
Denton Gentry | d81f8a03b6 | 3 years ago |
Denton Gentry | 76df742662 | 3 years ago |
Denton Gentry | a68462ec65 | 3 years ago |
Denton Gentry | 3575ef712a | 3 years ago |
Denton Gentry | 82b6b8dbd3 | 3 years ago |
Denton Gentry | 0f46117f9c | 3 years ago |
Denton Gentry | d98827df03 | 3 years ago |
Denton Gentry | f25f7ecc80 | 3 years ago |
Denton Gentry | 67c2869ec6 | 3 years ago |
Elias Naur | ebcc878fcb | 3 years ago |
Denton Gentry | 7f086ccaa6 | 3 years ago |
Denton Gentry | f59e53e41d | 3 years ago |
Denton Gentry | ade708af7f | 3 years ago |
Denton Gentry | 9f701283bc | 3 years ago |
Denton Gentry | 078eee6b39 | 3 years ago |
Denton Gentry | bb0494637f | 3 years ago |
Denton Gentry | ea1bb12e9b | 3 years ago |
Denton Gentry | 7b2e61e80c | 3 years ago |
Denton Gentry | 00e45e3795 | 3 years ago |
Denton Gentry | 7ebedfd62a | 3 years ago |
Denton Gentry | 0df2377630 | 3 years ago |
Denton Gentry | 7a00ad639b | 3 years ago |
Denton Gentry | 3cb36c4599 | 3 years ago |
Denton Gentry | 4d32c6da4f | 3 years ago |
Denton Gentry | 2c1f35d560 | 3 years ago |
Denton Gentry | 1c78887bf5 | 3 years ago |
Denton Gentry | 85ed50317d | 3 years ago |
Denton Gentry | 90909797a0 | 3 years ago |
Denton Gentry | 74a18b3359 | 3 years ago |
Denton Gentry | 2a6cd09d7c | 3 years ago |
Denton Gentry | adfcedb097 | 3 years ago |
Elias Naur | 14cdec17f1 | 3 years ago |
Elias Naur | 242c936b2c | 3 years ago |
Elias Naur | 639aebac6a | 3 years ago |
Elias Naur | 10ded1bad2 | 3 years ago |
Elias Naur | 331bc1e30a | 3 years ago |
Elias Naur | e007a9c153 | 3 years ago |
Denton Gentry | 7dedc91a80 | 3 years ago |
Denton Gentry | 594ed6b7bc | 3 years ago |
Elias Naur | e8f2409cb3 | 3 years ago |
Elias Naur | 47b732aaab | 3 years ago |
Elias Naur | 131bf27995 | 3 years ago |
Elias Naur | b57f06455d | 3 years ago |
Elias Naur | d67229dc38 | 3 years ago |
Brad Fitzpatrick | 2f7b27412a | 3 years ago |
Brad Fitzpatrick | b97cc703d8 | 3 years ago |
Brad Fitzpatrick | ac8ec020b8 | 3 years ago |
Brad Fitzpatrick | d66204ecfd | 3 years ago |
Elias Naur | 9e9d69fd95 | 4 years ago |
Denton Gentry | 0489079771 | 4 years ago |
Denton Gentry | 90351e7392 | 4 years ago |
Elias Naur | 20ddae3208 | 4 years ago |
Elias Naur | ff16a75a65 | 4 years ago |
Elias Naur | 9ba4a01a4e | 4 years ago |
Denton Gentry | d90aa446c0 | 4 years ago |
Denton Gentry | ec8133a972 | 4 years ago |
Elias Naur | 3d2abf0b3b | 4 years ago |
Elias Naur | 8ea1d4ced7 | 4 years ago |
Elias Naur | 06e461d703 | 4 years ago |
Elias Naur | 401ed389ef | 4 years ago |
Elias Naur | 05212e770b | 4 years ago |
Elias Naur | 6d9d6786af | 4 years ago |
Elias Naur | db13aa4e92 | 4 years ago |
Elias Naur | 633d81287a | 4 years ago |
Elias Naur | 206f2bb4e7 | 4 years ago |
Elias Naur | e2d731dbba | 4 years ago |
Elias Naur | 085d823920 | 4 years ago |
Elias Naur | 05ddfd5d90 | 4 years ago |
Elias Naur | 3b1a5e7a71 | 4 years ago |
Elias Naur | 71e0f2bd94 | 4 years ago |
Elias Naur | 56362cc61a | 4 years ago |
Elias Naur | b151de039b | 4 years ago |
Elias Naur | dfe7b6c0a2 | 4 years ago |
Elias Naur | d3e0b42093 | 4 years ago |
Elias Naur | ebdbe7c315 | 4 years ago |
Elias Naur | 3e758d0fe2 | 4 years ago |
Elias Naur | 54917ae2f5 | 4 years ago |
Elias Naur | 33cf7c0aa1 | 4 years ago |
Elias Naur | 07b2373e6b | 4 years ago |
Elias Naur | 71a9bd537c | 4 years ago |
Elias Naur | f19c0c057e | 4 years ago |
Sonia Appasamy | c4f626c5a7 | 4 years ago |
Elias Naur | ba38a9bb59 | 4 years ago |
Elias Naur | 61d9733b24 | 4 years ago |
Elias Naur | d3dc208237 | 4 years ago |
Elias Naur | 9525b1c46c | 4 years ago |
Elias Naur | 96e2661764 | 4 years ago |
Elias Naur | 1c93c0f2c7 | 4 years ago |
David Anderson | b1395cfefb | 4 years ago |
Elias Naur | c8114b4474 | 4 years ago |
Elias Naur | c26c3b0a35 | 4 years ago |
Elias Naur | 28e5c33b3b | 4 years ago |
Elias Naur | b981aa576c | 4 years ago |
Elias Naur | 2ed6c7df9a | 4 years ago |
David Anderson | 8daee9c431 | 4 years ago |
David Anderson | e87e87367a | 4 years ago |
Elias Naur | 2c9fddab4f | 4 years ago |
Elias Naur | 1c61cc0702 | 4 years ago |
Elias Naur | de6c243bae | 4 years ago |
Elias Naur | 9db851a113 | 4 years ago |
Elias Naur | 0363b565ee | 4 years ago |
Poussinou | 9c39b7fced | 4 years ago |
Elias Naur | b97970dd8f | 4 years ago |
Elias Naur | 33a953fb21 | 4 years ago |
Elias Naur | 129abdb13f | 4 years ago |
Elias Naur | 21037e6d67 | 4 years ago |
Elias Naur | f2c035a8bf | 4 years ago |
Elias Naur | cedc696c87 | 4 years ago |
Elias Naur | d95d693a73 | 4 years ago |
Elias Naur | 0964bc5a6e | 4 years ago |
Elias Naur | 97a826d118 | 4 years ago |
Elias Naur | 6252433c48 | 4 years ago |
Elias Naur | 7f6ccc9f88 | 4 years ago |
Elias Naur | 99d00e803c | 4 years ago |
Elias Naur | 9c38bebfa9 | 4 years ago |
Elias Naur | 6d9acbb479 | 4 years ago |
Elias Naur | 412fe8ad68 | 4 years ago |
Elias Naur | 39dfd84951 | 4 years ago |
Elias Naur | 2b38d32130 | 4 years ago |
Elias Naur | 3eab35ca80 | 4 years ago |
Elias Naur | 5a581c1a9d | 4 years ago |
Elias Naur | 6aaaa84dcf | 4 years ago |
Elias Naur | cddf7217f3 | 4 years ago |
Elias Naur | 25168130a7 | 4 years ago |
Elias Naur | fd4646a900 | 4 years ago |
Elias Naur | ab29b995b5 | 4 years ago |
Elias Naur | 6eeb9d8ac4 | 4 years ago |
Elias Naur | 233515e86a | 4 years ago |
Elias Naur | 7de19cd9b8 | 4 years ago |
Elias Naur | dfbfd2a3ed | 4 years ago |
Elias Naur | 97b727d8a8 | 4 years ago |
Elias Naur | 679f97afb3 | 4 years ago |
Elias Naur | c1863a42ae | 4 years ago |
Elias Naur | d221e0db42 | 4 years ago |
Elias Naur | b6d6f57261 | 4 years ago |
Elias Naur | 1b402aebb0 | 4 years ago |
Elias Naur | 3089ad8347 | 4 years ago |
Elias Naur | a0a33e92c4 | 4 years ago |
Elias Naur | 39cb01da42 | 4 years ago |
Elias Naur | 3ced33d812 | 4 years ago |
Elias Naur | f25b5bbcba | 4 years ago |
Elias Naur | 1003774193 | 4 years ago |
Elias Naur | 758e5691da | 4 years ago |
Elias Naur | 454c59a4e5 | 4 years ago |
Elias Naur | 943bded910 | 4 years ago |
Elias Naur | 334dff897c | 4 years ago |
Elias Naur | 05fc3ef433 | 4 years ago |
Elias Naur | a2b15127dd | 4 years ago |
Elias Naur | c706699862 | 4 years ago |
Elias Naur | fe465654a2 | 4 years ago |
Elias Naur | a7dfea267c | 4 years ago |
Elias Naur | 7211e6db1b | 4 years ago |
Elias Naur | 9e45538997 | 4 years ago |
Elias Naur | 93afdf1e5d | 4 years ago |
Elias Naur | 91d4d47fd8 | 4 years ago |
Elias Naur | 15632cb15b | 4 years ago |
Elias Naur | 053820acda | 4 years ago |
Elias Naur | 5a018c7209 | 4 years ago |
Elias Naur | 8353a32ed9 | 4 years ago |
Elias Naur | 1a937b5c5f | 4 years ago |
Elias Naur | 1775eaf309 | 4 years ago |
Elias Naur | 6265d84c36 | 4 years ago |
Elias Naur | cbde34f13b | 4 years ago |
Elias Naur | 19ed532519 | 4 years ago |
Elias Naur | 2119f8aa9d | 4 years ago |
Elias Naur | bae9b8394a | 4 years ago |
Elias Naur | df1d8b338b | 4 years ago |
Elias Naur | ad92c8d81f | 4 years ago |
@ -0,0 +1,20 @@
|
||||
{{/*
|
||||
|
||||
This template is used to generate the license notices published at
|
||||
https://github.com/tailscale/tailscale/blob/main/licenses/android.md.
|
||||
Publishing is managed by the go-licenses GitHub Action. Non-Go dependencies
|
||||
should be manually updated at the bottom of this file as needed.
|
||||
|
||||
*/}}# Tailscale for Android dependencies
|
||||
|
||||
The following open source dependencies are used to build the [Tailscale Android
|
||||
Client][]. See also the dependencies in the [Tailscale CLI][].
|
||||
|
||||
[Tailscale Android Client]: https://github.com/tailscale/tailscale-android
|
||||
|
||||
## Go Packages
|
||||
|
||||
{{ range . }}
|
||||
- [{{.Name}}](https://pkg.go.dev/{{.Name}}) ([{{.LicenseName}}]({{.LicenseURL}}))
|
||||
{{- end }}
|
||||
- [tailscale.com](https://pkg.go.dev/tailscale.com) ([BSD-3-Clause](https://github.com/tailscale/tailscale/blob/HEAD/LICENSE))
|
@ -0,0 +1,37 @@
|
||||
name: Android CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Switch to Java 17 # Note: 17 is pre-installed on ubuntu-latest
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
|
||||
# Clean should essentially be a no-op, but make sure that it works.
|
||||
- name: Clean
|
||||
run: make clean
|
||||
|
||||
- name: Build APKs
|
||||
run: make tailscale-debug.apk
|
||||
|
||||
- name: Run tests
|
||||
run: make test
|
@ -0,0 +1,36 @@
|
||||
name: go mod tidy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-branch/*"
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-go-mod-tidy:
|
||||
runs-on: [ubuntu-latest]
|
||||
timeout-minutes: 8
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
cache: false
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Check 'go mod tidy' is clean
|
||||
run: |
|
||||
./tool/go mod tidy
|
||||
echo
|
||||
echo
|
||||
git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go mod tidy'."; exit 1)
|
@ -0,0 +1,19 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
- "release-branch/*"
|
||||
pull_request:
|
||||
# all PRs on all branches
|
||||
merge_group:
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
jobs:
|
||||
license_headers:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: check license headers
|
||||
run: ./scripts/check_license_headers.sh .
|
@ -1,54 +0,0 @@
|
||||
# This is a Dockerfile for creating a build environment for
|
||||
# tailscale-android.
|
||||
|
||||
FROM openjdk:8-jdk
|
||||
|
||||
# To enable running android tools such as aapt
|
||||
RUN apt-get update && apt-get -y upgrade
|
||||
RUN apt-get install -y lib32z1 lib32stdc++6
|
||||
# For Go:
|
||||
RUN apt-get -y --no-install-recommends install curl gcc
|
||||
RUN apt-get -y --no-install-recommends install ca-certificates libc6-dev git
|
||||
|
||||
RUN apt-get -y install make
|
||||
|
||||
RUN mkdir -p BUILD
|
||||
ENV HOME /build
|
||||
|
||||
# Get android sdk, ndk, and rest of the stuff needed to build the android app.
|
||||
WORKDIR $HOME
|
||||
RUN mkdir android-sdk
|
||||
ENV ANDROID_HOME $HOME/android-sdk
|
||||
WORKDIR $ANDROID_HOME
|
||||
RUN curl -O https://dl.google.com/android/repository/sdk-tools-linux-3859397.zip
|
||||
RUN echo '444e22ce8ca0f67353bda4b85175ed3731cae3ffa695ca18119cbacef1c1bea0 sdk-tools-linux-3859397.zip' | sha256sum -c
|
||||
RUN unzip sdk-tools-linux-3859397.zip
|
||||
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager --update
|
||||
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'platforms;android-29'
|
||||
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'extras;android;m2repository'
|
||||
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'ndk;20.0.5594570'
|
||||
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'platform-tools'
|
||||
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'build-tools;28.0.3'
|
||||
|
||||
# Get Go stable release
|
||||
WORKDIR $HOME
|
||||
ARG GO_VERSION=1.14.3
|
||||
RUN curl -O https://storage.googleapis.com/golang/go${GO_VERSION}.linux-amd64.tar.gz
|
||||
RUN echo "1c39eac4ae95781b066c144c58e45d6859652247f7515f0d2cba7be7d57d2226 go${GO_VERSION}.linux-amd64.tar.gz" | sha256sum -c
|
||||
RUN tar -xzf go${GO_VERSION}.linux-amd64.tar.gz && mv go goroot
|
||||
ENV GOROOT $HOME/goroot
|
||||
ENV PATH $PATH:$GOROOT/bin:$HOME/bin:$ANDROID_HOME/platform-tools
|
||||
|
||||
RUN mkdir -p $HOME/tailscale-android
|
||||
WORKDIR $HOME/tailscale-android
|
||||
|
||||
ADD go.mod .
|
||||
ADD go.sum .
|
||||
RUN go mod download
|
||||
|
||||
# Preload Gradle
|
||||
COPY android/gradlew android/gradlew
|
||||
COPY android/gradle android/gradle
|
||||
RUN ./android/gradlew
|
||||
|
||||
CMD /bin/bash
|
@ -1,43 +1,195 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = "1.9.22"
|
||||
ext.compose_version = "1.5.10"
|
||||
ext.accompanist_version = "0.34.0"
|
||||
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
mavenCentral()
|
||||
maven {
|
||||
url = uri("https://plugins.gradle.org/m2/")
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:3.6.0'
|
||||
classpath 'com.android.tools.build:gradle:8.6.1'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
|
||||
classpath("com.ncorti.ktfmt.gradle:plugin:0.17.0")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
flatDir {
|
||||
dirs 'libs'
|
||||
}
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
flatDir {
|
||||
dirs 'libs'
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'org.jetbrains.kotlin.plugin.serialization'
|
||||
apply plugin: 'com.ncorti.ktfmt.gradle'
|
||||
|
||||
android {
|
||||
compileSdkVersion 29
|
||||
ndkVersion "23.1.7779620"
|
||||
compileSdkVersion 34
|
||||
defaultConfig {
|
||||
minSdkVersion 23
|
||||
targetSdkVersion 29
|
||||
versionCode 12
|
||||
versionName System.getenv("VERSION")
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility 1.8
|
||||
targetCompatibility 1.8
|
||||
}
|
||||
minSdkVersion 26
|
||||
targetSdkVersion 34
|
||||
versionCode 242
|
||||
versionName getVersionProperty("VERSION_LONG")
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
// This setting, which defaults to 'true', will cause Tailscale to fall
|
||||
// back to the Google DNS servers if it cannot determine what the
|
||||
// operating system's DNS configuration is.
|
||||
//
|
||||
// Set it to false either here or in your local.properties file to
|
||||
// disable this behaviour.
|
||||
buildConfigField "boolean", "USE_GOOGLE_DNS_FALLBACK", getLocalProperty("tailscale.useGoogleDnsFallback", "true")
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
warningsAsErrors true
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig true
|
||||
compose true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "$compose_version"
|
||||
}
|
||||
flavorDimensions "version"
|
||||
namespace 'com.tailscale.ipn'
|
||||
|
||||
buildTypes {
|
||||
applicationTest {
|
||||
initWith debug
|
||||
manifestPlaceholders.leanbackRequired = false
|
||||
buildConfigField "String", "GITHUB_USERNAME", "\"" + getLocalProperty("githubUsername", "")+"\""
|
||||
buildConfigField "String", "GITHUB_PASSWORD", "\"" + getLocalProperty("githubPassword", "")+"\""
|
||||
buildConfigField "String", "GITHUB_2FA_SECRET", "\"" + getLocalProperty("github2FASecret", "")+"\""
|
||||
}
|
||||
debug {
|
||||
manifestPlaceholders.leanbackRequired = false
|
||||
}
|
||||
release {
|
||||
manifestPlaceholders.leanbackRequired = false
|
||||
|
||||
minifyEnabled true
|
||||
|
||||
shrinkResources true
|
||||
|
||||
proguardFiles getDefaultProguardFile(
|
||||
'proguard-android-optimize.txt'),
|
||||
'proguard-rules.pro'
|
||||
}
|
||||
release_tv {
|
||||
initWith release
|
||||
manifestPlaceholders.leanbackRequired = true
|
||||
}
|
||||
}
|
||||
|
||||
testBuildType "applicationTest"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.google.android.gms:play-services-auth:18.0.0'
|
||||
implementation "androidx.core:core:1.2.0"
|
||||
implementation "androidx.browser:browser:1.2.0"
|
||||
implementation "androidx.security:security-crypto:1.0.0-rc01"
|
||||
implementation ':ipn@aar'
|
||||
// Android dependencies.
|
||||
implementation "androidx.core:core:1.13.1"
|
||||
implementation 'androidx.core:core-ktx:1.13.1'
|
||||
implementation "androidx.browser:browser:1.8.0"
|
||||
implementation "androidx.security:security-crypto:1.1.0-alpha06"
|
||||
implementation "androidx.work:work-runtime:2.9.1"
|
||||
|
||||
// Kotlin dependencies.
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1"
|
||||
implementation 'junit:junit:4.13.2'
|
||||
runtimeOnly "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
|
||||
|
||||
// Compose dependencies.
|
||||
def composeBom = platform('androidx.compose:compose-bom:2024.09.03')
|
||||
implementation composeBom
|
||||
implementation 'androidx.compose.material3:material3:1.3.0'
|
||||
implementation 'androidx.compose.material:material-icons-core:1.7.3'
|
||||
implementation "androidx.compose.ui:ui:1.7.3"
|
||||
implementation "androidx.compose.ui:ui-tooling:1.7.3"
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6'
|
||||
implementation 'androidx.activity:activity-compose:1.9.2'
|
||||
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
|
||||
implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version"
|
||||
implementation "androidx.core:core-splashscreen:1.1.0-rc01"
|
||||
implementation "androidx.compose.animation:animation:1.7.4"
|
||||
|
||||
// Navigation dependencies.
|
||||
def nav_version = "2.8.2"
|
||||
implementation "androidx.navigation:navigation-compose:$nav_version"
|
||||
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
|
||||
|
||||
// Supporting libraries.
|
||||
implementation("io.coil-kt:coil-compose:2.6.0")
|
||||
implementation("com.google.zxing:core:3.5.1")
|
||||
implementation("com.patrykandpatrick.vico:compose:1.15.0")
|
||||
implementation("com.patrykandpatrick.vico:compose-m3:1.15.0")
|
||||
|
||||
// Tailscale dependencies.
|
||||
implementation ':libtailscale@aar'
|
||||
|
||||
// Integration Tests
|
||||
androidTestImplementation composeBom
|
||||
androidTestImplementation 'androidx.test:runner:1.6.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
|
||||
implementation 'androidx.test.uiautomator:uiautomator:2.3.0'
|
||||
|
||||
|
||||
// Authentication only for tests
|
||||
androidTestImplementation 'dev.turingcomplete:kotlin-onetimepassword:2.4.0'
|
||||
androidTestImplementation 'commons-codec:commons-codec:1.16.1'
|
||||
|
||||
// Unit Tests
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.mockito:mockito-core:5.12.0'
|
||||
testImplementation 'org.mockito:mockito-inline:5.2.0'
|
||||
testImplementation 'org.mockito.kotlin:mockito-kotlin:5.4.0'
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
}
|
||||
|
||||
def getLocalProperty(key, defaultValue) {
|
||||
try {
|
||||
Properties properties = new Properties()
|
||||
properties.load(project.file('local.properties').newDataInputStream())
|
||||
return properties.getProperty(key) ?: defaultValue
|
||||
} catch(Throwable ignored) {
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def getVersionProperty(key) {
|
||||
// tailscale.version is created / updated by the makefile, it is in a loosely
|
||||
// Makfile/envfile format, which is also loosely a properties file format.
|
||||
// make tailscale.version
|
||||
def versionProps = new Properties()
|
||||
versionProps.load(project.file('../tailscale.version').newDataInputStream())
|
||||
return versionProps.getProperty(key).replaceAll('^\"|\"$', '')
|
||||
}
|
||||
|
@ -1 +1,5 @@
|
||||
android.defaults.buildfeatures.buildconfig=true
|
||||
android.nonFinalResIds=false
|
||||
android.nonTransitiveRClass=true
|
||||
android.useAndroidX=true
|
||||
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m
|
||||
|
@ -1,5 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.3-bin.zip
|
||||
distributionSha256Sum=544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
@ -0,0 +1,25 @@
|
||||
# Keep all classes with native methods
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# Keep the classes with syspolicy MDM keys, some of which
|
||||
# get used only by the Go backend.
|
||||
-keep class com.tailscale.ipn.mdm.** { *; }
|
||||
|
||||
# Keep specific classes from Tink library
|
||||
-keep class com.google.crypto.tink.** { *; }
|
||||
|
||||
# Ignore warnings about missing Error Prone annotations
|
||||
-dontwarn com.google.errorprone.annotations.**
|
||||
|
||||
# Keep Error Prone annotations if referenced
|
||||
-keep class com.google.errorprone.annotations.** { *; }
|
||||
|
||||
# Keep Google HTTP Client classes
|
||||
-keep class com.google.api.client.http.** { *; }
|
||||
-dontwarn com.google.api.client.http.**
|
||||
|
||||
# Keep Joda-Time classes
|
||||
-keep class org.joda.time.** { *; }
|
||||
-dontwarn org.joda.time.**
|
@ -0,0 +1,163 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn
|
||||
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import androidx.test.ext.junit.rules.activityScenarioRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.LargeTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.uiautomator.By
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
import androidx.test.uiautomator.UiSelector
|
||||
import dev.turingcomplete.kotlinonetimepassword.HmacAlgorithm
|
||||
import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordConfig
|
||||
import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordGenerator
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import org.apache.commons.codec.binary.Base32
|
||||
import org.junit.After
|
||||
import org.junit.Assert
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@LargeTest
|
||||
class MainActivityTest {
|
||||
companion object {
|
||||
const val TAG = "MainActivityTest"
|
||||
}
|
||||
|
||||
@get:Rule val activityRule = activityScenarioRule<MainActivity>()
|
||||
|
||||
@Before fun setUp() {}
|
||||
|
||||
@After fun tearDown() {}
|
||||
|
||||
/**
|
||||
* This test starts with a clean install, logs the user in to a tailnet using credentials provided
|
||||
* through a build config, and then makes sure we can hit https://hello.ts.net.
|
||||
*/
|
||||
@Test
|
||||
fun loginAndVisitHello() {
|
||||
val githubUsername = BuildConfig.GITHUB_USERNAME
|
||||
val githubPassword = BuildConfig.GITHUB_PASSWORD
|
||||
val github2FASecret = Base32().decode(BuildConfig.GITHUB_2FA_SECRET)
|
||||
val config =
|
||||
TimeBasedOneTimePasswordConfig(
|
||||
codeDigits = 6,
|
||||
hmacAlgorithm = HmacAlgorithm.SHA1,
|
||||
timeStep = 30,
|
||||
timeStepUnit = TimeUnit.SECONDS)
|
||||
val githubTOTP = TimeBasedOneTimePasswordGenerator(github2FASecret, config)
|
||||
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
|
||||
|
||||
Log.d(TAG, "Click through Get Started screen")
|
||||
device.find(By.text("Get Started"))
|
||||
device.find(By.text("Get Started")).click()
|
||||
|
||||
Log.d(TAG, "Wait for VPN permission prompt and accept")
|
||||
device.find(By.text("Connection request"))
|
||||
device.find(By.text("OK")).click()
|
||||
|
||||
asNecessary(
|
||||
2.minutes,
|
||||
{
|
||||
Log.d(TAG, "Log in")
|
||||
device.find(By.text("Log in")).click()
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Accept Chrome terms and conditions (if necessary)")
|
||||
device.find(By.text("Welcome to Chrome"))
|
||||
val dismissIndex =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) 1 else 0
|
||||
device.find(UiSelector().instance(dismissIndex).className(Button::class.java)).click()
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Don't turn on sync")
|
||||
device.find(By.text("Turn on sync?"))
|
||||
device.find(By.text("No thanks")).click()
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Log in with GitHub")
|
||||
device.find(By.text("Sign in with GitHub")).click()
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Make sure GitHub page has loaded")
|
||||
device.find(By.text("Username or email address"))
|
||||
device.find(By.text("Sign in"))
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Enter credentials")
|
||||
device
|
||||
.find(UiSelector().instance(0).className(EditText::class.java))
|
||||
.setText(githubUsername)
|
||||
device
|
||||
.find(UiSelector().instance(1).className(EditText::class.java))
|
||||
.setText(githubPassword)
|
||||
device.find(By.text("Sign in")).click()
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Enter 2FA")
|
||||
device.find(By.text("Two-factor authentication"))
|
||||
device
|
||||
.find(UiSelector().instance(0).className(EditText::class.java))
|
||||
.setText(githubTOTP.generate())
|
||||
device.find(UiSelector().instance(0).className(Button::class.java)).click()
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Authorizing Tailscale")
|
||||
device.find(By.text("Authorize tailscale")).click()
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Accept Tailscale app")
|
||||
device.find(By.text("Learn more about OAuth"))
|
||||
// Sleep a little to give button time to activate
|
||||
|
||||
Thread.sleep(5.seconds.inWholeMilliseconds)
|
||||
device.find(UiSelector().instance(1).className(Button::class.java)).click()
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Connect device")
|
||||
device.find(By.text("Connect device"))
|
||||
device.find(UiSelector().instance(0).className(Button::class.java)).click()
|
||||
})
|
||||
|
||||
try {
|
||||
Log.d(TAG, "Accept Permission (Either Storage or Notifications)")
|
||||
device.find(By.text("Continue")).click()
|
||||
device.find(By.text("Allow")).click()
|
||||
} catch (t: Throwable) {
|
||||
// we're not always prompted for permissions, that's okay
|
||||
}
|
||||
|
||||
Log.d(TAG, "Wait for VPN to connect")
|
||||
device.find(By.text("Connected"))
|
||||
|
||||
val helloResponse = helloTSNet
|
||||
Assert.assertTrue(
|
||||
"Response from hello.ts.net should show success",
|
||||
helloResponse.contains("You're connected over Tailscale!"))
|
||||
}
|
||||
}
|
||||
|
||||
private val helloTSNet: String
|
||||
get() {
|
||||
return URL("https://hello.ts.net").run {
|
||||
openConnection().run {
|
||||
this as HttpURLConnection
|
||||
connectTimeout = 30000
|
||||
readTimeout = 5000
|
||||
inputStream.bufferedReader().readText()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn
|
||||
|
||||
import android.util.Log
|
||||
import androidx.test.uiautomator.BySelector
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
import androidx.test.uiautomator.UiObject
|
||||
import androidx.test.uiautomator.UiObject2
|
||||
import androidx.test.uiautomator.UiSelector
|
||||
import androidx.test.uiautomator.Until
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
private val defaultTimeout = 10.seconds
|
||||
|
||||
private val threadLocalTimeout = ThreadLocal<Duration>()
|
||||
|
||||
/**
|
||||
* Wait until the specified timeout for the given selector and return the matching UiObject2.
|
||||
* Timeout defaults to 10 seconds.
|
||||
*
|
||||
* @throws Exception if selector is not found within timeout.
|
||||
*/
|
||||
fun UiDevice.find(
|
||||
selector: BySelector,
|
||||
timeout: Duration = threadLocalTimeout.get() ?: defaultTimeout
|
||||
): UiObject2 {
|
||||
wait(Until.findObject(selector), timeout.inWholeMilliseconds)?.let {
|
||||
return it
|
||||
} ?: run { throw Exception("not found") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the specified timeout for the given selector and return the matching UiObject. Timeout
|
||||
* defaults to 10 seconds.
|
||||
*
|
||||
* @throws Exception if selector is not found within timeout.
|
||||
*/
|
||||
fun UiDevice.find(
|
||||
selector: UiSelector,
|
||||
timeout: Duration = threadLocalTimeout.get() ?: defaultTimeout
|
||||
): UiObject {
|
||||
val obj = findObject(selector)
|
||||
if (!obj.waitForExists(timeout.inWholeMilliseconds)) {
|
||||
throw Exception("not found")
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an ordered collection of steps as necessary. If an earlier step fails but a subsequent
|
||||
* step succeeds, this skips the earlier step. This is useful for interruptible sequences like
|
||||
* logging in that may resume in an intermediate state.
|
||||
*/
|
||||
fun asNecessary(timeout: Duration, vararg steps: () -> Unit) {
|
||||
val interval = 250.milliseconds
|
||||
// Use a short timeout to avoid waiting on actions that can be skipped
|
||||
threadLocalTimeout.set(interval)
|
||||
try {
|
||||
val start = System.currentTimeMillis()
|
||||
var furthestSuccessful = -1
|
||||
while (System.currentTimeMillis() - start < timeout.inWholeMilliseconds) {
|
||||
for (i in furthestSuccessful + 1 ..< steps.size) {
|
||||
val step = steps[i]
|
||||
try {
|
||||
step()
|
||||
furthestSuccessful = i
|
||||
Log.d("TestUtil.asNecessary", "SUCCESS!")
|
||||
// Going forward, use the normal timeout on the assumption that subsequent steps will
|
||||
// succeed.
|
||||
threadLocalTimeout.remove()
|
||||
} catch (t: Throwable) {
|
||||
Log.d("TestUtil.asNecessary", t.toString())
|
||||
// Going forward, use a short timeout to avoid waiting on actions that can be skipped
|
||||
threadLocalTimeout.set(interval)
|
||||
}
|
||||
}
|
||||
if (furthestSuccessful == steps.size - 1) {
|
||||
// All steps have completed successfully
|
||||
return
|
||||
}
|
||||
// Still some steps left to run
|
||||
Thread.sleep(interval.inWholeMilliseconds)
|
||||
}
|
||||
throw Exception("failed to complete within timeout")
|
||||
} finally {
|
||||
threadLocalTimeout.remove()
|
||||
}
|
||||
}
|
@ -1,31 +1,126 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.tailscale.ipn">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
|
||||
<!-- Disable input emulation on ChromeOS -->
|
||||
<uses-feature android:name="android.hardware.type.pc" android:required="false"/>
|
||||
|
||||
<application android:label="Tailscale" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:name=".App">
|
||||
<activity android:name="org.gioui.GioActivity"
|
||||
android:label="Tailscale"
|
||||
android:theme="@style/Theme.GioApp"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service android:name=".IPNService"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
|
||||
<!-- Disable input emulation on ChromeOS -->
|
||||
<uses-feature
|
||||
android:name="android.hardware.type.pc"
|
||||
android:required="false" />
|
||||
|
||||
<!-- Signal support for Android TV -->
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="${leanbackRequired}" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="false"
|
||||
android:banner="@drawable/tv_banner"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="Tailscale"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:theme="@style/Theme.App.SplashScreen">
|
||||
<activity
|
||||
android:name="MainActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="ShareActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.MaterialComponents.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:mimeType="application/*" />
|
||||
<data android:mimeType="audio/*" />
|
||||
<data android:mimeType="image/*" />
|
||||
<data android:mimeType="message/*" />
|
||||
<data android:mimeType="multipart/*" />
|
||||
<data android:mimeType="text/*" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:mimeType="application/*" />
|
||||
<data android:mimeType="audio/*" />
|
||||
<data android:mimeType="image/*" />
|
||||
<data android:mimeType="message/*" />
|
||||
<data android:mimeType="multipart/*" />
|
||||
<data android:mimeType="text/*" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name="IPNReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="com.tailscale.ipn.CONNECT_VPN" />
|
||||
<action android:name="com.tailscale.ipn.DISCONNECT_VPN" />
|
||||
<action android:name="com.tailscale.ipn.USE_EXIT_NODE" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:name=".IPNService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="systemExempted"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<service
|
||||
android:name=".QuickToggleService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_tile"
|
||||
android:label="@string/tile_name"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<meta-data
|
||||
android:name="android.content.APP_RESTRICTIONS"
|
||||
android:resource="@xml/app_restrictions" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
@ -1,132 +0,0 @@
|
||||
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package com.tailscale.ipn;
|
||||
|
||||
import android.app.Application;
|
||||
import android.app.Activity;
|
||||
import android.app.Fragment;
|
||||
import android.app.FragmentTransaction;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.provider.Settings;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.view.View;
|
||||
import android.os.Build;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
|
||||
import java.security.GeneralSecurityException;
|
||||
|
||||
import androidx.security.crypto.EncryptedSharedPreferences;
|
||||
import androidx.security.crypto.MasterKeys;
|
||||
|
||||
import org.gioui.Gio;
|
||||
|
||||
public class App extends Application {
|
||||
@Override public void onCreate() {
|
||||
super.onCreate();
|
||||
// Load and initialize the Go library.
|
||||
Gio.init(this);
|
||||
registerNetworkCallback();
|
||||
}
|
||||
|
||||
private void registerNetworkCallback() {
|
||||
BroadcastReceiver connectivityChanged = new BroadcastReceiver() {
|
||||
@Override public void onReceive(Context ctx, Intent intent) {
|
||||
boolean noconn = intent.getBooleanExtra(ConnectivityManager.EXTRA_NO_CONNECTIVITY, false);
|
||||
onConnectivityChanged(!noconn);
|
||||
}
|
||||
};
|
||||
registerReceiver(connectivityChanged, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION));
|
||||
}
|
||||
|
||||
public void startVPN() {
|
||||
Intent intent = new Intent(this, IPNService.class);
|
||||
intent.setAction(IPNService.ACTION_CONNECT);
|
||||
startService(intent);
|
||||
}
|
||||
|
||||
public void stopVPN() {
|
||||
Intent intent = new Intent(this, IPNService.class);
|
||||
intent.setAction(IPNService.ACTION_DISCONNECT);
|
||||
startService(intent);
|
||||
}
|
||||
|
||||
// encryptToPref a byte array of data using the Jetpack Security
|
||||
// library and writes it to a global encrypted preference store.
|
||||
public void encryptToPref(String prefKey, String plaintext) throws IOException, GeneralSecurityException {
|
||||
getEncryptedPrefs().edit().putString(prefKey, plaintext).commit();
|
||||
}
|
||||
|
||||
// decryptFromPref decrypts a encrypted preference using the Jetpack Security
|
||||
// library and returns the plaintext.
|
||||
public String decryptFromPref(String prefKey) throws IOException, GeneralSecurityException {
|
||||
return getEncryptedPrefs().getString(prefKey, null);
|
||||
}
|
||||
|
||||
private SharedPreferences getEncryptedPrefs() throws IOException, GeneralSecurityException {
|
||||
String masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC);
|
||||
|
||||
return EncryptedSharedPreferences.create(
|
||||
"secret_shared_prefs",
|
||||
masterKeyAlias,
|
||||
this,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
);
|
||||
}
|
||||
|
||||
String getHostname() {
|
||||
String userConfiguredDeviceName = getUserConfiguredDeviceName();
|
||||
if (!isEmpty(userConfiguredDeviceName)) return userConfiguredDeviceName;
|
||||
|
||||
return getModelName();
|
||||
}
|
||||
|
||||
private String getModelName() {
|
||||
String manu = Build.MANUFACTURER;
|
||||
String model = Build.MODEL;
|
||||
// Strip manufacturer from model.
|
||||
int idx = model.toLowerCase().indexOf(manu.toLowerCase());
|
||||
if (idx != -1) {
|
||||
model = model.substring(idx + manu.length());
|
||||
model = model.trim();
|
||||
}
|
||||
return manu + " " + model;
|
||||
}
|
||||
|
||||
// get user defined nickname from Settings
|
||||
// returns null if not available
|
||||
private String getUserConfiguredDeviceName() {
|
||||
String nameFromSystemBluetooth = Settings.System.getString(getContentResolver(), "bluetooth_name");
|
||||
String nameFromSecureBluetooth = Settings.Secure.getString(getContentResolver(), "bluetooth_name");
|
||||
String nameFromSystemDevice = Settings.Secure.getString(getContentResolver(), "device_name");
|
||||
|
||||
if (!isEmpty(nameFromSystemBluetooth)) return nameFromSystemBluetooth;
|
||||
if (!isEmpty(nameFromSecureBluetooth)) return nameFromSecureBluetooth;
|
||||
if (!isEmpty(nameFromSystemDevice)) return nameFromSystemDevice;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isEmpty(String str) {
|
||||
return str == null || str.length() == 0;
|
||||
}
|
||||
|
||||
// Tracklifecycle adds a Peer fragment for tracking the Activity
|
||||
// lifecycle.
|
||||
static void trackLifecycle(View view) {
|
||||
Activity act = (Activity)view.getContext();
|
||||
FragmentTransaction ft = act.getFragmentManager().beginTransaction();
|
||||
ft.add(new Peer(), "Peer");
|
||||
ft.commitNow();
|
||||
}
|
||||
|
||||
private static native void onConnectivityChanged(boolean connected);
|
||||
}
|
@ -0,0 +1,587 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
package com.tailscale.ipn
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Application
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.tailscale.ipn.mdm.MDMSettings
|
||||
import com.tailscale.ipn.ui.localapi.Client
|
||||
import com.tailscale.ipn.ui.localapi.Request
|
||||
import com.tailscale.ipn.ui.model.Ipn
|
||||
import com.tailscale.ipn.ui.notifier.HealthNotifier
|
||||
import com.tailscale.ipn.ui.notifier.Notifier
|
||||
import com.tailscale.ipn.ui.viewModel.VpnViewModel
|
||||
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
|
||||
import com.tailscale.ipn.util.TSLog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import libtailscale.Libtailscale
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.NetworkInterface
|
||||
import java.security.GeneralSecurityException
|
||||
import java.util.Locale
|
||||
|
||||
class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
|
||||
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
companion object {
|
||||
private const val FILE_CHANNEL_ID = "tailscale-files"
|
||||
private const val TAG = "App"
|
||||
private lateinit var appInstance: App
|
||||
|
||||
/**
|
||||
* Initializes the app (if necessary) and returns the singleton app instance. Always use this
|
||||
* function to obtain an App reference to make sure the app initializes.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun get(): App {
|
||||
appInstance.initOnce()
|
||||
return appInstance
|
||||
}
|
||||
}
|
||||
|
||||
val dns = DnsConfig()
|
||||
private lateinit var connectivityManager: ConnectivityManager
|
||||
private lateinit var app: libtailscale.Application
|
||||
|
||||
override val viewModelStore: ViewModelStore
|
||||
get() = appViewModelStore
|
||||
|
||||
private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() }
|
||||
|
||||
var healthNotifier: HealthNotifier? = null
|
||||
|
||||
override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString
|
||||
|
||||
override fun getInstallSource(): String = AppSourceChecker.getInstallSource(this)
|
||||
|
||||
override fun shouldUseGoogleDNSFallback(): Boolean = BuildConfig.USE_GOOGLE_DNS_FALLBACK
|
||||
|
||||
override fun log(s: String, s1: String) {
|
||||
Log.d(s, s1)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel(
|
||||
STATUS_CHANNEL_ID,
|
||||
getString(R.string.vpn_status),
|
||||
getString(R.string.optional_notifications_which_display_the_status_of_the_vpn_tunnel),
|
||||
NotificationManagerCompat.IMPORTANCE_MIN)
|
||||
createNotificationChannel(
|
||||
FILE_CHANNEL_ID,
|
||||
getString(R.string.taildrop_file_transfers),
|
||||
getString(R.string.notifications_delivered_when_a_file_is_received_using_taildrop),
|
||||
NotificationManagerCompat.IMPORTANCE_DEFAULT)
|
||||
createNotificationChannel(
|
||||
HealthNotifier.HEALTH_CHANNEL_ID,
|
||||
getString(R.string.health_channel_name),
|
||||
getString(R.string.health_channel_description),
|
||||
NotificationManagerCompat.IMPORTANCE_HIGH)
|
||||
appInstance = this
|
||||
setUnprotectedInstance(this)
|
||||
}
|
||||
|
||||
override fun onTerminate() {
|
||||
super.onTerminate()
|
||||
Notifier.stop()
|
||||
notificationManager.cancelAll()
|
||||
applicationScope.cancel()
|
||||
viewModelStore.clear()
|
||||
}
|
||||
|
||||
private var isInitialized = false
|
||||
|
||||
@Synchronized
|
||||
private fun initOnce() {
|
||||
if (isInitialized) {
|
||||
return
|
||||
}
|
||||
isInitialized = true
|
||||
|
||||
val dataDir = this.filesDir.absolutePath
|
||||
|
||||
// Set this to enable direct mode for taildrop whereby downloads will be saved directly
|
||||
// to the given folder. We will preferentially use <shared>/Downloads and fallback to
|
||||
// an app local directory "Taildrop" if we cannot create that. This mode does not support
|
||||
// user notifications for incoming files.
|
||||
val directFileDir = this.prepareDownloadsFolder()
|
||||
app = Libtailscale.start(dataDir, directFileDir.absolutePath, this)
|
||||
Request.setApp(app)
|
||||
Notifier.setApp(app)
|
||||
Notifier.start(applicationScope)
|
||||
healthNotifier = HealthNotifier(Notifier.health, applicationScope)
|
||||
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
|
||||
initViewModels()
|
||||
applicationScope.launch {
|
||||
Notifier.state.collect { state ->
|
||||
combine(Notifier.state, MDMSettings.forceEnabled.flow) { state, forceEnabled ->
|
||||
Pair(state, forceEnabled)
|
||||
}
|
||||
.collect { (state, hideDisconnectAction) ->
|
||||
val ableToStartVPN = state > Ipn.State.NeedsMachineAuth
|
||||
// If VPN is stopped, show a disconnected notification. If it is running as a
|
||||
// foreground
|
||||
// service, IPNService will show a connected notification.
|
||||
if (state == Ipn.State.Stopped) {
|
||||
notifyStatus(vpnRunning = false, hideDisconnectAction = hideDisconnectAction.value)
|
||||
}
|
||||
|
||||
val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running
|
||||
updateConnStatus(ableToStartVPN)
|
||||
QuickToggleService.setVPNRunning(vpnRunning)
|
||||
|
||||
// Update notification status when VPN is running
|
||||
if (vpnRunning) {
|
||||
notifyStatus(vpnRunning = true, hideDisconnectAction = hideDisconnectAction.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
applicationScope.launch {
|
||||
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initViewModels() {
|
||||
vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java)
|
||||
}
|
||||
|
||||
fun setWantRunning(wantRunning: Boolean, onSuccess: (() -> Unit)? = null) {
|
||||
val callback: (Result<Ipn.Prefs>) -> Unit = { result ->
|
||||
result.fold(
|
||||
onSuccess = { onSuccess?.invoke() },
|
||||
onFailure = { error ->
|
||||
TSLog.d("TAG", "Set want running: failed to update preferences: ${error.message}")
|
||||
})
|
||||
}
|
||||
Client(applicationScope)
|
||||
.editPrefs(Ipn.MaskedPrefs().apply { WantRunning = wantRunning }, callback)
|
||||
}
|
||||
|
||||
// encryptToPref a byte array of data using the Jetpack Security
|
||||
// library and writes it to a global encrypted preference store.
|
||||
@Throws(IOException::class, GeneralSecurityException::class)
|
||||
override fun encryptToPref(prefKey: String?, plaintext: String?) {
|
||||
getEncryptedPrefs().edit().putString(prefKey, plaintext).commit()
|
||||
}
|
||||
|
||||
// decryptFromPref decrypts a encrypted preference using the Jetpack Security
|
||||
// library and returns the plaintext.
|
||||
@Throws(IOException::class, GeneralSecurityException::class)
|
||||
override fun decryptFromPref(prefKey: String?): String? {
|
||||
return getEncryptedPrefs().getString(prefKey, null)
|
||||
}
|
||||
|
||||
@Throws(IOException::class, GeneralSecurityException::class)
|
||||
fun getEncryptedPrefs(): SharedPreferences {
|
||||
val key = MasterKey.Builder(this).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
|
||||
|
||||
return EncryptedSharedPreferences.create(
|
||||
this,
|
||||
"secret_shared_prefs",
|
||||
key,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
|
||||
}
|
||||
|
||||
/*
|
||||
* setAbleToStartVPN remembers whether or not we're able to start the VPN
|
||||
* by storing this in a shared preference. This allows us to check this
|
||||
* value without needing a fully initialized instance of the application.
|
||||
*/
|
||||
private fun updateConnStatus(ableToStartVPN: Boolean) {
|
||||
setAbleToStartVPN(ableToStartVPN)
|
||||
QuickToggleService.updateTile()
|
||||
TSLog.d("App", "Set Tile Ready: $ableToStartVPN")
|
||||
}
|
||||
|
||||
override fun getModelName(): String {
|
||||
val manu = Build.MANUFACTURER
|
||||
var model = Build.MODEL
|
||||
// Strip manufacturer from model.
|
||||
val idx = model.lowercase(Locale.getDefault()).indexOf(manu.lowercase(Locale.getDefault()))
|
||||
if (idx != -1) {
|
||||
model = model.substring(idx + manu.length).trim()
|
||||
}
|
||||
return "$manu $model"
|
||||
}
|
||||
|
||||
override fun getOSVersion(): String = Build.VERSION.RELEASE
|
||||
|
||||
override fun isChromeOS(): Boolean {
|
||||
return packageManager.hasSystemFeature("android.hardware.type.pc")
|
||||
}
|
||||
|
||||
override fun getInterfacesAsString(): String {
|
||||
val interfaces: ArrayList<NetworkInterface> =
|
||||
java.util.Collections.list(NetworkInterface.getNetworkInterfaces())
|
||||
|
||||
val sb = StringBuilder()
|
||||
for (nif in interfaces) {
|
||||
try {
|
||||
sb.append(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"%s %d %d %b %b %b %b %b |",
|
||||
nif.name,
|
||||
nif.index,
|
||||
nif.mtu,
|
||||
nif.isUp,
|
||||
nif.supportsMulticast(),
|
||||
nif.isLoopback,
|
||||
nif.isPointToPoint,
|
||||
nif.supportsMulticast()))
|
||||
|
||||
for (ia in nif.interfaceAddresses) {
|
||||
val parts = ia.toString().split("/", limit = 0)
|
||||
if (parts.size > 1) {
|
||||
sb.append(String.format(Locale.ROOT, "%s/%d ", parts[1], ia.networkPrefixLength))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
continue
|
||||
}
|
||||
sb.append("\n")
|
||||
}
|
||||
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun prepareDownloadsFolder(): File {
|
||||
var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
|
||||
try {
|
||||
if (!downloads.exists()) {
|
||||
downloads.mkdirs()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
TSLog.e(TAG, "Failed to create downloads folder: $e")
|
||||
downloads = File(this.filesDir, "Taildrop")
|
||||
try {
|
||||
if (!downloads.exists()) {
|
||||
downloads.mkdirs()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
TSLog.e(TAG, "Failed to create Taildrop folder: $e")
|
||||
downloads = File("")
|
||||
}
|
||||
}
|
||||
|
||||
return downloads
|
||||
}
|
||||
|
||||
@Throws(
|
||||
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
|
||||
override fun getSyspolicyBooleanValue(key: String): Boolean {
|
||||
return getSyspolicyStringValue(key) == "true"
|
||||
}
|
||||
|
||||
@Throws(
|
||||
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
|
||||
override fun getSyspolicyStringValue(key: String): String {
|
||||
val setting = MDMSettings.allSettingsByKey[key]?.flow?.value
|
||||
if (setting?.isSet != true) {
|
||||
throw MDMSettings.NoSuchKeyException()
|
||||
}
|
||||
return setting.value?.toString() ?: ""
|
||||
}
|
||||
|
||||
@Throws(
|
||||
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
|
||||
override fun getSyspolicyStringArrayJSONValue(key: String): String {
|
||||
val setting = MDMSettings.allSettingsByKey[key]?.flow?.value
|
||||
if (setting?.isSet != true) {
|
||||
throw MDMSettings.NoSuchKeyException()
|
||||
}
|
||||
try {
|
||||
val list = setting.value as? List<*>
|
||||
return Json.encodeToString(list)
|
||||
} catch (e: Exception) {
|
||||
TSLog.d("MDM", "$key value cannot be serialized to JSON. Throwing NoSuchKeyException.")
|
||||
throw MDMSettings.NoSuchKeyException()
|
||||
}
|
||||
}
|
||||
|
||||
fun notifyPolicyChanged() {
|
||||
app.notifyPolicyChanged()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UninitializedApp contains all of the methods of App that can be used without having to initialize
|
||||
* the Go backend. This is useful when you want to access functions on the App without creating side
|
||||
* effects from starting the Go backend (such as launching the VPN).
|
||||
*/
|
||||
open class UninitializedApp : Application() {
|
||||
companion object {
|
||||
const val TAG = "UninitializedApp"
|
||||
|
||||
const val STATUS_NOTIFICATION_ID = 1
|
||||
const val STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID = 2
|
||||
const val STATUS_CHANNEL_ID = "tailscale-status"
|
||||
|
||||
// Key for shared preference that tracks whether or not we're able to start
|
||||
// the VPN (i.e. we're logged in and machine is authorized).
|
||||
private const val ABLE_TO_START_VPN_KEY = "ableToStartVPN"
|
||||
|
||||
private const val DISALLOWED_APPS_KEY = "disallowedApps"
|
||||
|
||||
// File for shared preferences that are not encrypted.
|
||||
private const val UNENCRYPTED_PREFERENCES = "unencrypted"
|
||||
|
||||
private lateinit var appInstance: UninitializedApp
|
||||
lateinit var notificationManager: NotificationManagerCompat
|
||||
|
||||
lateinit var vpnViewModel: VpnViewModel
|
||||
|
||||
@JvmStatic
|
||||
fun get(): UninitializedApp {
|
||||
return appInstance
|
||||
}
|
||||
}
|
||||
|
||||
protected fun setUnprotectedInstance(instance: UninitializedApp) {
|
||||
appInstance = instance
|
||||
}
|
||||
|
||||
protected fun setAbleToStartVPN(rdy: Boolean) {
|
||||
getUnencryptedPrefs().edit().putBoolean(ABLE_TO_START_VPN_KEY, rdy).apply()
|
||||
}
|
||||
|
||||
/** This function can be called without initializing the App. */
|
||||
fun isAbleToStartVPN(): Boolean {
|
||||
return getUnencryptedPrefs().getBoolean(ABLE_TO_START_VPN_KEY, false)
|
||||
}
|
||||
|
||||
private fun getUnencryptedPrefs(): SharedPreferences {
|
||||
return getSharedPreferences(UNENCRYPTED_PREFERENCES, MODE_PRIVATE)
|
||||
}
|
||||
|
||||
fun startVPN() {
|
||||
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_START_VPN }
|
||||
// FLAG_UPDATE_CURRENT ensures that if the intent is already pending, the existing intent will
|
||||
// be updated rather than creating multiple redundant instances.
|
||||
val pendingIntent =
|
||||
PendingIntent.getService(
|
||||
this,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or
|
||||
PendingIntent.FLAG_IMMUTABLE // FLAG_IMMUTABLE for Android 12+
|
||||
)
|
||||
|
||||
try {
|
||||
pendingIntent.send()
|
||||
} catch (foregroundServiceStartException: IllegalStateException) {
|
||||
TSLog.e(
|
||||
TAG,
|
||||
"startVPN hit ForegroundServiceStartNotAllowedException: $foregroundServiceStartException")
|
||||
} catch (securityException: SecurityException) {
|
||||
TSLog.e(TAG, "startVPN hit SecurityException: $securityException")
|
||||
} catch (e: Exception) {
|
||||
TSLog.e(TAG, "startVPN hit exception: $e")
|
||||
}
|
||||
}
|
||||
|
||||
fun stopVPN() {
|
||||
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_STOP_VPN }
|
||||
try {
|
||||
startService(intent)
|
||||
} catch (illegalStateException: IllegalStateException) {
|
||||
TSLog.e(TAG, "stopVPN hit IllegalStateException in startService(): $illegalStateException")
|
||||
} catch (e: Exception) {
|
||||
TSLog.e(TAG, "stopVPN hit exception in startService(): $e")
|
||||
}
|
||||
}
|
||||
|
||||
fun restartVPN() {
|
||||
// Register a receiver to listen for the completion of stopVPN
|
||||
TSLog.d("KARI", "hi")
|
||||
val stopReceiver =
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
// Ensure stop intent is complete
|
||||
if (intent?.action == IPNService.ACTION_STOP_VPN) {
|
||||
// Unregister receiver after receiving the broadcast
|
||||
context?.unregisterReceiver(this)
|
||||
// Now start the VPN
|
||||
startVPN()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the receiver before stopping VPN
|
||||
val intentFilter = IntentFilter(IPNService.ACTION_STOP_VPN)
|
||||
this.registerReceiver(stopReceiver, intentFilter)
|
||||
|
||||
stopVPN()
|
||||
}
|
||||
|
||||
fun createNotificationChannel(id: String, name: String, description: String, importance: Int) {
|
||||
val channel = NotificationChannel(id, name, importance)
|
||||
channel.description = description
|
||||
notificationManager = NotificationManagerCompat.from(this)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
fun notifyStatus(vpnRunning: Boolean, hideDisconnectAction: Boolean) {
|
||||
notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction))
|
||||
}
|
||||
|
||||
fun notifyStatus(notification: Notification) {
|
||||
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
PackageManager.PERMISSION_GRANTED) {
|
||||
// TODO: Consider calling
|
||||
// ActivityCompat#requestPermissions
|
||||
// here to request the missing permissions, and then overriding
|
||||
// public void onRequestPermissionsResult(int requestCode, String[] permissions,
|
||||
// int[] grantResults)
|
||||
// to handle the case where the user grants the permission. See the documentation
|
||||
// for ActivityCompat#requestPermissions for more details.
|
||||
return
|
||||
}
|
||||
notificationManager.notify(STATUS_NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
fun buildStatusNotification(vpnRunning: Boolean, hideDisconnectAction: Boolean): Notification {
|
||||
val message = getString(if (vpnRunning) R.string.connected else R.string.not_connected)
|
||||
val icon = if (vpnRunning) R.drawable.ic_notification else R.drawable.ic_notification_disabled
|
||||
val action =
|
||||
if (vpnRunning) IPNReceiver.INTENT_DISCONNECT_VPN else IPNReceiver.INTENT_CONNECT_VPN
|
||||
val actionLabel = getString(if (vpnRunning) R.string.disconnect else R.string.connect)
|
||||
val buttonIntent = Intent(this, IPNReceiver::class.java).apply { this.action = action }
|
||||
val pendingButtonIntent: PendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
this,
|
||||
0,
|
||||
buttonIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
val intent =
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
val pendingIntent: PendingIntent =
|
||||
PendingIntent.getActivity(
|
||||
this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
val builder =
|
||||
NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
|
||||
.setSmallIcon(icon)
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setContentText(message)
|
||||
.setAutoCancel(!vpnRunning)
|
||||
.setOnlyAlertOnce(!vpnRunning)
|
||||
.setOngoing(vpnRunning)
|
||||
.setSilent(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setContentIntent(pendingIntent)
|
||||
if (!vpnRunning || !hideDisconnectAction) {
|
||||
builder.addAction(
|
||||
NotificationCompat.Action.Builder(0, actionLabel, pendingButtonIntent).build())
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
fun addUserDisallowedPackageName(packageName: String) {
|
||||
if (packageName.isEmpty()) {
|
||||
TSLog.e(TAG, "addUserDisallowedPackageName called with empty packageName")
|
||||
return
|
||||
}
|
||||
|
||||
getUnencryptedPrefs()
|
||||
.edit()
|
||||
.putStringSet(
|
||||
DISALLOWED_APPS_KEY, disallowedPackageNames().toMutableSet().union(setOf(packageName)))
|
||||
.apply()
|
||||
|
||||
this.restartVPN()
|
||||
}
|
||||
|
||||
fun removeUserDisallowedPackageName(packageName: String) {
|
||||
if (packageName.isEmpty()) {
|
||||
TSLog.e(TAG, "removeUserDisallowedPackageName called with empty packageName")
|
||||
return
|
||||
}
|
||||
|
||||
getUnencryptedPrefs()
|
||||
.edit()
|
||||
.putStringSet(
|
||||
DISALLOWED_APPS_KEY,
|
||||
disallowedPackageNames().toMutableSet().subtract(setOf(packageName)))
|
||||
.apply()
|
||||
|
||||
this.restartVPN()
|
||||
}
|
||||
|
||||
fun disallowedPackageNames(): List<String> {
|
||||
val mdmDisallowed =
|
||||
MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
|
||||
if (mdmDisallowed.isNotEmpty()) {
|
||||
TSLog.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed")
|
||||
return builtInDisallowedPackageNames + mdmDisallowed
|
||||
}
|
||||
val userDisallowed =
|
||||
getUnencryptedPrefs().getStringSet(DISALLOWED_APPS_KEY, emptySet())?.toList() ?: emptyList()
|
||||
return builtInDisallowedPackageNames + userDisallowed
|
||||
}
|
||||
|
||||
fun getAppScopedViewModel(): VpnViewModel {
|
||||
return vpnViewModel
|
||||
}
|
||||
|
||||
val builtInDisallowedPackageNames: List<String> =
|
||||
listOf(
|
||||
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
|
||||
"com.google.android.apps.messaging",
|
||||
// Android Auto https://github.com/tailscale/tailscale/issues/3828
|
||||
"com.google.android.projection.gearhead",
|
||||
// GoPro https://github.com/tailscale/tailscale/issues/2554
|
||||
"com.gopro.smarty",
|
||||
// Sonos https://github.com/tailscale/tailscale/issues/2548
|
||||
"com.sonos.acr",
|
||||
"com.sonos.acr2",
|
||||
// Google Chromecast https://github.com/tailscale/tailscale/issues/3636
|
||||
"com.google.android.apps.chromecast.app",
|
||||
// Voicemail https://github.com/tailscale/tailscale/issues/13199
|
||||
"com.samsung.attvvm",
|
||||
"com.att.mobile.android.vvm",
|
||||
"com.tmobile.vvm.application",
|
||||
"com.metropcs.service.vvm",
|
||||
"com.mizmowireless.vvm",
|
||||
"com.vna.service.vvm",
|
||||
"com.dish.vvm",
|
||||
"com.comcast.modesto.vvm.client",
|
||||
)
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
package com.tailscale.ipn
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
|
||||
object AppSourceChecker {
|
||||
|
||||
const val TAG = "AppSourceChecker"
|
||||
|
||||
fun getInstallSource(context: Context): String {
|
||||
val packageManager = context.packageManager
|
||||
val packageName = context.packageName
|
||||
Log.d(TAG, "Package name: $packageName")
|
||||
|
||||
val installerPackageName =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
packageManager.getInstallSourceInfo(packageName).installingPackageName
|
||||
} else {
|
||||
@Suppress("deprecation") packageManager.getInstallerPackageName(packageName)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Installer package name: $installerPackageName")
|
||||
|
||||
return when (installerPackageName) {
|
||||
"com.android.vending" -> "googleplay"
|
||||
"org.fdroid.fdroid" -> "fdroid"
|
||||
"com.amazon.venezia" -> "amazon"
|
||||
null -> "unknown"
|
||||
else -> "unknown($installerPackageName)"
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn;
|
||||
|
||||
// Tailscale DNS Config retrieval
|
||||
//
|
||||
// Tailscale's DNS support can either override the local DNS servers with a set of servers
|
||||
// configured in the admin panel, or supplement the local DNS servers with additional
|
||||
// servers for specific domains like example.com.beta.tailscale.net. In the non-override mode,
|
||||
// we need to retrieve the current set of DNS servers from the platform. These will typically
|
||||
// be the DNS servers received from DHCP.
|
||||
//
|
||||
// Importantly, after the Tailscale VPN comes up it will set a DNS server of 100.100.100.100
|
||||
// but we still want to retrieve the underlying DNS servers received from DHCP. If we roam
|
||||
// from Wi-Fi to LTE, we want the DNS servers received from LTE.
|
||||
|
||||
public class DnsConfig {
|
||||
private String dnsConfigs;
|
||||
|
||||
// getDnsConfigAsString returns the current DNS configuration as a multiline string:
|
||||
// line[0] DNS server addresses separated by spaces
|
||||
// line[1] search domains separated by spaces
|
||||
//
|
||||
// For example:
|
||||
// 8.8.8.8 8.8.4.4
|
||||
// example.com
|
||||
//
|
||||
// an empty string means the current DNS configuration could not be retrieved.
|
||||
String getDnsConfigAsString() {
|
||||
String dnsConfig = getDnsConfigs();
|
||||
if (dnsConfig != null) {
|
||||
return getDnsConfigs().trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String getDnsConfigs() {
|
||||
synchronized (this) {
|
||||
return this.dnsConfigs;
|
||||
}
|
||||
}
|
||||
|
||||
boolean updateDNSFromNetwork(String dnsConfigs) {
|
||||
synchronized (this) {
|
||||
if (!dnsConfigs.equals(this.dnsConfigs)) {
|
||||
this.dnsConfigs = dnsConfigs;
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import androidx.work.Data;
|
||||
|
||||
import androidx.work.OneTimeWorkRequest;
|
||||
import androidx.work.WorkManager;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* IPNReceiver allows external applications to start the VPN.
|
||||
*/
|
||||
public class IPNReceiver extends BroadcastReceiver {
|
||||
|
||||
public static final String INTENT_CONNECT_VPN = "com.tailscale.ipn.CONNECT_VPN";
|
||||
public static final String INTENT_DISCONNECT_VPN = "com.tailscale.ipn.DISCONNECT_VPN";
|
||||
|
||||
private static final String INTENT_USE_EXIT_NODE = "com.tailscale.ipn.USE_EXIT_NODE";
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
WorkManager workManager = WorkManager.getInstance(context);
|
||||
|
||||
// On the relevant action, start the relevant worker, which can stay active for longer than this receiver can.
|
||||
if (Objects.equals(intent.getAction(), INTENT_CONNECT_VPN)) {
|
||||
workManager.enqueue(new OneTimeWorkRequest.Builder(StartVPNWorker.class).build());
|
||||
} else if (Objects.equals(intent.getAction(), INTENT_DISCONNECT_VPN)) {
|
||||
workManager.enqueue(new OneTimeWorkRequest.Builder(StopVPNWorker.class).build());
|
||||
}
|
||||
else if (Objects.equals(intent.getAction(), INTENT_USE_EXIT_NODE)) {
|
||||
String exitNode = intent.getStringExtra("exitNode");
|
||||
boolean allowLanAccess = intent.getBooleanExtra("allowLanAccess", false);
|
||||
Data.Builder workData = new Data.Builder();
|
||||
workData.putString(UseExitNodeWorker.EXIT_NODE_NAME, exitNode);
|
||||
workData.putBoolean(UseExitNodeWorker.ALLOW_LAN_ACCESS, allowLanAccess);
|
||||
workManager.enqueue(new OneTimeWorkRequest.Builder(UseExitNodeWorker.class).setInputData(workData.build()).build());
|
||||
}
|
||||
}
|
||||
}
|
@ -1,111 +0,0 @@
|
||||
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package com.tailscale.ipn;
|
||||
|
||||
import android.os.Build;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.NotificationChannel;
|
||||
import android.content.Intent;
|
||||
import android.net.VpnService;
|
||||
import android.system.OsConstants;
|
||||
|
||||
import org.gioui.GioActivity;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
|
||||
public class IPNService extends VpnService {
|
||||
public static final String ACTION_CONNECT = "com.tailscale.ipn.CONNECT";
|
||||
public static final String ACTION_DISCONNECT = "com.tailscale.ipn.DISCONNECT";
|
||||
|
||||
private static final String STATUS_CHANNEL_ID = "tailscale-status";
|
||||
private static final String STATUS_CHANNEL_NAME = "VPN Status";
|
||||
private static final int STATUS_NOTIFICATION_ID = 1;
|
||||
|
||||
private static final String NOTIFY_CHANNEL_ID = "tailscale-notify";
|
||||
private static final String NOTIFY_CHANNEL_NAME = "Notifications";
|
||||
private static final int NOTIFY_NOTIFICATION_ID = 2;
|
||||
|
||||
@Override public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent != null && ACTION_DISCONNECT.equals(intent.getAction())) {
|
||||
close();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
connect();
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
private void close() {
|
||||
stopForeground(true);
|
||||
disconnect();
|
||||
}
|
||||
|
||||
@Override public void onDestroy() {
|
||||
close();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override public void onRevoke() {
|
||||
close();
|
||||
super.onRevoke();
|
||||
}
|
||||
|
||||
private PendingIntent configIntent() {
|
||||
return PendingIntent.getActivity(this, 0, new Intent(this, GioActivity.class), PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
}
|
||||
|
||||
protected VpnService.Builder newBuilder() {
|
||||
VpnService.Builder b = new VpnService.Builder()
|
||||
.setConfigureIntent(configIntent())
|
||||
.allowFamily(OsConstants.AF_INET)
|
||||
.allowFamily(OsConstants.AF_INET6);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
b.setMetered(false); // Inherit the metered status from the underlying networks.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||
b.setUnderlyingNetworks(null); // Use all available networks.
|
||||
return b;
|
||||
}
|
||||
|
||||
public void notify(String title, String message) {
|
||||
createNotificationChannel(NOTIFY_CHANNEL_ID, NOTIFY_CHANNEL_NAME, NotificationManagerCompat.IMPORTANCE_DEFAULT);
|
||||
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, NOTIFY_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(message)
|
||||
.setContentIntent(configIntent())
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
|
||||
|
||||
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
|
||||
nm.notify(NOTIFY_NOTIFICATION_ID, builder.build());
|
||||
}
|
||||
|
||||
public void updateStatusNotification(String title, String message) {
|
||||
createNotificationChannel(STATUS_CHANNEL_ID, STATUS_CHANNEL_NAME, NotificationManagerCompat.IMPORTANCE_LOW);
|
||||
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(message)
|
||||
.setContentIntent(configIntent())
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW);
|
||||
|
||||
startForeground(STATUS_NOTIFICATION_ID, builder.build());
|
||||
}
|
||||
|
||||
private void createNotificationChannel(String id, String name, int importance) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return;
|
||||
}
|
||||
NotificationChannel channel = new NotificationChannel(id, name, importance);
|
||||
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
|
||||
nm.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
private native void connect();
|
||||
private native void disconnect();
|
||||
}
|
@ -0,0 +1,181 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
package com.tailscale.ipn
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.system.OsConstants
|
||||
import com.tailscale.ipn.mdm.MDMSettings
|
||||
import com.tailscale.ipn.ui.model.Ipn
|
||||
import com.tailscale.ipn.ui.notifier.Notifier
|
||||
import com.tailscale.ipn.util.TSLog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import libtailscale.Libtailscale
|
||||
import java.util.UUID
|
||||
|
||||
open class IPNService : VpnService(), libtailscale.IPNService {
|
||||
private val TAG = "IPNService"
|
||||
private val randomID: String = UUID.randomUUID().toString()
|
||||
private lateinit var app: App
|
||||
val scope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
override fun id(): String {
|
||||
return randomID
|
||||
}
|
||||
|
||||
override fun updateVpnStatus(status: Boolean) {
|
||||
app.getAppScopedViewModel().setVpnActive(status)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// grab app to make sure it initializes
|
||||
app = App.get()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
|
||||
when (intent?.action) {
|
||||
ACTION_STOP_VPN -> {
|
||||
app.setWantRunning(false)
|
||||
close()
|
||||
START_NOT_STICKY
|
||||
}
|
||||
ACTION_START_VPN -> {
|
||||
scope.launch {
|
||||
// Collect the first value of hideDisconnectAction asynchronously.
|
||||
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
|
||||
showForegroundNotification(hideDisconnectAction.value)
|
||||
}
|
||||
app.setWantRunning(true)
|
||||
Libtailscale.requestVPN(this)
|
||||
START_STICKY
|
||||
}
|
||||
"android.net.VpnService" -> {
|
||||
// This means we were started by Android due to Always On VPN.
|
||||
// We show a non-foreground notification because we weren't
|
||||
// started as a foreground service.
|
||||
scope.launch {
|
||||
// Collect the first value of hideDisconnectAction asynchronously.
|
||||
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
|
||||
app.notifyStatus(true, hideDisconnectAction.value)
|
||||
}
|
||||
app.setWantRunning(true)
|
||||
Libtailscale.requestVPN(this)
|
||||
START_STICKY
|
||||
}
|
||||
else -> {
|
||||
// This means that we were restarted after the service was killed
|
||||
// (potentially due to OOM).
|
||||
if (UninitializedApp.get().isAbleToStartVPN()) {
|
||||
scope.launch {
|
||||
// Collect the first value of hideDisconnectAction asynchronously.
|
||||
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
|
||||
showForegroundNotification(hideDisconnectAction.value)
|
||||
}
|
||||
App.get()
|
||||
Libtailscale.requestVPN(this)
|
||||
START_STICKY
|
||||
} else {
|
||||
START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
app.setWantRunning(false) {}
|
||||
Notifier.setState(Ipn.State.Stopping)
|
||||
disconnectVPN()
|
||||
Libtailscale.serviceDisconnect(this)
|
||||
}
|
||||
|
||||
override fun disconnectVPN() {
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
close()
|
||||
updateVpnStatus(false)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onRevoke() {
|
||||
close()
|
||||
updateVpnStatus(false)
|
||||
super.onRevoke()
|
||||
}
|
||||
|
||||
private fun setVpnPrepared(isPrepared: Boolean) {
|
||||
app.getAppScopedViewModel().setVpnPrepared(isPrepared)
|
||||
}
|
||||
|
||||
private fun showForegroundNotification(hideDisconnectAction: Boolean) {
|
||||
try {
|
||||
startForeground(
|
||||
UninitializedApp.STATUS_NOTIFICATION_ID,
|
||||
UninitializedApp.get().buildStatusNotification(true, hideDisconnectAction))
|
||||
} catch (e: Exception) {
|
||||
TSLog.e(TAG, "Failed to start foreground service: $e")
|
||||
}
|
||||
}
|
||||
|
||||
private fun configIntent(): PendingIntent {
|
||||
return PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
|
||||
private fun disallowApp(b: Builder, name: String) {
|
||||
try {
|
||||
b.addDisallowedApplication(name)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
TSLog.d(TAG, "Failed to add disallowed application: $e")
|
||||
}
|
||||
}
|
||||
|
||||
override fun newBuilder(): VPNServiceBuilder {
|
||||
val b: Builder =
|
||||
Builder()
|
||||
.setConfigureIntent(configIntent())
|
||||
.allowFamily(OsConstants.AF_INET)
|
||||
.allowFamily(OsConstants.AF_INET6)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
b.setMetered(false) // Inherit the metered status from the underlying networks.
|
||||
}
|
||||
b.setUnderlyingNetworks(null) // Use all available networks.
|
||||
|
||||
val includedPackages: List<String> =
|
||||
MDMSettings.includedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
|
||||
if (includedPackages.isNotEmpty()) {
|
||||
// If an admin defined a list of packages that are exclusively allowed to be used via
|
||||
// Tailscale,
|
||||
// then only allow those apps.
|
||||
for (packageName in includedPackages) {
|
||||
TSLog.d(TAG, "Including app: $packageName")
|
||||
b.addAllowedApplication(packageName)
|
||||
}
|
||||
} else {
|
||||
// Otherwise, prevent certain apps from getting their traffic + DNS routed via Tailscale:
|
||||
// - any app that the user manually disallowed in the GUI
|
||||
// - any app that we disallowed via hard-coding
|
||||
for (disallowedPackageName in UninitializedApp.get().disallowedPackageNames()) {
|
||||
TSLog.d(TAG, "Disallowing app: $disallowedPackageName")
|
||||
disallowApp(b, disallowedPackageName)
|
||||
}
|
||||
}
|
||||
|
||||
return VPNServiceBuilder(b)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_START_VPN = "com.tailscale.ipn.START_VPN"
|
||||
const val ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN"
|
||||
}
|
||||
}
|
@ -0,0 +1,426 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.RestrictionsManager
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE
|
||||
import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.tailscale.ipn.mdm.MDMSettings
|
||||
import com.tailscale.ipn.ui.model.Ipn
|
||||
import com.tailscale.ipn.ui.notifier.Notifier
|
||||
import com.tailscale.ipn.ui.theme.AppTheme
|
||||
import com.tailscale.ipn.ui.util.AndroidTVUtil
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.ui.util.universalFit
|
||||
import com.tailscale.ipn.ui.view.AboutView
|
||||
import com.tailscale.ipn.ui.view.BugReportView
|
||||
import com.tailscale.ipn.ui.view.DNSSettingsView
|
||||
import com.tailscale.ipn.ui.view.ExitNodePicker
|
||||
import com.tailscale.ipn.ui.view.HealthView
|
||||
import com.tailscale.ipn.ui.view.IntroView
|
||||
import com.tailscale.ipn.ui.view.LoginQRView
|
||||
import com.tailscale.ipn.ui.view.LoginWithAuthKeyView
|
||||
import com.tailscale.ipn.ui.view.LoginWithCustomControlURLView
|
||||
import com.tailscale.ipn.ui.view.MDMSettingsDebugView
|
||||
import com.tailscale.ipn.ui.view.MainView
|
||||
import com.tailscale.ipn.ui.view.MainViewNavigation
|
||||
import com.tailscale.ipn.ui.view.ManagedByView
|
||||
import com.tailscale.ipn.ui.view.MullvadExitNodePicker
|
||||
import com.tailscale.ipn.ui.view.MullvadExitNodePickerList
|
||||
import com.tailscale.ipn.ui.view.MullvadInfoView
|
||||
import com.tailscale.ipn.ui.view.PeerDetails
|
||||
import com.tailscale.ipn.ui.view.PermissionsView
|
||||
import com.tailscale.ipn.ui.view.RunExitNodeView
|
||||
import com.tailscale.ipn.ui.view.SettingsView
|
||||
import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView
|
||||
import com.tailscale.ipn.ui.view.TailnetLockSetupView
|
||||
import com.tailscale.ipn.ui.view.UserSwitcherNav
|
||||
import com.tailscale.ipn.ui.view.UserSwitcherView
|
||||
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
|
||||
import com.tailscale.ipn.ui.viewModel.MainViewModel
|
||||
import com.tailscale.ipn.ui.viewModel.MainViewModelFactory
|
||||
import com.tailscale.ipn.ui.viewModel.PingViewModel
|
||||
import com.tailscale.ipn.ui.viewModel.SettingsNav
|
||||
import com.tailscale.ipn.ui.viewModel.VpnViewModel
|
||||
import com.tailscale.ipn.util.TSLog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private lateinit var navController: NavHostController
|
||||
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
|
||||
private val viewModel: MainViewModel by lazy {
|
||||
val app = App.get()
|
||||
vpnViewModel = app.getAppScopedViewModel()
|
||||
ViewModelProvider(this, MainViewModelFactory(vpnViewModel)).get(MainViewModel::class.java)
|
||||
}
|
||||
private lateinit var vpnViewModel: VpnViewModel
|
||||
|
||||
companion object {
|
||||
private const val TAG = "Main Activity"
|
||||
private const val START_AT_ROOT = "startAtRoot"
|
||||
}
|
||||
|
||||
private fun Context.isLandscapeCapable(): Boolean {
|
||||
return (resources.configuration.screenLayout and SCREENLAYOUT_SIZE_MASK) >=
|
||||
SCREENLAYOUT_SIZE_LARGE
|
||||
}
|
||||
|
||||
// The loginQRCode is used to track whether or not we should be rendering a QR code
|
||||
// to the user. This is used only on TV platforms with no browser in lieu of
|
||||
// simply opening the URL. This should be consumed once it has been handled.
|
||||
private val loginQRCode: StateFlow<String?> = MutableStateFlow(null)
|
||||
|
||||
@SuppressLint("SourceLockedOrientationActivity")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// grab app to make sure it initializes
|
||||
App.get()
|
||||
vpnViewModel = ViewModelProvider(App.get()).get(VpnViewModel::class.java)
|
||||
|
||||
// (jonathan) TODO: Force the app to be portrait on small screens until we have
|
||||
// proper landscape layout support
|
||||
if (!isLandscapeCapable()) {
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
}
|
||||
|
||||
installSplashScreen()
|
||||
|
||||
vpnPermissionLauncher =
|
||||
registerForActivityResult(VpnPermissionContract()) { granted ->
|
||||
if (granted) {
|
||||
TSLog.d("VpnPermission", "VPN permission granted")
|
||||
vpnViewModel.setVpnPrepared(true)
|
||||
App.get().startVPN()
|
||||
} else {
|
||||
if (isAnotherVpnActive(this)) {
|
||||
TSLog.d("VpnPermission", "Another VPN is likely active")
|
||||
showOtherVPNConflictDialog()
|
||||
} else {
|
||||
TSLog.d("VpnPermission", "Permission was denied by the user")
|
||||
vpnViewModel.setVpnPrepared(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher)
|
||||
|
||||
setContent {
|
||||
AppTheme {
|
||||
navController = rememberNavController()
|
||||
Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox
|
||||
Surface(modifier = Modifier.universalFit()) { // Letterbox for AndroidTV
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "main",
|
||||
enterTransition = {
|
||||
slideInHorizontally(animationSpec = tween(150), initialOffsetX = { it })
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutHorizontally(animationSpec = tween(150), targetOffsetX = { -it })
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideInHorizontally(animationSpec = tween(150), initialOffsetX = { -it })
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutHorizontally(animationSpec = tween(150), targetOffsetX = { it })
|
||||
}) {
|
||||
fun backTo(route: String): () -> Unit = {
|
||||
navController.popBackStack(route = route, inclusive = false)
|
||||
}
|
||||
|
||||
val mainViewNav =
|
||||
MainViewNavigation(
|
||||
onNavigateToSettings = { navController.navigate("settings") },
|
||||
onNavigateToPeerDetails = {
|
||||
navController.navigate("peerDetails/${it.StableID}")
|
||||
},
|
||||
onNavigateToExitNodes = { navController.navigate("exitNodes") },
|
||||
onNavigateToHealth = { navController.navigate("health") })
|
||||
|
||||
val settingsNav =
|
||||
SettingsNav(
|
||||
onNavigateToBugReport = { navController.navigate("bugReport") },
|
||||
onNavigateToAbout = { navController.navigate("about") },
|
||||
onNavigateToDNSSettings = { navController.navigate("dnsSettings") },
|
||||
onNavigateToSplitTunneling = { navController.navigate("splitTunneling") },
|
||||
onNavigateToTailnetLock = { navController.navigate("tailnetLock") },
|
||||
onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
|
||||
onNavigateToManagedBy = { navController.navigate("managedBy") },
|
||||
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
|
||||
onNavigateToPermissions = { navController.navigate("permissions") },
|
||||
onBackToSettings = backTo("settings"),
|
||||
onNavigateBackHome = backTo("main"))
|
||||
|
||||
val exitNodePickerNav =
|
||||
ExitNodePickerNav(
|
||||
onNavigateBackHome = {
|
||||
navController.popBackStack(route = "main", inclusive = false)
|
||||
},
|
||||
onNavigateBackToExitNodes = backTo("exitNodes"),
|
||||
onNavigateToMullvad = { navController.navigate("mullvad") },
|
||||
onNavigateToMullvadInfo = { navController.navigate("mullvad_info") },
|
||||
onNavigateBackToMullvad = backTo("mullvad"),
|
||||
onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") },
|
||||
onNavigateToRunAsExitNode = { navController.navigate("runExitNode") })
|
||||
|
||||
val userSwitcherNav =
|
||||
UserSwitcherNav(
|
||||
backToSettings = backTo("settings"),
|
||||
onNavigateHome = backTo("main"),
|
||||
onNavigateCustomControl = {
|
||||
navController.navigate("loginWithCustomControl")
|
||||
},
|
||||
onNavigateToAuthKey = { navController.navigate("loginWithAuthKey") })
|
||||
|
||||
composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) {
|
||||
MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel)
|
||||
}
|
||||
composable("settings") { SettingsView(settingsNav) }
|
||||
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
|
||||
composable("health") { HealthView(backTo("main")) }
|
||||
composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) }
|
||||
composable("mullvad_info") { MullvadInfoView(exitNodePickerNav) }
|
||||
composable(
|
||||
"mullvad/{countryCode}",
|
||||
arguments =
|
||||
listOf(navArgument("countryCode") { type = NavType.StringType })) {
|
||||
MullvadExitNodePicker(
|
||||
it.arguments!!.getString("countryCode")!!, exitNodePickerNav)
|
||||
}
|
||||
composable("runExitNode") { RunExitNodeView(exitNodePickerNav) }
|
||||
composable(
|
||||
"peerDetails/{nodeId}",
|
||||
arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) {
|
||||
PeerDetails(
|
||||
backTo("main"),
|
||||
it.arguments?.getString("nodeId") ?: "",
|
||||
PingViewModel())
|
||||
}
|
||||
composable("bugReport") { BugReportView(backTo("settings")) }
|
||||
composable("dnsSettings") { DNSSettingsView(backTo("settings")) }
|
||||
composable("splitTunneling") { SplitTunnelAppPickerView(backTo("settings")) }
|
||||
composable("tailnetLock") { TailnetLockSetupView(backTo("settings")) }
|
||||
composable("about") { AboutView(backTo("settings")) }
|
||||
composable("mdmSettings") { MDMSettingsDebugView(backTo("settings")) }
|
||||
composable("managedBy") { ManagedByView(backTo("settings")) }
|
||||
composable("userSwitcher") { UserSwitcherView(userSwitcherNav) }
|
||||
composable("permissions") {
|
||||
PermissionsView(backTo("settings"), ::openApplicationSettings)
|
||||
}
|
||||
composable("intro", exitTransition = { fadeOut(animationSpec = tween(150)) }) {
|
||||
IntroView(backTo("main"))
|
||||
}
|
||||
composable("loginWithAuthKey") {
|
||||
LoginWithAuthKeyView(onNavigateHome = backTo("main"), backTo("userSwitcher"))
|
||||
}
|
||||
composable("loginWithCustomControl") {
|
||||
LoginWithCustomControlURLView(
|
||||
onNavigateHome = backTo("main"), backTo("userSwitcher"))
|
||||
}
|
||||
}
|
||||
|
||||
// Show the intro screen one time
|
||||
if (!introScreenViewed()) {
|
||||
navController.navigate("intro")
|
||||
setIntroScreenViewed(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Login actions are app wide. If we are told about a browse-to-url, we should render it
|
||||
// over whatever screen we happen to be on.
|
||||
loginQRCode.collectAsState().value?.let {
|
||||
LoginQRView(onDismiss = { loginQRCode.set(null) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// Watch the model's browseToURL and launch the browser when it changes or
|
||||
// pop up a QR code to scan
|
||||
lifecycleScope.launch {
|
||||
Notifier.browseToURL.collect { url ->
|
||||
url?.let {
|
||||
when (useQRCodeLogin()) {
|
||||
false -> Dispatchers.Main.run { login(it) }
|
||||
true -> loginQRCode.set(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Once we see a loginFinished event, clear the QR code which will dismiss the QR dialog.
|
||||
lifecycleScope.launch { Notifier.loginFinished.collect { _ -> loginQRCode.set(null) } }
|
||||
}
|
||||
|
||||
private fun showOtherVPNConflictDialog() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.vpn_permission_denied)
|
||||
.setMessage(R.string.multiple_vpn_explainer)
|
||||
.setPositiveButton(R.string.go_to_settings) { _, _ ->
|
||||
// Intent to open the VPN settings
|
||||
val intent = Intent(Settings.ACTION_VPN_SETTINGS)
|
||||
startActivity(intent)
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
fun isAnotherVpnActive(context: Context): Boolean {
|
||||
val connectivityManager =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
val activeNetwork = connectivityManager.activeNetwork
|
||||
if (activeNetwork != null) {
|
||||
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
|
||||
if (networkCapabilities != null &&
|
||||
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Returns true if we should render a QR code instead of launching a browser
|
||||
// for login requests
|
||||
private fun useQRCodeLogin(): Boolean {
|
||||
return AndroidTVUtil.isAndroidTV()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
if (intent.getBooleanExtra(START_AT_ROOT, false)) {
|
||||
if (this::navController.isInitialized) {
|
||||
navController.popBackStack(route = "main", inclusive = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun login(urlString: String) {
|
||||
// Launch coroutine to listen for state changes. When the user completes login, relaunch
|
||||
// MainActivity to bring the app back to focus.
|
||||
App.get().applicationScope.launch {
|
||||
try {
|
||||
Notifier.state.collect { state ->
|
||||
if (state > Ipn.State.NeedsMachineAuth) {
|
||||
val intent =
|
||||
Intent(applicationContext, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
action = Intent.ACTION_MAIN
|
||||
addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
putExtra(START_AT_ROOT, true)
|
||||
}
|
||||
startActivity(intent)
|
||||
|
||||
// Cancel coroutine once we've logged in
|
||||
this@launch.cancel()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
TSLog.e(TAG, "Login: failed to start MainActivity: $e")
|
||||
}
|
||||
}
|
||||
|
||||
val url = urlString.toUri()
|
||||
try {
|
||||
val customTabsIntent = CustomTabsIntent.Builder().build()
|
||||
customTabsIntent.launchUrl(this, url)
|
||||
} catch (e: Exception) {
|
||||
// Fallback to a regular browser if CustomTabsIntent fails
|
||||
try {
|
||||
val fallbackIntent = Intent(Intent.ACTION_VIEW, url)
|
||||
startActivity(fallbackIntent)
|
||||
} catch (e: Exception) {
|
||||
TSLog.e(TAG, "Login: failed to open browser: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val restrictionsManager =
|
||||
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
|
||||
lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
val restrictionsManager =
|
||||
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
|
||||
lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
|
||||
}
|
||||
|
||||
private fun openApplicationSettings() {
|
||||
val intent =
|
||||
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
||||
}
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun introScreenViewed(): Boolean {
|
||||
return getSharedPreferences("introScreen", Context.MODE_PRIVATE).getBoolean("seen", false)
|
||||
}
|
||||
|
||||
private fun setIntroScreenViewed(seen: Boolean) {
|
||||
getSharedPreferences("introScreen", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putBoolean("seen", seen)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
class VpnPermissionContract : ActivityResultContract<Intent, Boolean>() {
|
||||
override fun createIntent(context: Context, input: Intent): Intent {
|
||||
return input
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
|
||||
return resultCode == Activity.RESULT_OK
|
||||
}
|
||||
}
|
@ -0,0 +1,168 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
package com.tailscale.ipn
|
||||
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.LinkProperties
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.util.Log
|
||||
import com.tailscale.ipn.util.TSLog
|
||||
import libtailscale.Libtailscale
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
|
||||
object NetworkChangeCallback {
|
||||
|
||||
private const val TAG = "NetworkChangeCallback"
|
||||
|
||||
private data class NetworkInfo(var caps: NetworkCapabilities, var linkProps: LinkProperties)
|
||||
|
||||
private val lock = ReentrantLock()
|
||||
|
||||
private val activeNetworks = mutableMapOf<Network, NetworkInfo>() // keyed by Network
|
||||
|
||||
// monitorDnsChanges sets up a network callback to monitor changes to the
|
||||
// system's network state and update the DNS configuration when interfaces
|
||||
// become available or properties of those interfaces change.
|
||||
fun monitorDnsChanges(connectivityManager: ConnectivityManager, dns: DnsConfig) {
|
||||
val networkConnectivityRequest =
|
||||
NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
.build()
|
||||
|
||||
// Use registerNetworkCallback to listen for updates from all networks, and
|
||||
// then update DNS configs for the best network when LinkProperties are changed.
|
||||
// Per
|
||||
// https://developer.android.com/reference/android/net/ConnectivityManager.NetworkCallback#onAvailable(android.net.Network), this happens after all other updates.
|
||||
//
|
||||
// Note that we can't use registerDefaultNetworkCallback because the
|
||||
// default network used by Tailscale will always show up with capability
|
||||
// NOT_VPN=false, and we must filter out NOT_VPN networks to avoid routing
|
||||
// loops.
|
||||
connectivityManager.registerNetworkCallback(
|
||||
networkConnectivityRequest,
|
||||
object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
super.onAvailable(network)
|
||||
|
||||
TSLog.d(TAG, "onAvailable: network ${network}")
|
||||
lock.withLock {
|
||||
activeNetworks[network] = NetworkInfo(NetworkCapabilities(), LinkProperties())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
|
||||
super.onCapabilitiesChanged(network, capabilities)
|
||||
lock.withLock { activeNetworks[network]?.caps = capabilities }
|
||||
}
|
||||
|
||||
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
|
||||
super.onLinkPropertiesChanged(network, linkProperties)
|
||||
lock.withLock {
|
||||
activeNetworks[network]?.linkProps = linkProperties
|
||||
maybeUpdateDNSConfig("onLinkPropertiesChanged", dns)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
super.onLost(network)
|
||||
|
||||
TSLog.d(TAG, "onLost: network ${network}")
|
||||
lock.withLock {
|
||||
activeNetworks.remove(network)
|
||||
maybeUpdateDNSConfig("onLost", dns)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// pickNonMetered returns the first non-metered network in the list of
|
||||
// networks, or the first network if none are non-metered.
|
||||
private fun pickNonMetered(networks: Map<Network, NetworkInfo>): Network? {
|
||||
for ((network, info) in networks) {
|
||||
if (info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)) {
|
||||
return network
|
||||
}
|
||||
}
|
||||
return networks.keys.firstOrNull()
|
||||
}
|
||||
|
||||
// pickDefaultNetwork returns a non-VPN network to use as the 'default'
|
||||
// network; one that is used as a gateway to the internet and from which we
|
||||
// obtain our DNS servers.
|
||||
private fun pickDefaultNetwork(): Network? {
|
||||
// Filter the list of all networks to those that have the INTERNET
|
||||
// capability, are not VPNs, and have a non-zero number of DNS servers
|
||||
// available.
|
||||
val networks =
|
||||
activeNetworks.filter { (_, info) ->
|
||||
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) &&
|
||||
info.linkProps.dnsServers.isNotEmpty() == true
|
||||
}
|
||||
|
||||
// If we have one; just return it; otherwise, prefer networks that are also
|
||||
// not metered (i.e. cell modems).
|
||||
val nonMeteredNetwork = pickNonMetered(networks)
|
||||
if (nonMeteredNetwork != null) {
|
||||
return nonMeteredNetwork
|
||||
}
|
||||
|
||||
// Okay, less good; just return the first network that has the INTERNET and
|
||||
// NOT_VPN capabilities; even though this interface doesn't have any DNS
|
||||
// servers set, we'll use our DNS fallback servers to make queries. It's
|
||||
// strictly better to return an interface + use the DNS fallback servers
|
||||
// than to return nothing and not be able to route traffic.
|
||||
for ((network, info) in activeNetworks) {
|
||||
if (info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"no networks available that also have DNS servers set; falling back to first network ${network}")
|
||||
return network
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, return nothing; we don't want to return a VPN network since
|
||||
// it could result in a routing loop, and a non-INTERNET network isn't
|
||||
// helpful.
|
||||
Log.w(TAG, "no networks available to pick a default network")
|
||||
return null
|
||||
}
|
||||
|
||||
// maybeUpdateDNSConfig will maybe update our DNS configuration based on the
|
||||
// current set of active Networks.
|
||||
private fun maybeUpdateDNSConfig(why: String, dns: DnsConfig) {
|
||||
val defaultNetwork = pickDefaultNetwork()
|
||||
if (defaultNetwork == null) {
|
||||
TSLog.d(TAG, "${why}: no default network available; not updating DNS config")
|
||||
return
|
||||
}
|
||||
val info = activeNetworks[defaultNetwork]
|
||||
if (info == null) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"${why}: [unexpected] no info available for default network; not updating DNS config")
|
||||
return
|
||||
}
|
||||
|
||||
val sb = StringBuilder()
|
||||
for (ip in info.linkProps.dnsServers) {
|
||||
sb.append(ip.hostAddress).append(" ")
|
||||
}
|
||||
val searchDomains: String? = info.linkProps.domains
|
||||
if (searchDomains != null) {
|
||||
sb.append("\n")
|
||||
sb.append(searchDomains)
|
||||
}
|
||||
if (dns.updateDNSFromNetwork(sb.toString())) {
|
||||
TSLog.d(
|
||||
TAG,
|
||||
"${why}: updated DNS config for network ${defaultNetwork} (${info.linkProps.interfaceName})")
|
||||
Libtailscale.onDNSConfigChanged(info.linkProps.interfaceName)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,89 +0,0 @@
|
||||
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package com.tailscale.ipn;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.app.Dialog;
|
||||
import android.app.Fragment;
|
||||
import android.app.DialogFragment;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.net.VpnService;
|
||||
import android.os.Bundle;
|
||||
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
|
||||
import androidx.browser.customtabs.CustomTabsIntent;
|
||||
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignIn;
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignInAccount;
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignInClient;
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignInOptions;
|
||||
|
||||
public class Peer extends Fragment {
|
||||
private final static int REQUEST_SIGNIN = 1001;
|
||||
private final static int REQUEST_PREPARE_VPN = 1002;
|
||||
|
||||
@Override public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
switch (requestCode) {
|
||||
case REQUEST_SIGNIN:
|
||||
if (resultCode == Activity.RESULT_OK) {
|
||||
GoogleSignInAccount acc = GoogleSignIn.getLastSignedInAccount(getActivity());
|
||||
onSignin(acc.getIdToken());
|
||||
return;
|
||||
}
|
||||
case REQUEST_PREPARE_VPN:
|
||||
if (resultCode == Activity.RESULT_OK) {
|
||||
onVPNPrepared();
|
||||
return;
|
||||
}
|
||||
}
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
}
|
||||
|
||||
@Override public void onCreate(Bundle b) {
|
||||
super.onCreate(b);
|
||||
fragmentCreated();
|
||||
}
|
||||
|
||||
@Override public void onDestroy() {
|
||||
fragmentDestroyed();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
public void googleSignIn(String serverOAuthID) {
|
||||
GoogleSignInOptions gso = new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
|
||||
.requestIdToken(serverOAuthID)
|
||||
.requestEmail()
|
||||
.build();
|
||||
GoogleSignInClient client = GoogleSignIn.getClient(getActivity(), gso);
|
||||
Intent signInIntent = client.getSignInIntent();
|
||||
startActivityForResult(signInIntent, REQUEST_SIGNIN);
|
||||
}
|
||||
|
||||
public void prepareVPN() {
|
||||
Intent intent = VpnService.prepare(getActivity());
|
||||
if (intent == null) {
|
||||
onVPNPrepared();
|
||||
} else {
|
||||
startActivityForResult(intent, REQUEST_PREPARE_VPN);
|
||||
}
|
||||
}
|
||||
|
||||
void showURL(String url) {
|
||||
CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
|
||||
int headerColor = 0xff496495;
|
||||
builder.setToolbarColor(headerColor);
|
||||
CustomTabsIntent intent = builder.build();
|
||||
intent.launchUrl(getActivity(), Uri.parse(url));
|
||||
}
|
||||
|
||||
private native void fragmentCreated();
|
||||
private native void fragmentDestroyed();
|
||||
private native void onSignin(String idToken);
|
||||
private native void onVPNPrepared();
|
||||
}
|
@ -0,0 +1,99 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn;
|
||||
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.service.quicksettings.Tile;
|
||||
import android.service.quicksettings.TileService;
|
||||
|
||||
public class QuickToggleService extends TileService {
|
||||
// lock protects the static fields below it.
|
||||
private static final Object lock = new Object();
|
||||
|
||||
// isRunning tracks whether the VPN is running.
|
||||
private static boolean isRunning;
|
||||
|
||||
// currentTile tracks getQsTile while service is listening.
|
||||
private static Tile currentTile;
|
||||
|
||||
public static void updateTile() {
|
||||
var app = UninitializedApp.get();
|
||||
Tile t;
|
||||
boolean act;
|
||||
synchronized (lock) {
|
||||
t = currentTile;
|
||||
act = isRunning && app.isAbleToStartVPN();
|
||||
}
|
||||
if (t == null) {
|
||||
return;
|
||||
}
|
||||
t.setLabel("Tailscale");
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
t.setSubtitle(act ? app.getString(R.string.connected) : app.getString(R.string.not_connected));
|
||||
}
|
||||
t.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
|
||||
t.updateTile();
|
||||
}
|
||||
|
||||
static void setVPNRunning(boolean running) {
|
||||
synchronized (lock) {
|
||||
isRunning = running;
|
||||
}
|
||||
updateTile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStartListening() {
|
||||
synchronized (lock) {
|
||||
currentTile = getQsTile();
|
||||
}
|
||||
updateTile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStopListening() {
|
||||
synchronized (lock) {
|
||||
currentTile = null;
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
@Override
|
||||
public void onClick() {
|
||||
boolean r;
|
||||
synchronized (lock) {
|
||||
r = UninitializedApp.get().isAbleToStartVPN();
|
||||
}
|
||||
if (r) {
|
||||
// Get the application to make sure it initializes
|
||||
App.get();
|
||||
onTileClick();
|
||||
} else {
|
||||
// Start main activity.
|
||||
Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName());
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
// Request code for opening activity.
|
||||
startActivityAndCollapse(PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE));
|
||||
} else {
|
||||
// Deprecated, but still required for older versions.
|
||||
startActivityAndCollapse(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void onTileClick() {
|
||||
UninitializedApp app = UninitializedApp.get();
|
||||
boolean needsToStop;
|
||||
synchronized (lock) {
|
||||
needsToStop = app.isAbleToStartVPN() && isRunning;
|
||||
}
|
||||
if (needsToStop) {
|
||||
app.stopVPN();
|
||||
} else {
|
||||
app.startVPN();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,129 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.tailscale.ipn.ui.model.Ipn
|
||||
import com.tailscale.ipn.ui.theme.AppTheme
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.ui.util.universalFit
|
||||
import com.tailscale.ipn.ui.view.TaildropView
|
||||
import com.tailscale.ipn.util.TSLog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.random.Random
|
||||
|
||||
// ShareActivity is the entry point for Taildrop share intents
|
||||
class ShareActivity : ComponentActivity() {
|
||||
private val TAG = ShareActivity::class.simpleName
|
||||
|
||||
private val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>> = MutableStateFlow(emptyList())
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
AppTheme {
|
||||
Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox
|
||||
Surface(modifier = Modifier.universalFit()) {
|
||||
TaildropView(requestedTransfers, (application as App).applicationScope)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
// Ensure our app instance is initialized
|
||||
App.get()
|
||||
lifecycleScope.launch { withContext(Dispatchers.IO) { loadFiles() } }
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
loadFiles()
|
||||
}
|
||||
|
||||
// Loads the files from the intent.
|
||||
fun loadFiles() {
|
||||
if (intent == null) {
|
||||
TSLog.e(TAG, "Share failure - No intent found")
|
||||
return
|
||||
}
|
||||
|
||||
val act = intent.action
|
||||
|
||||
val uris: List<Uri?>? =
|
||||
when (act) {
|
||||
Intent.ACTION_SEND -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)
|
||||
}
|
||||
}
|
||||
Intent.ACTION_SEND_MULTIPLE -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableArrayListExtra<Uri?>(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION") intent.getParcelableArrayListExtra<Uri?>(Intent.EXTRA_STREAM)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
TSLog.e(TAG, "No extras found in intent - nothing to share")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val pendingFiles: List<Ipn.OutgoingFile> =
|
||||
uris?.filterNotNull()?.mapNotNull {
|
||||
contentResolver?.query(it, null, null, null, null)?.let { c ->
|
||||
val nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
val sizeCol = c.getColumnIndex(OpenableColumns.SIZE)
|
||||
c.moveToFirst()
|
||||
val name: String =
|
||||
c.getString(nameCol)
|
||||
?: run {
|
||||
// For some reason, some content resolvers don't return a name.
|
||||
// Try to build a name from a random integer plus file extension
|
||||
// (if type can be determined), else just a random integer.
|
||||
val rand = Random.nextLong()
|
||||
contentResolver.getType(it)?.let { mimeType ->
|
||||
MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let {
|
||||
extension ->
|
||||
"$rand.$extension"
|
||||
} ?: "$rand"
|
||||
} ?: "$rand"
|
||||
}
|
||||
val size = c.getLong(sizeCol)
|
||||
c.close()
|
||||
val file = Ipn.OutgoingFile(Name = name, DeclaredSize = size)
|
||||
file.uri = it
|
||||
file
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
if (pendingFiles.isEmpty()) {
|
||||
TSLog.e(TAG, "Share failure - no files extracted from intent")
|
||||
}
|
||||
|
||||
requestedTransfers.set(pendingFiles)
|
||||
}
|
||||
}
|
@ -0,0 +1,65 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.VpnService;
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.work.Worker;
|
||||
import androidx.work.WorkerParameters;
|
||||
|
||||
import com.tailscale.ipn.util.TSLog;
|
||||
|
||||
/**
|
||||
* A worker that exists to support IPNReceiver.
|
||||
*/
|
||||
public final class StartVPNWorker extends Worker {
|
||||
|
||||
public StartVPNWorker(Context appContext, WorkerParameters workerParams) {
|
||||
super(appContext, workerParams);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Result doWork() {
|
||||
UninitializedApp app = UninitializedApp.get();
|
||||
boolean ableToStartVPN = app.isAbleToStartVPN();
|
||||
if (ableToStartVPN) {
|
||||
if (VpnService.prepare(app) == null) {
|
||||
// We're ready and have permissions, start the VPN
|
||||
app.startVPN();
|
||||
return Result.success();
|
||||
}
|
||||
}
|
||||
|
||||
// We aren't ready to start the VPN or don't have permission, open the Tailscale app.
|
||||
TSLog.e("StartVPNWorker", "Tailscale isn't ready to start the VPN, notify the user.");
|
||||
|
||||
// Send notification
|
||||
NotificationManager notificationManager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
String channelId = "start_vpn_channel";
|
||||
|
||||
// Use createNotificationChannel method from App.java
|
||||
app.createNotificationChannel(channelId, getApplicationContext().getString(R.string.vpn_start), getApplicationContext().getString(R.string.notifications_delivered_when_user_interaction_is_required_to_establish_the_vpn_tunnel), NotificationManager.IMPORTANCE_HIGH);
|
||||
|
||||
// Use prepareIntent if available.
|
||||
Intent intent = app.getPackageManager().getLaunchIntentForPackage(app.getPackageName());
|
||||
assert intent != null;
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
int pendingIntentFlags = PendingIntent.FLAG_ONE_SHOT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_IMMUTABLE : 0);
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(app, 0, intent, pendingIntentFlags);
|
||||
|
||||
Notification notification = new Notification.Builder(app, channelId).setContentTitle(app.getString(R.string.title_connection_failed)).setContentText(app.getString(R.string.body_open_tailscale)).setSmallIcon(R.drawable.ic_notification).setContentIntent(pendingIntent).setAutoCancel(true).build();
|
||||
|
||||
notificationManager.notify(1, notification);
|
||||
|
||||
return Result.failure();
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.work.Worker;
|
||||
import androidx.work.WorkerParameters;
|
||||
|
||||
/**
|
||||
* A worker that exists to support IPNReceiver.
|
||||
*/
|
||||
public final class StopVPNWorker extends Worker {
|
||||
|
||||
public StopVPNWorker(
|
||||
Context appContext,
|
||||
WorkerParameters workerParams) {
|
||||
super(appContext, workerParams);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Result doWork() {
|
||||
UninitializedApp.get().stopVPN();
|
||||
return Result.success();
|
||||
}
|
||||
}
|
@ -0,0 +1,112 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
package com.tailscale.ipn
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.WorkerParameters
|
||||
import com.tailscale.ipn.UninitializedApp.Companion.STATUS_CHANNEL_ID
|
||||
import com.tailscale.ipn.UninitializedApp.Companion.STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID
|
||||
import com.tailscale.ipn.ui.localapi.Client
|
||||
import com.tailscale.ipn.ui.model.Ipn
|
||||
import com.tailscale.ipn.ui.notifier.Notifier
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
|
||||
class UseExitNodeWorker(
|
||||
appContext: Context,
|
||||
workerParams: WorkerParameters
|
||||
) : CoroutineWorker(appContext, workerParams) {
|
||||
override suspend fun doWork(): Result {
|
||||
val app = UninitializedApp.get()
|
||||
suspend fun runAndGetResult(): String? {
|
||||
val exitNodeName = inputData.getString(EXIT_NODE_NAME)
|
||||
|
||||
val exitNodeId = if (exitNodeName.isNullOrEmpty()) {
|
||||
null
|
||||
} else {
|
||||
if (!app.isAbleToStartVPN()) {
|
||||
return app.getString(R.string.vpn_is_not_ready_to_start)
|
||||
}
|
||||
|
||||
val peers =
|
||||
(Notifier.netmap.value
|
||||
?: run { return@runAndGetResult app.getString(R.string.tailscale_is_not_setup) })
|
||||
.Peers ?: run { return@runAndGetResult app.getString(R.string.no_peers_found) }
|
||||
|
||||
val filteredPeers = peers.filter {
|
||||
it.displayName == exitNodeName
|
||||
}.toList()
|
||||
|
||||
if (filteredPeers.isEmpty()) {
|
||||
return app.getString(R.string.no_peers_with_name_found, exitNodeName)
|
||||
} else if (filteredPeers.size > 1) {
|
||||
return app.getString(R.string.multiple_peers_with_name_found, exitNodeName)
|
||||
} else if (!filteredPeers[0].isExitNode) {
|
||||
return app.getString(
|
||||
R.string.peer_with_name_is_not_an_exit_node,
|
||||
exitNodeName
|
||||
)
|
||||
}
|
||||
|
||||
filteredPeers[0].StableID
|
||||
}
|
||||
|
||||
val allowLanAccess = inputData.getBoolean(ALLOW_LAN_ACCESS, false)
|
||||
val prefsOut = Ipn.MaskedPrefs()
|
||||
prefsOut.ExitNodeID = exitNodeId
|
||||
prefsOut.ExitNodeAllowLANAccess = allowLanAccess
|
||||
|
||||
val scope = CoroutineScope(Dispatchers.Default + Job())
|
||||
var result: String? = null
|
||||
Client(scope).editPrefs(prefsOut) {
|
||||
result = if (it.isFailure) {
|
||||
it.exceptionOrNull()?.message
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
scope.coroutineContext[Job]?.join()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
val result = runAndGetResult()
|
||||
|
||||
return if (result != null) {
|
||||
val intent =
|
||||
Intent(app, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
val pendingIntent: PendingIntent =
|
||||
PendingIntent.getActivity(
|
||||
app, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
val notification = NotificationCompat.Builder(app, STATUS_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(app.getString(R.string.use_exit_node_intent_failed))
|
||||
.setContentText(result)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
|
||||
app.notifyStatus(notification)
|
||||
|
||||
Result.failure(Data.Builder().putString(ERROR_KEY, result).build())
|
||||
} else {
|
||||
Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXIT_NODE_NAME = "EXIT_NODE_NAME"
|
||||
const val ALLOW_LAN_ACCESS = "ALLOW_LAN_ACCESS"
|
||||
const val ERROR_KEY = "error"
|
||||
}
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn
|
||||
|
||||
import android.net.VpnService
|
||||
import libtailscale.ParcelFileDescriptor
|
||||
import java.net.InetAddress
|
||||
import android.net.IpPrefix as AndroidIpPrefix
|
||||
|
||||
class VPNServiceBuilder(private val builder: VpnService.Builder) : libtailscale.VPNServiceBuilder {
|
||||
override fun addAddress(p0: String, p1: Int) {
|
||||
builder.addAddress(p0, p1)
|
||||
}
|
||||
|
||||
override fun addDNSServer(p0: String) {
|
||||
builder.addDnsServer(p0)
|
||||
}
|
||||
|
||||
override fun addRoute(p0: String, p1: Int) {
|
||||
builder.addRoute(p0, p1)
|
||||
}
|
||||
|
||||
override fun excludeRoute(p0: String, p1: Int) {
|
||||
val inetAddress = InetAddress.getByName(p0)
|
||||
val prefix = AndroidIpPrefix(inetAddress, p1)
|
||||
builder.excludeRoute(prefix)
|
||||
}
|
||||
|
||||
override fun addSearchDomain(p0: String) {
|
||||
builder.addSearchDomain(p0)
|
||||
}
|
||||
|
||||
override fun establish(): ParcelFileDescriptor? {
|
||||
return builder.establish()?.let { ParcelFileDescriptor(it) }
|
||||
}
|
||||
|
||||
override fun setMTU(p0: Int) {
|
||||
builder.setMtu(p0)
|
||||
}
|
||||
}
|
||||
|
||||
class ParcelFileDescriptor(private val fd: android.os.ParcelFileDescriptor) :
|
||||
libtailscale.ParcelFileDescriptor {
|
||||
override fun detach(): Int {
|
||||
return fd.detachFd()
|
||||
}
|
||||
}
|
@ -0,0 +1,115 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.mdm
|
||||
|
||||
import android.content.RestrictionsManager
|
||||
import com.tailscale.ipn.App
|
||||
import kotlin.reflect.KVisibility
|
||||
import kotlin.reflect.full.declaredMemberProperties
|
||||
import kotlin.reflect.full.isSubclassOf
|
||||
import kotlin.reflect.jvm.jvmErasure
|
||||
|
||||
object MDMSettings {
|
||||
// The String message used in this NoSuchKeyException must match the value of
|
||||
// syspolicy.ErrNoSuchKey defined in Go. We compare against its exact text
|
||||
// to determine whether the requested policy setting is not configured and
|
||||
// an actual syspolicy.ErrNoSuchKey should be returned from syspolicyHandler
|
||||
// to the backend.
|
||||
class NoSuchKeyException : Exception("no such key")
|
||||
|
||||
val forceEnabled = BooleanMDMSetting("ForceEnabled", "Force Enabled Connection Toggle")
|
||||
|
||||
// Handled on the backed
|
||||
val exitNodeID = StringMDMSetting("ExitNodeID", "Forced Exit Node: Stable ID")
|
||||
|
||||
// (jonathan) TODO: Unused but required. There is some funky go string duration parsing required
|
||||
// here.
|
||||
val keyExpirationNotice = StringMDMSetting("KeyExpirationNotice", "Key Expiration Notice Period")
|
||||
|
||||
val loginURL = StringMDMSetting("LoginURL", "Custom control server URL")
|
||||
|
||||
val managedByCaption = StringMDMSetting("ManagedByCaption", "Managed By - Caption")
|
||||
|
||||
val managedByOrganizationName =
|
||||
StringMDMSetting("ManagedByOrganizationName", "Managed By - Organization Name")
|
||||
|
||||
val managedByURL = StringMDMSetting("ManagedByURL", "Managed By - Support URL")
|
||||
|
||||
// Handled on the backend
|
||||
val tailnet = StringMDMSetting("Tailnet", "Recommended/Required Tailnet Name")
|
||||
|
||||
val hiddenNetworkDevices =
|
||||
StringArrayListMDMSetting("HiddenNetworkDevices", "Hidden Network Device Categories")
|
||||
|
||||
// Unused on Android
|
||||
val allowIncomingConnections =
|
||||
AlwaysNeverUserDecidesMDMSetting("AllowIncomingConnections", "Allow Incoming Connections")
|
||||
|
||||
// Unused on Android
|
||||
val detectThirdPartyAppConflicts =
|
||||
AlwaysNeverUserDecidesMDMSetting(
|
||||
"DetectThirdPartyAppConflicts", "Detect potentially problematic third-party apps")
|
||||
|
||||
val exitNodeAllowLANAccess =
|
||||
AlwaysNeverUserDecidesMDMSetting(
|
||||
"ExitNodeAllowLANAccess", "Allow LAN Access when using an exit node")
|
||||
|
||||
// Handled on the backend
|
||||
val postureChecking =
|
||||
AlwaysNeverUserDecidesMDMSetting("PostureChecking", "Enable Posture Checking")
|
||||
|
||||
val useTailscaleDNSSettings =
|
||||
AlwaysNeverUserDecidesMDMSetting("UseTailscaleDNSSettings", "Use Tailscale DNS Settings")
|
||||
|
||||
// Unused on Android
|
||||
val useTailscaleSubnets =
|
||||
AlwaysNeverUserDecidesMDMSetting("UseTailscaleSubnets", "Use Tailscale Subnets")
|
||||
|
||||
val exitNodesPicker = ShowHideMDMSetting("ExitNodesPicker", "Exit Nodes Picker")
|
||||
|
||||
val manageTailnetLock = ShowHideMDMSetting("ManageTailnetLock", "“Manage Tailnet lock” menu item")
|
||||
|
||||
// Unused on Android
|
||||
val resetToDefaults = ShowHideMDMSetting("ResetToDefaults", "“Reset to Defaults” menu item")
|
||||
|
||||
val runExitNode = ShowHideMDMSetting("RunExitNode", "Run as Exit Node")
|
||||
|
||||
// Unused on Android
|
||||
val testMenu = ShowHideMDMSetting("TestMenu", "Show Debug Menu")
|
||||
|
||||
// Unused on Android
|
||||
val updateMenu = ShowHideMDMSetting("UpdateMenu", "“Update Available” menu item")
|
||||
|
||||
// (jonathan) TODO: Use this when suggested exit nodes are implemented
|
||||
val allowedSuggestedExitNodes =
|
||||
StringArrayListMDMSetting("AllowedSuggestedExitNodes", "Allowed Suggested Exit Nodes")
|
||||
|
||||
// Allows admins to define a list of packages that won't be routed via Tailscale.
|
||||
val excludedPackages = StringMDMSetting("ExcludedPackageNames", "Excluded Package Names")
|
||||
// Allows admins to define a list of packages that will be routed via Tailscale, letting all other
|
||||
// apps skip the VPN tunnel.
|
||||
val includedPackages = StringMDMSetting("IncludedPackageNames", "Included Package Names")
|
||||
|
||||
// Handled on the backend
|
||||
val authKey = StringMDMSetting("AuthKey", "Auth Key for login")
|
||||
|
||||
val allSettings by lazy {
|
||||
MDMSettings::class
|
||||
.declaredMemberProperties
|
||||
.filter {
|
||||
it.visibility == KVisibility.PUBLIC &&
|
||||
it.returnType.jvmErasure.isSubclassOf(MDMSetting::class)
|
||||
}
|
||||
.map { it.call(MDMSettings) as MDMSetting<*> }
|
||||
}
|
||||
|
||||
val allSettingsByKey by lazy { allSettings.associateBy { it.key } }
|
||||
|
||||
fun update(app: App, restrictionsManager: RestrictionsManager?) {
|
||||
val bundle = restrictionsManager?.applicationRestrictions
|
||||
val preferences = lazy { app.getEncryptedPrefs() }
|
||||
allSettings.forEach { it.setFrom(bundle, preferences) }
|
||||
app.notifyPolicyChanged()
|
||||
}
|
||||
}
|
@ -0,0 +1,121 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.mdm
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import com.tailscale.ipn.App
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
data class SettingState<T>(val value: T, val isSet: Boolean)
|
||||
|
||||
abstract class MDMSetting<T>(defaultValue: T, val key: String, val localizedTitle: String) {
|
||||
val defaultValue = defaultValue
|
||||
val flow = MutableStateFlow(SettingState(defaultValue, false))
|
||||
|
||||
fun setFrom(bundle: Bundle?, prefs: Lazy<SharedPreferences>) {
|
||||
val v: T? = getFrom(bundle, prefs)
|
||||
flow.set(SettingState(v ?: defaultValue, v != null))
|
||||
}
|
||||
|
||||
fun getFrom(bundle: Bundle?, prefs: Lazy<SharedPreferences>): T? {
|
||||
return when {
|
||||
bundle != null -> bundle.takeIf { it.containsKey(key) }?.let { getFromBundle(it) }
|
||||
else -> prefs.value.takeIf { it.contains(key) }?.let { getFromPrefs(it) }
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract fun getFromBundle(bundle: Bundle): T
|
||||
protected abstract fun getFromPrefs(prefs: SharedPreferences): T
|
||||
}
|
||||
|
||||
class BooleanMDMSetting(key: String, localizedTitle: String) :
|
||||
MDMSetting<Boolean>(false, key, localizedTitle) {
|
||||
override fun getFromBundle(bundle: Bundle) = bundle.getBoolean(key)
|
||||
override fun getFromPrefs(prefs: SharedPreferences) = prefs.getBoolean(key, false)
|
||||
}
|
||||
|
||||
class StringMDMSetting(key: String, localizedTitle: String) :
|
||||
MDMSetting<String?>(null, key, localizedTitle) {
|
||||
override fun getFromBundle(bundle: Bundle) = bundle.getString(key)
|
||||
override fun getFromPrefs(prefs: SharedPreferences) = prefs.getString(key, null)
|
||||
}
|
||||
|
||||
class StringArrayListMDMSetting(key: String, localizedTitle: String) :
|
||||
MDMSetting<List<String>?>(null, key, localizedTitle) {
|
||||
override fun getFromBundle(bundle: Bundle): List<String>? {
|
||||
// Try to retrieve the value as a String[] first
|
||||
val stringArray = bundle.getStringArray(key)
|
||||
if (stringArray != null) {
|
||||
return stringArray.toList()
|
||||
}
|
||||
|
||||
// Optionally, handle other types if necessary
|
||||
val stringArrayList = bundle.getStringArrayList(key)
|
||||
if (stringArrayList != null) {
|
||||
return stringArrayList
|
||||
}
|
||||
|
||||
// If neither String[] nor ArrayList<String> is found, return null
|
||||
return null
|
||||
}
|
||||
|
||||
override fun getFromPrefs(prefs: SharedPreferences): List<String>? {
|
||||
return prefs.getStringSet(key, HashSet<String>())?.toList()
|
||||
}
|
||||
}
|
||||
|
||||
class AlwaysNeverUserDecidesMDMSetting(key: String, localizedTitle: String) :
|
||||
MDMSetting<AlwaysNeverUserDecides>(AlwaysNeverUserDecides.UserDecides, key, localizedTitle) {
|
||||
override fun getFromBundle(bundle: Bundle) =
|
||||
AlwaysNeverUserDecides.fromString(bundle.getString(key))
|
||||
override fun getFromPrefs(prefs: SharedPreferences) =
|
||||
AlwaysNeverUserDecides.fromString(prefs.getString(key, null))
|
||||
}
|
||||
|
||||
class ShowHideMDMSetting(key: String, localizedTitle: String) :
|
||||
MDMSetting<ShowHide>(ShowHide.Show, key, localizedTitle) {
|
||||
override fun getFromBundle(bundle: Bundle) =
|
||||
ShowHide.fromString(bundle.getString(key))
|
||||
override fun getFromPrefs(prefs: SharedPreferences) =
|
||||
ShowHide.fromString(prefs.getString(key, null))
|
||||
}
|
||||
|
||||
enum class AlwaysNeverUserDecides(val value: String) {
|
||||
Always("always"),
|
||||
Never("never"),
|
||||
UserDecides("user-decides");
|
||||
|
||||
val hiddenFromUser: Boolean
|
||||
get() {
|
||||
return this != UserDecides
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return value
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String?): AlwaysNeverUserDecides {
|
||||
return values().find { it.value == value } ?: UserDecides
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class ShowHide(val value: String) {
|
||||
Show("show"),
|
||||
Hide("hide");
|
||||
|
||||
override fun toString(): String {
|
||||
return value
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromString(value: String?): ShowHide {
|
||||
return ShowHide.values().find { it.value == value } ?: Show
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui
|
||||
|
||||
object Links {
|
||||
const val DEFAULT_CONTROL_URL = "https://controlplane.tailscale.com"
|
||||
const val SERVER_URL = "https://login.tailscale.com"
|
||||
const val ADMIN_URL = SERVER_URL + "/admin"
|
||||
const val SIGNIN_URL = "https://tailscale.com/login"
|
||||
const val PRIVACY_POLICY_URL = "https://tailscale.com/privacy-policy/"
|
||||
const val TERMS_URL = "https://tailscale.com/terms"
|
||||
const val DOCS_URL = "https://tailscale.com/kb/"
|
||||
const val START_GUIDE_URL = "https://tailscale.com/kb/1017/install/"
|
||||
const val LICENSES_URL = "https://tailscale.com/licenses/android"
|
||||
const val DELETE_ACCOUNT_URL =
|
||||
"https://login.tailscale.com/login?next_url=%2Fadmin%2Fsettings%2Fgeneral"
|
||||
const val TAILNET_LOCK_KB_URL = "https://tailscale.com/kb/1226/tailnet-lock/"
|
||||
const val KEY_EXPIRY_KB_URL = "https://tailscale.com/kb/1028/key-expiry/"
|
||||
const val INSTALL_TAILSCALE_KB_URL = "https://tailscale.com/kb/installation/"
|
||||
const val INSTALL_UNSTABLE_KB_URL = "https://tailscale.com/kb/1083/install-unstable"
|
||||
const val MAGICDNS_KB_URL = "https://tailscale.com/kb/1081/magicdns"
|
||||
const val TROUBLESHOOTING_KB_URL = "https://tailscale.com/kb/1023/troubleshooting"
|
||||
const val SUPPORT_URL = "https://tailscale.com/contact/support#support-form"
|
||||
const val TAILDROP_KB_URL = "https://tailscale.com/kb/1106/taildrop"
|
||||
const val TAILFS_KB_URL = "https://tailscale.com/kb/1106/taildrop"
|
||||
}
|
@ -0,0 +1,368 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.localapi
|
||||
|
||||
import android.content.Context
|
||||
import com.tailscale.ipn.ui.model.BugReportID
|
||||
import com.tailscale.ipn.ui.model.Errors
|
||||
import com.tailscale.ipn.ui.model.Ipn
|
||||
import com.tailscale.ipn.ui.model.IpnLocal
|
||||
import com.tailscale.ipn.ui.model.IpnState
|
||||
import com.tailscale.ipn.ui.model.StableNodeID
|
||||
import com.tailscale.ipn.ui.model.Tailcfg
|
||||
import com.tailscale.ipn.ui.util.InputStreamAdapter
|
||||
import com.tailscale.ipn.util.TSLog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import kotlinx.serialization.serializer
|
||||
import libtailscale.FilePart
|
||||
import java.nio.charset.Charset
|
||||
import kotlin.reflect.KType
|
||||
import kotlin.reflect.typeOf
|
||||
|
||||
private object Endpoint {
|
||||
const val DEBUG = "debug"
|
||||
const val DEBUG_LOG = "debug-log"
|
||||
const val BUG_REPORT = "bugreport"
|
||||
const val PREFS = "prefs"
|
||||
const val FILE_TARGETS = "file-targets"
|
||||
const val UPLOAD_METRICS = "upload-client-metrics"
|
||||
const val START = "start"
|
||||
const val LOGIN_INTERACTIVE = "login-interactive"
|
||||
const val RESET_AUTH = "reset-auth"
|
||||
const val LOGOUT = "logout"
|
||||
const val PROFILES = "profiles/"
|
||||
const val PROFILES_CURRENT = "profiles/current"
|
||||
const val STATUS = "status"
|
||||
const val TKA_STATUS = "tka/status"
|
||||
const val TKA_SIGN = "tka/sign"
|
||||
const val TKA_VERIFY_DEEP_LINK = "tka/verify-deeplink"
|
||||
const val PING = "ping"
|
||||
const val FILES = "files"
|
||||
const val FILE_PUT = "file-put"
|
||||
const val TAILFS_SERVER_ADDRESS = "tailfs/fileserver-address"
|
||||
const val ENABLE_EXIT_NODE = "set-use-exit-node-enabled"
|
||||
}
|
||||
|
||||
typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit
|
||||
|
||||
typealias TailnetLockStatusResponseHandler = (Result<IpnState.NetworkLockStatus>) -> Unit
|
||||
|
||||
typealias BugReportIdHandler = (Result<BugReportID>) -> Unit
|
||||
|
||||
typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
|
||||
|
||||
typealias PingResultHandler = (Result<IpnState.PingResult>) -> Unit
|
||||
|
||||
/**
|
||||
* Client provides a mechanism for calling Go's LocalAPIClient. Every LocalAPI endpoint has a
|
||||
* corresponding method on this Client.
|
||||
*/
|
||||
class Client(private val scope: CoroutineScope) {
|
||||
private val TAG = Client::class.simpleName
|
||||
|
||||
fun start(options: Ipn.Options, responseHandler: (Result<Unit>) -> Unit) {
|
||||
val body = Json.encodeToString(options).toByteArray()
|
||||
return post(Endpoint.START, body, responseHandler = responseHandler)
|
||||
}
|
||||
|
||||
fun status(responseHandler: StatusResponseHandler) {
|
||||
get(Endpoint.STATUS, responseHandler = responseHandler)
|
||||
}
|
||||
|
||||
fun ping(peer: Tailcfg.Node, responseHandler: PingResultHandler) {
|
||||
val ip = peer.primaryIPv4Address.orEmpty()
|
||||
if (ip.isEmpty()) {
|
||||
responseHandler(Result.failure(Exception("No IP address for peer $peer")))
|
||||
return
|
||||
}
|
||||
|
||||
val path = "${Endpoint.PING}?ip=${ip}&type=disco"
|
||||
post(path, timeoutMillis = 2000L, responseHandler = responseHandler)
|
||||
}
|
||||
|
||||
fun bugReportId(responseHandler: BugReportIdHandler) {
|
||||
post(Endpoint.BUG_REPORT, responseHandler = responseHandler)
|
||||
}
|
||||
|
||||
fun prefs(responseHandler: PrefsHandler) {
|
||||
get(Endpoint.PREFS, responseHandler = responseHandler)
|
||||
}
|
||||
|
||||
fun editPrefs(prefs: Ipn.MaskedPrefs, responseHandler: (Result<Ipn.Prefs>) -> Unit) {
|
||||
val body = Json.encodeToString(prefs).toByteArray()
|
||||
return patch(Endpoint.PREFS, body, responseHandler = responseHandler)
|
||||
}
|
||||
|
||||
fun setUseExitNode(use: Boolean, responseHandler: (Result<Ipn.Prefs>) -> Unit) {
|
||||
val path = "${Endpoint.ENABLE_EXIT_NODE}?enabled=$use"
|
||||
return post(path, responseHandler = responseHandler)
|
||||
}
|
||||
|
||||
fun profiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit) {
|
||||
get(Endpoint.PROFILES, responseHandler = responseHandler)
|
||||
}
|
||||
|
||||
fun currentProfile(responseHandler: (Result<IpnLocal.LoginProfile>) -> Unit) {
|
||||
return get(Endpoint.PROFILES_CURRENT, responseHandler = responseHandler)
|
||||
}
|
||||
|
||||
fun addProfile(responseHandler: (Result<String>) -> Unit = {}) {
|
||||
return put(Endpoint.PROFILES, responseHandler = responseHandler)
|
||||
}
|
||||
|
||||
fun deleteProfile(
|
||||
profile: IpnLocal.LoginProfile,
|
||||
responseHandler: (Result<String>) -> Unit = {}
|
||||
) {
|
||||
return delete(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
|
||||
}
|
||||
|
||||
fun switchProfile(
|
||||
profile: IpnLocal.LoginProfile,
|
||||
responseHandler: (Result<String>) -> Unit = {}
|
||||
) {
|
||||
return post(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
|
||||
}
|
||||
|
||||
fun startLoginInteractive(responseHandler: (Result<Unit>) -> Unit) {
|
||||
return post(Endpoint.LOGIN_INTERACTIVE, responseHandler = responseHandler)
|
||||
}
|
||||
|
||||
fun logout(responseHandler: (Result<String>) -> Unit) {
|
||||
return post(Endpoint.LOGOUT, responseHandler = responseHandler)
|
||||
}
|
||||
|
||||
fun tailnetLockStatus(responseHandler: TailnetLockStatusResponseHandler) {
|
||||
get(Endpoint.TKA_STATUS, responseHandler = responseHandler)
|
||||
}
|
||||
|
||||
fun fileTargets(responseHandler: (Result<List<Ipn.FileTarget>>) -> Unit) {
|
||||
get(Endpoint.FILE_TARGETS, responseHandler = responseHandler)
|
||||
}
|
||||
|
||||
fun putTaildropFiles(
|
||||
context: Context,
|
||||
peerId: StableNodeID,
|
||||
files: Collection<Ipn.OutgoingFile>,
|
||||
responseHandler: (Result<String>) -> Unit
|
||||
) {
|
||||
val manifest = Json.encodeToString(files)
|
||||
val manifestPart = FilePart()
|
||||
manifestPart.body = InputStreamAdapter(manifest.byteInputStream(Charset.defaultCharset()))
|
||||
manifestPart.filename = "manifest.json"
|
||||
manifestPart.contentType = "application/json"
|
||||
val parts = mutableListOf(manifestPart)
|
||||
|
||||
try {
|
||||
parts.addAll(
|
||||
files.map { file ->
|
||||
val stream =
|
||||
context.contentResolver.openInputStream(file.uri)
|
||||
?: throw Exception("Error opening file stream")
|
||||
|
||||
val part = FilePart()
|
||||
part.filename = file.Name
|
||||
part.contentLength = file.DeclaredSize
|
||||
part.body = InputStreamAdapter(stream)
|
||||
part
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
parts.forEach { it.body.close() }
|
||||
TSLog.e(TAG, "Error creating file upload body: $e")
|
||||
responseHandler(Result.failure(e))
|
||||
return
|
||||
}
|
||||
|
||||
return postMultipart(
|
||||
"${Endpoint.FILE_PUT}/${peerId}",
|
||||
FileParts(parts),
|
||||
responseHandler,
|
||||
)
|
||||
}
|
||||
|
||||
private inline fun <reified T> get(
|
||||
path: String,
|
||||
body: ByteArray? = null,
|
||||
noinline responseHandler: (Result<T>) -> Unit
|
||||
) {
|
||||
Request(
|
||||
scope = scope,
|
||||
method = "GET",
|
||||
path = path,
|
||||
body = body,
|
||||
responseType = typeOf<T>(),
|
||||
responseHandler = responseHandler)
|
||||
.execute()
|
||||
}
|
||||
|
||||
private inline fun <reified T> put(
|
||||
path: String,
|
||||
body: ByteArray? = null,
|
||||
noinline responseHandler: (Result<T>) -> Unit
|
||||
) {
|
||||
Request(
|
||||
scope = scope,
|
||||
method = "PUT",
|
||||
path = path,
|
||||
body = body,
|
||||
responseType = typeOf<T>(),
|
||||
responseHandler = responseHandler)
|
||||
.execute()
|
||||
}
|
||||
|
||||
private inline fun <reified T> post(
|
||||
path: String,
|
||||
body: ByteArray? = null,
|
||||
timeoutMillis: Long = 30000,
|
||||
noinline responseHandler: (Result<T>) -> Unit
|
||||
) {
|
||||
Request(
|
||||
scope = scope,
|
||||
method = "POST",
|
||||
path = path,
|
||||
body = body,
|
||||
timeoutMillis = timeoutMillis,
|
||||
responseType = typeOf<T>(),
|
||||
responseHandler = responseHandler)
|
||||
.execute()
|
||||
}
|
||||
|
||||
private inline fun <reified T> postMultipart(
|
||||
path: String,
|
||||
parts: FileParts,
|
||||
noinline responseHandler: (Result<T>) -> Unit
|
||||
) {
|
||||
Request(
|
||||
scope = scope,
|
||||
method = "POST",
|
||||
path = path,
|
||||
parts = parts,
|
||||
timeoutMillis = 24 * 60 * 60 * 1000, // 24 hours
|
||||
responseType = typeOf<T>(),
|
||||
responseHandler = responseHandler)
|
||||
.execute()
|
||||
}
|
||||
|
||||
private inline fun <reified T> patch(
|
||||
path: String,
|
||||
body: ByteArray? = null,
|
||||
noinline responseHandler: (Result<T>) -> Unit
|
||||
) {
|
||||
Request(
|
||||
scope = scope,
|
||||
method = "PATCH",
|
||||
path = path,
|
||||
body = body,
|
||||
responseType = typeOf<T>(),
|
||||
responseHandler = responseHandler)
|
||||
.execute()
|
||||
}
|
||||
|
||||
private inline fun <reified T> delete(
|
||||
path: String,
|
||||
noinline responseHandler: (Result<T>) -> Unit
|
||||
) {
|
||||
Request(
|
||||
scope = scope,
|
||||
method = "DELETE",
|
||||
path = path,
|
||||
responseType = typeOf<T>(),
|
||||
responseHandler = responseHandler)
|
||||
.execute()
|
||||
}
|
||||
}
|
||||
|
||||
class Request<T>(
|
||||
private val scope: CoroutineScope,
|
||||
private val method: String,
|
||||
path: String,
|
||||
private val body: ByteArray? = null,
|
||||
private val parts: FileParts? = null,
|
||||
private val timeoutMillis: Long = 30000,
|
||||
private val responseType: KType,
|
||||
private val responseHandler: (Result<T>) -> Unit
|
||||
) {
|
||||
private val fullPath = "/localapi/v0/$path"
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LocalAPIRequest"
|
||||
|
||||
private val jsonDecoder = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private lateinit var app: libtailscale.Application
|
||||
|
||||
@JvmStatic
|
||||
fun setApp(newApp: libtailscale.Application) {
|
||||
app = newApp
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
fun execute() {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
TSLog.d(TAG, "Executing request:${method}:${fullPath} on app $app")
|
||||
try {
|
||||
val resp =
|
||||
if (parts != null) app.callLocalAPIMultipart(timeoutMillis, method, fullPath, parts)
|
||||
else
|
||||
app.callLocalAPI(
|
||||
timeoutMillis,
|
||||
method,
|
||||
fullPath,
|
||||
body?.let { InputStreamAdapter(it.inputStream()) })
|
||||
// TODO: use the streaming body for performance
|
||||
// An empty body is a perfectly valid response and indicates success
|
||||
val respData = resp.bodyBytes() ?: ByteArray(0)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val response: Result<T> =
|
||||
when (responseType) {
|
||||
typeOf<String>() -> Result.success(respData.decodeToString() as T)
|
||||
typeOf<Unit>() -> Result.success(Unit as T)
|
||||
else ->
|
||||
try {
|
||||
Result.success(
|
||||
jsonDecoder.decodeFromStream(
|
||||
Json.serializersModule.serializer(responseType), respData.inputStream())
|
||||
as T)
|
||||
} catch (t: Throwable) {
|
||||
// If we couldn't parse the response body, assume it's an error response
|
||||
try {
|
||||
val error =
|
||||
jsonDecoder.decodeFromStream<Errors.GenericError>(respData.inputStream())
|
||||
throw Exception(error.error)
|
||||
} catch (t: Throwable) {
|
||||
Result.failure(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (resp.statusCode() >= 400) {
|
||||
throw Exception(
|
||||
"Request failed with status ${resp.statusCode()}: ${respData.toString(Charset.defaultCharset())}")
|
||||
}
|
||||
// The response handler will invoked internally by the request parser
|
||||
scope.launch { responseHandler(response) }
|
||||
} catch (e: Exception) {
|
||||
TSLog.e(TAG, "Error executing request:${method}:${fullPath}: $e")
|
||||
scope.launch { responseHandler(Result.failure(e)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FileParts(private val parts: List<FilePart>) : libtailscale.FileParts {
|
||||
override fun get(i: Int): FilePart {
|
||||
return parts[i]
|
||||
}
|
||||
|
||||
override fun len(): Int {
|
||||
return parts.size
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
class Dns {
|
||||
@Serializable data class HostEntry(val addr: Addr?, val hosts: List<String>?)
|
||||
|
||||
@Serializable
|
||||
data class OSConfig(
|
||||
val hosts: List<HostEntry>? = null,
|
||||
val nameservers: List<Addr>? = null,
|
||||
val searchDomains: List<String>? = null,
|
||||
val matchDomains: List<String>? = null,
|
||||
) {
|
||||
val isEmpty: Boolean
|
||||
get() =
|
||||
(hosts.isNullOrEmpty()) &&
|
||||
(nameservers.isNullOrEmpty()) &&
|
||||
(searchDomains.isNullOrEmpty()) &&
|
||||
(matchDomains.isNullOrEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
class DnsType {
|
||||
@Serializable
|
||||
data class Resolver(var Addr: String? = null, var BootstrapResolution: List<Addr>? = null)
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.model
|
||||
|
||||
import androidx.compose.material3.ListItemColors
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.tailscale.ipn.ui.theme.warning
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
class Health {
|
||||
@Serializable
|
||||
data class State(
|
||||
// WarnableCode -> UnhealthyState or null
|
||||
var Warnings: Map<String, UnhealthyState?>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UnhealthyState(
|
||||
var WarnableCode: String,
|
||||
var Severity: Severity,
|
||||
var Title: String,
|
||||
var Text: String,
|
||||
var BrokenSince: String? = null,
|
||||
var Args: Map<String, String>? = null,
|
||||
var ImpactsConnectivity: Boolean? = false,
|
||||
var DependsOn: List<String>? = null, // an array of WarnableCodes this depends on
|
||||
) : Comparable<UnhealthyState> {
|
||||
fun hiddenByDependencies(currentWarnableCodes: Set<String>): Boolean {
|
||||
return this.DependsOn?.let {
|
||||
it.any { depWarnableCode -> currentWarnableCodes.contains(depWarnableCode) }
|
||||
} == true
|
||||
}
|
||||
|
||||
override fun compareTo(other: UnhealthyState): Int {
|
||||
// Compare by severity first
|
||||
val severityComparison = Severity.compareTo(other.Severity)
|
||||
if (severityComparison != 0) {
|
||||
return severityComparison
|
||||
}
|
||||
|
||||
// If severities are equal, compare by warnableCode
|
||||
return WarnableCode.compareTo(other.WarnableCode)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class Severity : Comparable<Severity> {
|
||||
low,
|
||||
medium,
|
||||
high;
|
||||
|
||||
@Composable
|
||||
fun listItemColors(): ListItemColors {
|
||||
val default = ListItemDefaults.colors()
|
||||
return when (this) {
|
||||
Severity.low ->
|
||||
ListItemColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
headlineColor = MaterialTheme.colorScheme.secondary,
|
||||
leadingIconColor = MaterialTheme.colorScheme.secondary,
|
||||
overlineColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.8f),
|
||||
supportingTextColor = MaterialTheme.colorScheme.secondary,
|
||||
trailingIconColor = MaterialTheme.colorScheme.secondary,
|
||||
disabledHeadlineColor = default.disabledHeadlineColor,
|
||||
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
||||
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
||||
Severity.medium,
|
||||
Severity.high ->
|
||||
ListItemColors(
|
||||
containerColor = MaterialTheme.colorScheme.warning,
|
||||
headlineColor = MaterialTheme.colorScheme.onPrimary,
|
||||
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
|
||||
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f),
|
||||
supportingTextColor = MaterialTheme.colorScheme.onPrimary,
|
||||
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
|
||||
disabledHeadlineColor = default.disabledHeadlineColor,
|
||||
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
||||
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,240 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.model
|
||||
|
||||
import android.net.Uri
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import java.util.UUID
|
||||
|
||||
class Ipn {
|
||||
|
||||
// Represents the overall state of the Tailscale engine.
|
||||
enum class State(val value: Int) {
|
||||
NoState(0),
|
||||
InUseOtherUser(1),
|
||||
NeedsLogin(2),
|
||||
NeedsMachineAuth(3),
|
||||
Stopped(4),
|
||||
Starting(5),
|
||||
Running(6),
|
||||
// Stopping represents a state where a request to stop Tailscale has been issue but has not
|
||||
// completed. This state allows UI to optimistically reflect a stopped state, and to fallback if
|
||||
// necessary.
|
||||
Stopping(7);
|
||||
|
||||
companion object {
|
||||
fun fromInt(value: Int): State {
|
||||
return State.values().firstOrNull { it.value == value } ?: NoState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A nofitication message recieved on the Notify bus. Fields will be populated based
|
||||
// on which NotifyWatchOpts were set when the Notifier was created.
|
||||
@Serializable
|
||||
data class Notify(
|
||||
val Version: String? = null,
|
||||
val ErrMessage: String? = null,
|
||||
val LoginFinished: Empty.Message? = null,
|
||||
val FilesWaiting: Empty.Message? = null,
|
||||
val OutgoingFiles: List<OutgoingFile>? = null,
|
||||
val State: Int? = null,
|
||||
var Prefs: Prefs? = null,
|
||||
var NetMap: Netmap.NetworkMap? = null,
|
||||
var Engine: EngineStatus? = null,
|
||||
var BrowseToURL: String? = null,
|
||||
var BackendLogId: String? = null,
|
||||
var LocalTCPPort: Int? = null,
|
||||
var IncomingFiles: List<PartialFile>? = null,
|
||||
var ClientVersion: Tailcfg.ClientVersion? = null,
|
||||
var TailFSShares: List<String>? = null,
|
||||
var Health: Health.State? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Prefs(
|
||||
var ControlURL: String = "",
|
||||
var RouteAll: Boolean = false,
|
||||
var AllowsSingleHosts: Boolean = false,
|
||||
var CorpDNS: Boolean = false,
|
||||
var WantRunning: Boolean = false,
|
||||
var LoggedOut: Boolean = false,
|
||||
var ShieldsUp: Boolean = false,
|
||||
var AdvertiseRoutes: List<String>? = null,
|
||||
var AdvertiseTags: List<String>? = null,
|
||||
var ExitNodeID: StableNodeID? = null,
|
||||
var ExitNodeAllowLANAccess: Boolean = false,
|
||||
var Config: Persist.Persist? = null,
|
||||
var ForceDaemon: Boolean = false,
|
||||
var HostName: String = "",
|
||||
var AutoUpdate: AutoUpdatePrefs? = AutoUpdatePrefs(true, true),
|
||||
var InternalExitNodePrior: String? = null,
|
||||
) {
|
||||
|
||||
// For the InternalExitNodePrior and ExitNodeId, these will treats the empty string as null to
|
||||
// simplify the downstream logic.
|
||||
|
||||
val selectedExitNodeID: String?
|
||||
get() {
|
||||
return if (InternalExitNodePrior.isNullOrEmpty()) null else InternalExitNodePrior
|
||||
}
|
||||
|
||||
val activeExitNodeID: String?
|
||||
get() {
|
||||
return if (ExitNodeID.isNullOrEmpty()) null else ExitNodeID
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class MaskedPrefs(
|
||||
var ControlURLSet: Boolean? = null,
|
||||
var RouteAllSet: Boolean? = null,
|
||||
var CorpDNSSet: Boolean? = null,
|
||||
var ExitNodeIDSet: Boolean? = null,
|
||||
var ExitNodeAllowLANAccessSet: Boolean? = null,
|
||||
var WantRunningSet: Boolean? = null,
|
||||
var ShieldsUpSet: Boolean? = null,
|
||||
var AdvertiseRoutesSet: Boolean? = null,
|
||||
var ForceDaemonSet: Boolean? = null,
|
||||
var HostnameSet: Boolean? = null,
|
||||
var InternalExitNodePriorSet: Boolean? = null,
|
||||
) {
|
||||
|
||||
var ControlURL: String? = null
|
||||
set(value) {
|
||||
field = value
|
||||
ControlURLSet = true
|
||||
}
|
||||
|
||||
var RouteAll: Boolean? = null
|
||||
set(value) {
|
||||
field = value
|
||||
RouteAllSet = true
|
||||
}
|
||||
|
||||
var CorpDNS: Boolean? = null
|
||||
set(value) {
|
||||
field = value
|
||||
CorpDNSSet = true
|
||||
}
|
||||
|
||||
var ExitNodeID: StableNodeID? = null
|
||||
set(value) {
|
||||
field = value
|
||||
ExitNodeIDSet = true
|
||||
}
|
||||
|
||||
var InternalExitNodePrior: String? = null
|
||||
set(value) {
|
||||
field = value
|
||||
InternalExitNodePriorSet = true
|
||||
}
|
||||
|
||||
var ExitNodeAllowLANAccess: Boolean? = null
|
||||
set(value) {
|
||||
field = value
|
||||
ExitNodeAllowLANAccessSet = true
|
||||
}
|
||||
|
||||
var WantRunning: Boolean? = null
|
||||
set(value) {
|
||||
field = value
|
||||
WantRunningSet = true
|
||||
}
|
||||
|
||||
var ShieldsUp: Boolean? = null
|
||||
set(value) {
|
||||
field = value
|
||||
ShieldsUpSet = true
|
||||
}
|
||||
|
||||
var AdvertiseRoutes: List<String>? = null
|
||||
set(value) {
|
||||
field = value
|
||||
AdvertiseRoutesSet = true
|
||||
}
|
||||
|
||||
var ForceDaemon: Boolean? = null
|
||||
set(value) {
|
||||
field = value
|
||||
ForceDaemonSet = true
|
||||
}
|
||||
|
||||
var Hostname: String? = null
|
||||
set(value) {
|
||||
field = value
|
||||
HostnameSet = true
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class AutoUpdatePrefs(
|
||||
var Check: Boolean? = null,
|
||||
var Apply: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class EngineStatus(
|
||||
val RBytes: Long,
|
||||
val WBytes: Long,
|
||||
val NumLive: Int,
|
||||
val LivePeers: Map<String, IpnState.PeerStatusLite>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PartialFile(
|
||||
val Name: String,
|
||||
val Started: String,
|
||||
val DeclaredSize: Long,
|
||||
val Received: Long,
|
||||
val PartialPath: String? = null,
|
||||
var FinalPath: String? = null,
|
||||
val Done: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OutgoingFile(
|
||||
val ID: String = "",
|
||||
val Name: String,
|
||||
val PeerID: StableNodeID = "",
|
||||
val Started: String = "",
|
||||
val DeclaredSize: Long,
|
||||
val Sent: Long = 0L,
|
||||
val PartialPath: String? = null,
|
||||
var FinalPath: String? = null,
|
||||
val Finished: Boolean = false,
|
||||
val Succeeded: Boolean = false,
|
||||
) {
|
||||
@Transient lateinit var uri: Uri // only used on client
|
||||
|
||||
fun prepare(peerId: StableNodeID): OutgoingFile {
|
||||
val f = copy(ID = UUID.randomUUID().toString(), PeerID = peerId)
|
||||
f.uri = uri
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable data class FileTarget(var Node: Tailcfg.Node, var PeerAPIURL: String)
|
||||
|
||||
@Serializable
|
||||
data class Options(
|
||||
var FrontendLogID: String? = null,
|
||||
var UpdatePrefs: Prefs? = null,
|
||||
var AuthKey: String? = null,
|
||||
)
|
||||
}
|
||||
|
||||
class Persist {
|
||||
@Serializable
|
||||
data class Persist(
|
||||
var PrivateMachineKey: String =
|
||||
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
var PrivateNodeKey: String =
|
||||
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
var OldPrivateNodeKey: String =
|
||||
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
var Provider: String = "",
|
||||
)
|
||||
}
|
@ -0,0 +1,152 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.net.URL
|
||||
|
||||
class IpnState {
|
||||
@Serializable
|
||||
data class PeerStatusLite(
|
||||
val RxBytes: Long,
|
||||
val TxBytes: Long,
|
||||
val LastHandshake: String,
|
||||
val NodeKey: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PeerStatus(
|
||||
val ID: StableNodeID,
|
||||
val HostName: String,
|
||||
val DNSName: String,
|
||||
val TailscaleIPs: List<Addr>? = null,
|
||||
val Tags: List<String>? = null,
|
||||
val PrimaryRoutes: List<String>? = null,
|
||||
val Addrs: List<String>? = null,
|
||||
val CurAddr: String? = null,
|
||||
val Relay: String? = null,
|
||||
val Online: Boolean,
|
||||
val ExitNode: Boolean,
|
||||
val ExitNodeOption: Boolean,
|
||||
val Active: Boolean,
|
||||
val PeerAPIURL: List<String>? = null,
|
||||
val Capabilities: List<String>? = null,
|
||||
val SSH_HostKeys: List<String>? = null,
|
||||
val ShareeNode: Boolean? = null,
|
||||
val Expired: Boolean? = null,
|
||||
val Location: Tailcfg.Location? = null,
|
||||
) {
|
||||
fun computedName(status: Status): String {
|
||||
val name = DNSName
|
||||
val suffix = status.CurrentTailnet?.MagicDNSSuffix
|
||||
|
||||
suffix ?: return name
|
||||
|
||||
if (!(name.endsWith("." + suffix + "."))) {
|
||||
return name
|
||||
}
|
||||
|
||||
return name.dropLast(suffix.count() + 2)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ExitNodeStatus(
|
||||
val ID: StableNodeID,
|
||||
val Online: Boolean,
|
||||
val TailscaleIPs: List<Prefix>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TailnetStatus(
|
||||
val Name: String,
|
||||
val MagicDNSSuffix: String,
|
||||
val MagicDNSEnabled: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Status(
|
||||
val Version: String,
|
||||
val TUN: Boolean,
|
||||
val BackendState: String,
|
||||
val AuthURL: String,
|
||||
val TailscaleIPs: List<Addr>? = null,
|
||||
val Self: PeerStatus? = null,
|
||||
val ExitNodeStatus: ExitNodeStatus? = null,
|
||||
val Health: List<String>? = null,
|
||||
val CurrentTailnet: TailnetStatus? = null,
|
||||
val CertDomains: List<String>? = null,
|
||||
val Peer: Map<String, PeerStatus>? = null,
|
||||
val User: Map<String, Tailcfg.UserProfile>? = null,
|
||||
val ClientVersion: Tailcfg.ClientVersion? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NetworkLockStatus(
|
||||
var Enabled: Boolean? = null,
|
||||
var PublicKey: String? = null,
|
||||
var NodeKey: String? = null,
|
||||
var NodeKeySigned: Boolean? = null,
|
||||
var FilteredPeers: List<TKAFilteredPeer>? = null,
|
||||
var StateID: ULong? = null,
|
||||
var TrustedKeys: List<TKAKey>? = null
|
||||
) {
|
||||
|
||||
fun IsPublicKeyTrusted(): Boolean {
|
||||
return TrustedKeys?.any { it.Key == PublicKey } == true
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class TKAFilteredPeer(
|
||||
var Name: String,
|
||||
var TailscaleIPs: List<Addr>,
|
||||
var NodeKey: String,
|
||||
)
|
||||
|
||||
@Serializable data class TKAKey(var Key: String)
|
||||
|
||||
@Serializable
|
||||
data class PingResult(
|
||||
var IP: Addr,
|
||||
var Err: String,
|
||||
var LatencySeconds: Double,
|
||||
)
|
||||
}
|
||||
|
||||
class IpnLocal {
|
||||
@Serializable
|
||||
data class LoginProfile(
|
||||
var ID: String,
|
||||
val Name: String,
|
||||
val Key: String,
|
||||
val UserProfile: Tailcfg.UserProfile,
|
||||
val NetworkProfile: Tailcfg.NetworkProfile? = null,
|
||||
val LocalUserID: String,
|
||||
var ControlURL: String? = null,
|
||||
) {
|
||||
fun isEmpty(): Boolean {
|
||||
return ID.isEmpty()
|
||||
}
|
||||
|
||||
// Returns true if the profile uses a custom control server (not Tailscale SaaS).
|
||||
private fun isUsingCustomControlServer(): Boolean {
|
||||
return ControlURL != null && ControlURL != "https://controlplane.tailscale.com"
|
||||
}
|
||||
|
||||
// Returns the hostname of the custom control server, if any was set.
|
||||
//
|
||||
// Returns null if the ControlURL provided by the backend is an invalid URL, and
|
||||
// a hostname cannot be extracted.
|
||||
fun customControlServerHostname(): String? {
|
||||
if (!isUsingCustomControlServer()) return null
|
||||
|
||||
return try {
|
||||
URL(ControlURL).host
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
class Netmap {
|
||||
@Serializable
|
||||
data class NetworkMap(
|
||||
var SelfNode: Tailcfg.Node,
|
||||
var NodeKey: KeyNodePublic,
|
||||
var Peers: List<Tailcfg.Node>? = null,
|
||||
var Expiry: Time,
|
||||
var Domain: String,
|
||||
var UserProfiles: Map<String, Tailcfg.UserProfile>,
|
||||
var TKAEnabled: Boolean,
|
||||
var DNS: Tailcfg.DNSConfig? = null
|
||||
) {
|
||||
// Keys are tailcfg.UserIDs thet get stringified
|
||||
// Helpers
|
||||
fun currentUserProfile(): Tailcfg.UserProfile? {
|
||||
return userProfile(User())
|
||||
}
|
||||
|
||||
fun User(): UserID {
|
||||
return SelfNode.User
|
||||
}
|
||||
|
||||
fun userProfile(id: Long): Tailcfg.UserProfile? {
|
||||
return UserProfiles[id.toString()]
|
||||
}
|
||||
|
||||
fun getPeer(id: StableNodeID): Tailcfg.Node? {
|
||||
if (id == SelfNode.StableID) {
|
||||
return SelfNode
|
||||
}
|
||||
return Peers?.find { it.StableID == id }
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is NetworkMap) return false
|
||||
|
||||
return SelfNode == other.SelfNode &&
|
||||
NodeKey == other.NodeKey &&
|
||||
Peers == other.Peers &&
|
||||
Expiry == other.Expiry &&
|
||||
User() == other.User() &&
|
||||
Domain == other.Domain &&
|
||||
UserProfiles == other.UserProfiles &&
|
||||
TKAEnabled == other.TKAEnabled
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,90 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.model
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.PermissionState
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import com.google.accompanist.permissions.shouldShowRationale
|
||||
import com.tailscale.ipn.R
|
||||
|
||||
object Permissions {
|
||||
/** Permissions to prompt for on MainView. */
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
val prompt: List<Pair<Permission, PermissionState>>
|
||||
@Composable
|
||||
get() {
|
||||
val permissionStates = rememberMultiplePermissionsState(permissions = all.map { it.name })
|
||||
return all.zip(permissionStates.permissions).filter { (_, state) ->
|
||||
!state.status.isGranted && !state.status.shouldShowRationale
|
||||
}
|
||||
}
|
||||
|
||||
/** All permissions with granted status. */
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
val withGrantedStatus: List<Pair<Permission, Boolean>>
|
||||
@Composable
|
||||
get() {
|
||||
val permissionStates = rememberMultiplePermissionsState(permissions = all.map { it.name })
|
||||
val result = mutableListOf<Pair<Permission, Boolean>>()
|
||||
result.addAll(
|
||||
all.zip(permissionStates.permissions).map { (permission, state) ->
|
||||
Pair(permission, state.status.isGranted)
|
||||
})
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
// On Android versions prior to 13, we have to programmatically check if notifications are
|
||||
// being allowed.
|
||||
val notificationsEnabled =
|
||||
NotificationManagerCompat.from(LocalContext.current).areNotificationsEnabled()
|
||||
result.add(
|
||||
Pair(
|
||||
Permission(
|
||||
"",
|
||||
R.string.permission_post_notifications,
|
||||
R.string.permission_post_notifications_needed),
|
||||
notificationsEnabled))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* All permissions that Tailscale requires. MainView takes care of prompting for permissions, and
|
||||
* PermissionsView provides a list of permissions with corresponding statuses and a link to the
|
||||
* application settings.
|
||||
*
|
||||
* When new permissions are needed, just add them to this list and the necessary strings to
|
||||
* strings.xml and the rest should take care of itself.
|
||||
*/
|
||||
private val all: List<Permission> by lazy {
|
||||
val result = mutableListOf<Permission>()
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
result.add(
|
||||
Permission(
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
||||
R.string.permission_write_external_storage,
|
||||
R.string.permission_write_external_storage_needed,
|
||||
))
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
result.add(
|
||||
Permission(
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
R.string.permission_post_notifications,
|
||||
R.string.permission_post_notifications_needed))
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
data class Permission(
|
||||
val name: String,
|
||||
val title: Int,
|
||||
val description: Int,
|
||||
)
|
@ -0,0 +1,205 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.model
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.Links
|
||||
import com.tailscale.ipn.ui.theme.off
|
||||
import com.tailscale.ipn.ui.theme.on
|
||||
import com.tailscale.ipn.ui.util.ComposableStringFormatter
|
||||
import com.tailscale.ipn.ui.util.DisplayAddress
|
||||
import com.tailscale.ipn.ui.util.TimeUtil
|
||||
import com.tailscale.ipn.ui.util.flag
|
||||
import com.tailscale.ipn.ui.viewModel.PeerSettingInfo
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import java.util.Date
|
||||
|
||||
class Tailcfg {
|
||||
@Serializable
|
||||
data class ClientVersion(
|
||||
var RunningLatest: Boolean? = null,
|
||||
var LatestVersion: String? = null,
|
||||
var UrgentSecurityUpdate: Boolean? = null,
|
||||
var Notify: Boolean? = null,
|
||||
var NotifyURL: String? = null,
|
||||
var NotifyText: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserProfile(
|
||||
val ID: Long,
|
||||
val DisplayName: String,
|
||||
val LoginName: String,
|
||||
val ProfilePicURL: String? = null,
|
||||
) {
|
||||
fun isTaggedDevice(): Boolean {
|
||||
return LoginName == "tagged-devices"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Hostinfo(
|
||||
var IPNVersion: String? = null,
|
||||
var FrontendLogID: String? = null,
|
||||
var BackendLogID: String? = null,
|
||||
var OS: String? = null,
|
||||
var OSVersion: String? = null,
|
||||
var Env: String? = null,
|
||||
var Distro: String? = null,
|
||||
var DistroVersion: String? = null,
|
||||
var DistroCodeName: String? = null,
|
||||
var Desktop: Boolean? = null,
|
||||
var Package: String? = null,
|
||||
var DeviceModel: String? = null,
|
||||
var ShareeNode: Boolean? = null,
|
||||
var Hostname: String? = null,
|
||||
var ShieldsUp: Boolean? = null,
|
||||
var NoLogsNoSupport: Boolean? = null,
|
||||
var Machine: String? = null,
|
||||
var RoutableIPs: List<Prefix>? = null,
|
||||
var Services: List<Service>? = null,
|
||||
var Location: Location? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Node(
|
||||
var ID: NodeID,
|
||||
var StableID: StableNodeID,
|
||||
var Name: String,
|
||||
var User: UserID,
|
||||
var Sharer: UserID? = null,
|
||||
var Key: KeyNodePublic,
|
||||
var KeyExpiry: String,
|
||||
var Machine: MachineKey,
|
||||
var Addresses: List<Prefix>? = null,
|
||||
var AllowedIPs: List<Prefix>? = null,
|
||||
var Endpoints: List<String>? = null,
|
||||
var Hostinfo: Hostinfo,
|
||||
var Created: Time,
|
||||
var LastSeen: Time? = null,
|
||||
var Online: Boolean? = null,
|
||||
var Capabilities: List<String>? = null,
|
||||
var CapMap: Map<String, JsonElement?>? = null,
|
||||
var ComputedName: String?,
|
||||
var ComputedNameWithHost: String?
|
||||
) {
|
||||
val isAdmin: Boolean
|
||||
get() =
|
||||
Capabilities?.contains("https://tailscale.com/cap/is-admin") == true ||
|
||||
CapMap?.contains("https://tailscale.com/cap/is-admin") == true
|
||||
|
||||
// Derives the url to directly administer a node
|
||||
val nodeAdminUrl: String
|
||||
get() = primaryIPv4Address?.let { "${Links.ADMIN_URL}/machines/${it}" } ?: Links.ADMIN_URL
|
||||
|
||||
val primaryIPv4Address: String?
|
||||
get() = displayAddresses.firstOrNull { it.type == DisplayAddress.addrType.V4 }?.address
|
||||
|
||||
val primaryIPv6Address: String?
|
||||
get() = displayAddresses.firstOrNull { it.type == DisplayAddress.addrType.V6 }?.address
|
||||
|
||||
// isExitNode reproduces the Go logic in local.go peerStatusFromNode
|
||||
val isExitNode: Boolean =
|
||||
AllowedIPs?.contains("0.0.0.0/0") ?: false && AllowedIPs?.contains("::/0") ?: false
|
||||
|
||||
val isMullvadNode: Boolean
|
||||
get() = Name.endsWith(".mullvad.ts.net.")
|
||||
|
||||
val displayName: String
|
||||
get() = ComputedName ?: Name
|
||||
|
||||
val exitNodeName: String
|
||||
get() {
|
||||
if (isMullvadNode &&
|
||||
Hostinfo.Location?.Country != null &&
|
||||
Hostinfo.Location?.City != null &&
|
||||
Hostinfo.Location?.CountryCode != null) {
|
||||
return "${Hostinfo.Location!!.CountryCode!!.flag()} ${Hostinfo.Location!!.Country!!}: ${Hostinfo.Location!!.City!!}"
|
||||
}
|
||||
return displayName
|
||||
}
|
||||
|
||||
val keyDoesNotExpire: Boolean
|
||||
get() = KeyExpiry == "0001-01-01T00:00:00Z"
|
||||
|
||||
fun isSelfNode(netmap: Netmap.NetworkMap): Boolean = StableID == netmap.SelfNode.StableID
|
||||
|
||||
fun connectedOrSelfNode(nm: Netmap.NetworkMap?) =
|
||||
Online == true || StableID == nm?.SelfNode?.StableID
|
||||
|
||||
fun connectedStrRes(nm: Netmap.NetworkMap?) =
|
||||
if (connectedOrSelfNode(nm)) R.string.connected else R.string.not_connected
|
||||
|
||||
@Composable
|
||||
fun connectedColor(nm: Netmap.NetworkMap?) =
|
||||
if (connectedOrSelfNode(nm)) MaterialTheme.colorScheme.on else MaterialTheme.colorScheme.off
|
||||
|
||||
val nameWithoutTrailingDot = Name.trimEnd('.')
|
||||
|
||||
val displayAddresses: List<DisplayAddress>
|
||||
get() {
|
||||
var addresses = mutableListOf<DisplayAddress>()
|
||||
addresses.add(DisplayAddress(nameWithoutTrailingDot))
|
||||
Addresses?.let { addresses.addAll(it.map { addr -> DisplayAddress(addr) }) }
|
||||
return addresses
|
||||
}
|
||||
|
||||
val info: List<PeerSettingInfo>
|
||||
get() {
|
||||
val result = mutableListOf<PeerSettingInfo>()
|
||||
if (Hostinfo.OS?.isNotEmpty() == true) {
|
||||
result.add(
|
||||
PeerSettingInfo(R.string.os, ComposableStringFormatter(Hostinfo.OS!!)),
|
||||
)
|
||||
}
|
||||
if (keyDoesNotExpire) {
|
||||
result.add(
|
||||
PeerSettingInfo(
|
||||
R.string.key_expiry, ComposableStringFormatter(R.string.deviceKeyNeverExpires)))
|
||||
} else {
|
||||
result.add(PeerSettingInfo(R.string.key_expiry, TimeUtil.keyExpiryFromGoTime(KeyExpiry)))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun expiryLabel(): String {
|
||||
if (KeyExpiry == GoZeroTimeString) {
|
||||
return stringResource(R.string.deviceKeyNeverExpires)
|
||||
}
|
||||
|
||||
val expDate = TimeUtil.dateFromGoString(KeyExpiry)
|
||||
val template = if (expDate > Date()) R.string.deviceKeyExpires else R.string.deviceKeyExpired
|
||||
return stringResource(template, TimeUtil.keyExpiryFromGoTime(KeyExpiry).getString())
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Service(var Proto: String, var Port: Int, var Description: String? = null)
|
||||
|
||||
@Serializable
|
||||
data class NetworkProfile(var MagicDNSName: String? = null, var DomainName: String? = null)
|
||||
|
||||
@Serializable
|
||||
data class Location(
|
||||
var Country: String? = null,
|
||||
var CountryCode: String? = null,
|
||||
var City: String? = null,
|
||||
var CityCode: String? = null,
|
||||
var Priority: Int? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DNSConfig(
|
||||
var Resolvers: List<DnsType.Resolver>? = null,
|
||||
var Routes: Map<String, List<DnsType.Resolver>?>? = null,
|
||||
var FallbackResolvers: List<DnsType.Resolver>? = null,
|
||||
var Domains: List<String>? = null,
|
||||
var Nameservers: List<Addr>? = null
|
||||
)
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
typealias Addr = String
|
||||
|
||||
typealias Prefix = String
|
||||
|
||||
typealias NodeID = Long
|
||||
|
||||
typealias KeyNodePublic = String
|
||||
|
||||
typealias MachineKey = String
|
||||
|
||||
typealias UserID = Long
|
||||
|
||||
typealias Time = String
|
||||
|
||||
typealias StableNodeID = String
|
||||
|
||||
typealias BugReportID = String
|
||||
|
||||
val GoZeroTimeString = "0001-01-01T00:00:00Z"
|
||||
|
||||
// Represents and empty message with a single 'property' field.
|
||||
class Empty {
|
||||
@Serializable data class Message(val property: String = "")
|
||||
}
|
||||
|
||||
// Parsable errors returned by localApiService
|
||||
class Errors {
|
||||
@Serializable data class GenericError(val error: String)
|
||||
}
|
@ -0,0 +1,140 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.notifier
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.tailscale.ipn.App
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.UninitializedApp.Companion.notificationManager
|
||||
import com.tailscale.ipn.ui.model.Health
|
||||
import com.tailscale.ipn.ui.model.Health.UnhealthyState
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.util.TSLog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
class HealthNotifier(
|
||||
healthStateFlow: StateFlow<Health.State?>,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
companion object {
|
||||
const val HEALTH_CHANNEL_ID = "tailscale-health"
|
||||
}
|
||||
|
||||
private val TAG = "Health"
|
||||
private val ignoredWarnableCodes: Set<String> =
|
||||
setOf(
|
||||
// Ignored on Android because installing unstable takes quite some effort
|
||||
"is-using-unstable-version",
|
||||
|
||||
// Ignored on Android because we already have a dedicated connected/not connected
|
||||
// notification
|
||||
"wantrunning-false")
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
healthStateFlow
|
||||
.distinctUntilChanged { old, new -> old?.Warnings?.count() == new?.Warnings?.count() }
|
||||
.debounce(5000)
|
||||
.collect { health ->
|
||||
TSLog.d(TAG, "Health updated: ${health?.Warnings?.keys?.sorted()}")
|
||||
health?.Warnings?.let {
|
||||
notifyHealthUpdated(it.values.mapNotNull { it }.toTypedArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val currentWarnings: StateFlow<Set<UnhealthyState>> = MutableStateFlow(setOf())
|
||||
val currentIcon: StateFlow<Int?> = MutableStateFlow(null)
|
||||
|
||||
private fun notifyHealthUpdated(warnings: Array<UnhealthyState>) {
|
||||
val warningsBeforeAdd = currentWarnings.value
|
||||
val currentWarnableCodes = warnings.map { it.WarnableCode }.toSet()
|
||||
val addedWarnings: MutableSet<UnhealthyState> = mutableSetOf()
|
||||
val isWarmingUp = warnings.any { it.WarnableCode == "warming-up" }
|
||||
|
||||
for (warning in warnings) {
|
||||
if (ignoredWarnableCodes.contains(warning.WarnableCode)) {
|
||||
continue
|
||||
}
|
||||
|
||||
addedWarnings.add(warning)
|
||||
|
||||
if (this.currentWarnings.value.contains(warning)) {
|
||||
// Already notified, skip
|
||||
continue
|
||||
} else if (warning.hiddenByDependencies(currentWarnableCodes)) {
|
||||
// Ignore this warning because a dependency is also unhealthy
|
||||
TSLog.d(TAG, "Ignoring ${warning.WarnableCode} because of dependency")
|
||||
continue
|
||||
} else if (!isWarmingUp) {
|
||||
TSLog.d(TAG, "Adding health warning: ${warning.WarnableCode}")
|
||||
this.currentWarnings.set(this.currentWarnings.value + warning)
|
||||
if (warning.Severity == Health.Severity.high) {
|
||||
this.sendNotification(warning.Title, warning.Text, warning.WarnableCode)
|
||||
}
|
||||
} else {
|
||||
TSLog.d(TAG, "Ignoring ${warning.WarnableCode} because warming up")
|
||||
}
|
||||
}
|
||||
|
||||
val warningsToDrop = warningsBeforeAdd.minus(addedWarnings)
|
||||
if (warningsToDrop.isNotEmpty()) {
|
||||
TSLog.d(TAG, "Dropping health warnings with codes $warningsToDrop")
|
||||
this.removeNotifications(warningsToDrop)
|
||||
}
|
||||
currentWarnings.set(this.currentWarnings.value.subtract(warningsToDrop))
|
||||
this.updateIcon()
|
||||
}
|
||||
|
||||
private fun updateIcon() {
|
||||
if (currentWarnings.value.isEmpty()) {
|
||||
this.currentIcon.set(null)
|
||||
return
|
||||
}
|
||||
if (currentWarnings.value.any {
|
||||
(it.Severity == Health.Severity.high || it.ImpactsConnectivity == true)
|
||||
}) {
|
||||
this.currentIcon.set(R.drawable.warning_rounded)
|
||||
} else {
|
||||
this.currentIcon.set(R.drawable.info)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendNotification(title: String, text: String, code: String) {
|
||||
TSLog.d(TAG, "Sending notification for $code")
|
||||
val notification =
|
||||
NotificationCompat.Builder(App.get().applicationContext, HEALTH_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.build()
|
||||
if (ActivityCompat.checkSelfPermission(
|
||||
App.get().applicationContext, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
PackageManager.PERMISSION_GRANTED) {
|
||||
TSLog.d(TAG, "Notification permission not granted")
|
||||
return
|
||||
}
|
||||
notificationManager.notify(code.hashCode(), notification)
|
||||
}
|
||||
|
||||
private fun removeNotifications(warnings: Set<UnhealthyState>) {
|
||||
TSLog.d(TAG, "Removing notifications for $warnings")
|
||||
for (warning in warnings) {
|
||||
notificationManager.cancel(warning.WarnableCode.hashCode())
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,116 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.notifier
|
||||
|
||||
import android.util.Log
|
||||
import com.tailscale.ipn.App
|
||||
import com.tailscale.ipn.ui.model.Empty
|
||||
import com.tailscale.ipn.ui.model.Health
|
||||
import com.tailscale.ipn.ui.model.Ipn
|
||||
import com.tailscale.ipn.ui.model.Ipn.Notify
|
||||
import com.tailscale.ipn.ui.model.Netmap
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import com.tailscale.ipn.util.TSLog
|
||||
|
||||
// Notifier is a wrapper around the IPN Bus notifier. It provides a way to watch
|
||||
// for changes in various parts of the Tailscale engine. You will typically only use
|
||||
// a single Notifier per instance of your application which lasts for the lifetime of
|
||||
// the process.
|
||||
//
|
||||
// The primary entry point here is watchIPNBus which will start a watcher on the IPN bus
|
||||
// and return you the session Id. When you are done with your watcher, you must call
|
||||
// unwatchIPNBus with the sessionId.
|
||||
object Notifier {
|
||||
private val TAG = Notifier::class.simpleName
|
||||
private val decoder = Json { ignoreUnknownKeys = true }
|
||||
|
||||
// General IPN Bus State
|
||||
private val _state = MutableStateFlow(Ipn.State.NoState)
|
||||
val state: StateFlow<Ipn.State> = _state
|
||||
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
|
||||
val prefs: StateFlow<Ipn.Prefs?> = MutableStateFlow(null)
|
||||
val engineStatus: StateFlow<Ipn.EngineStatus?> = MutableStateFlow(null)
|
||||
val tailFSShares: StateFlow<Map<String, String>?> = MutableStateFlow(null)
|
||||
val browseToURL: StateFlow<String?> = MutableStateFlow(null)
|
||||
val loginFinished: StateFlow<String?> = MutableStateFlow(null)
|
||||
val version: StateFlow<String?> = MutableStateFlow(null)
|
||||
val health: StateFlow<Health.State?> = MutableStateFlow(null)
|
||||
|
||||
// Taildrop-specific State
|
||||
val outgoingFiles: StateFlow<List<Ipn.OutgoingFile>?> = MutableStateFlow(null)
|
||||
val incomingFiles: StateFlow<List<Ipn.PartialFile>?> = MutableStateFlow(null)
|
||||
val filesWaiting: StateFlow<Empty.Message?> = MutableStateFlow(null)
|
||||
|
||||
private lateinit var app: libtailscale.Application
|
||||
private var manager: libtailscale.NotificationManager? = null
|
||||
|
||||
@JvmStatic
|
||||
fun setApp(newApp: libtailscale.Application) {
|
||||
app = newApp
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
fun start(scope: CoroutineScope) {
|
||||
TSLog.d(TAG, "Starting Notifier")
|
||||
if (!::app.isInitialized) {
|
||||
App.get()
|
||||
}
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val mask =
|
||||
NotifyWatchOpt.Netmap.value or
|
||||
NotifyWatchOpt.Prefs.value or
|
||||
NotifyWatchOpt.InitialState.value or
|
||||
NotifyWatchOpt.InitialHealthState.value
|
||||
manager =
|
||||
app.watchNotifications(mask.toLong()) { notification ->
|
||||
val notify = decoder.decodeFromStream<Notify>(notification.inputStream())
|
||||
notify.State?.let { state.set(Ipn.State.fromInt(it)) }
|
||||
notify.NetMap?.let(netmap::set)
|
||||
notify.Prefs?.let(prefs::set)
|
||||
notify.Engine?.let(engineStatus::set)
|
||||
notify.TailFSShares?.let(tailFSShares::set)
|
||||
notify.BrowseToURL?.let(browseToURL::set)
|
||||
notify.LoginFinished?.let { loginFinished.set(it.property) }
|
||||
notify.Version?.let(version::set)
|
||||
notify.OutgoingFiles?.let(outgoingFiles::set)
|
||||
notify.FilesWaiting?.let(filesWaiting::set)
|
||||
notify.IncomingFiles?.let(incomingFiles::set)
|
||||
notify.Health?.let(health::set)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
TSLog.d(TAG, "Stopping Notifier")
|
||||
manager?.let {
|
||||
it.stop()
|
||||
manager = null
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which
|
||||
// what we want to see on the Notify bus
|
||||
private enum class NotifyWatchOpt(val value: Int) {
|
||||
EngineUpdates(1),
|
||||
InitialState(2),
|
||||
Prefs(4),
|
||||
Netmap(8),
|
||||
NoPrivateKey(16),
|
||||
InitialTailFSShares(32),
|
||||
InitialOutgoingFiles(64),
|
||||
InitialHealthState(128),
|
||||
}
|
||||
|
||||
fun setState(newState: Ipn.State) {
|
||||
_state.value = newState
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// TODO: replace references to these with references to material theme
|
||||
val ts_color_light_blue = Color(0xFF4B70CC)
|
@ -0,0 +1,467 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.ButtonColors
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ListItemColors
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.TextFieldColors
|
||||
import androidx.compose.material3.TopAppBarColors
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
|
||||
@Composable
|
||||
fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
|
||||
val colors =
|
||||
if (useDarkTheme) {
|
||||
DarkColors
|
||||
} else {
|
||||
LightColors
|
||||
}
|
||||
|
||||
val typography =
|
||||
Typography(
|
||||
// titleMedium is styled to be slightly larger than bodyMedium for emphasis
|
||||
titleMedium =
|
||||
MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp, lineHeight = 26.sp),
|
||||
// bodyMedium is styled to use same line height as titleMedium to ensure even vertical
|
||||
// margins in list items.
|
||||
bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp))
|
||||
|
||||
// TODO: Migrate to Activity.enableEdgeToEdge
|
||||
@Suppress("deprecation") val systemUiController = rememberSystemUiController()
|
||||
|
||||
DisposableEffect(systemUiController, useDarkTheme) {
|
||||
systemUiController.setStatusBarColor(color = colors.surfaceContainer)
|
||||
systemUiController.setNavigationBarColor(color = Color.Black)
|
||||
onDispose {}
|
||||
}
|
||||
|
||||
MaterialTheme(colorScheme = colors, typography = typography, content = content)
|
||||
}
|
||||
|
||||
private val LightColors =
|
||||
lightColorScheme(
|
||||
primary = Color(0xFF4B70CC), // blue-500
|
||||
onPrimary = Color(0xFFFFFFFF), // white
|
||||
primaryContainer = Color(0xFFF0F5FF), // blue-0
|
||||
onPrimaryContainer = Color(0xFF3E5DB3), // blue-600
|
||||
error = Color(0xFFB22C30), // red-500
|
||||
onError = Color(0xFFFFFFFF), // white
|
||||
errorContainer = Color(0xFFFEF6F3), // red-0
|
||||
onErrorContainer = Color(0xFF930921), // red-600
|
||||
surfaceDim = Color(0xFFF7F5F4), // gray-100
|
||||
surface = Color(0xFFFFFFFF), // white,
|
||||
background = Color(0xFFF7F5F4), // gray-100
|
||||
surfaceBright = Color(0xFFFFFFFF), // white
|
||||
surfaceContainerLowest = Color(0xFFFFFFFF), // white
|
||||
surfaceContainerLow = Color(0xFFF7F5F4), // gray-100
|
||||
surfaceContainer = Color(0xFFF7F5F4), // gray-100
|
||||
surfaceContainerHigh = Color(0xFFF7F5F4), // gray-100
|
||||
surfaceContainerHighest = Color(0xFFF7F5F4), // gray-100
|
||||
surfaceVariant = Color(0xFFF7F5F4), // gray-100,
|
||||
onSurface = Color(0xFF232222), // gray-800
|
||||
onSurfaceVariant = Color(0xFF706E6D), // gray-500
|
||||
outline = Color(0xFF706E6D), // gray-500
|
||||
outlineVariant = Color(0xFFEDEBEA), // gray-200
|
||||
inverseSurface = Color(0xFF232222), // gray-800
|
||||
inverseOnSurface = Color(0xFFFFFFFF), // white
|
||||
scrim = Color(0xAA000000), // black
|
||||
)
|
||||
|
||||
private val DarkColors =
|
||||
darkColorScheme(
|
||||
primary = Color(0xFF3E5DB3), // blue-600
|
||||
onPrimary = Color(0xFFFFFFFF), // white
|
||||
primaryContainer = Color(0xFFf0f5ff), // blue-0
|
||||
onPrimaryContainer = Color(0xFF5A82DC), // blue-400
|
||||
error = Color(0xFFEF5350), // red-400
|
||||
onError = Color(0xFFFFFFFF), // white
|
||||
errorContainer = Color(0xFFfff6f4), // red-0
|
||||
onErrorContainer = Color(0xFF940822), // red-600
|
||||
surfaceDim = Color(0xFF1f1e1e), // gray-900
|
||||
surface = Color(0xFF232222), // gray-800
|
||||
background = Color(0xFF181717), // gray-1000
|
||||
surfaceBright = Color(0xFF444342), // gray-600
|
||||
surfaceContainerLowest = Color(0xFF1f1e1e), // gray-900
|
||||
surfaceContainerLow = Color(0xFF232222), // gray-800
|
||||
surfaceContainer = Color(0xFF181717), // gray-1000
|
||||
surfaceContainerHigh = Color(0xFF232222), // gray-800
|
||||
surfaceContainerHighest = Color(0xFF2e2d2d), // gray-700
|
||||
surfaceVariant = Color(0xFF1f1e1e), // gray-900
|
||||
onSurface = Color(0xFFfaf9f8), // gray-0
|
||||
onSurfaceVariant = Color(0xFFafacab), // gray-400
|
||||
outline = Color(0xFF706E6D), // gray-500
|
||||
outlineVariant = Color(0xFF2E2D2D), // gray-700
|
||||
inverseSurface = Color(0xFFEDEBEA), // gray-200
|
||||
inverseOnSurface = Color(0xFF000000), // black
|
||||
scrim = Color(0xAA000000), // black
|
||||
)
|
||||
|
||||
val ColorScheme.warning: Color
|
||||
@Composable
|
||||
get() =
|
||||
if (isSystemInDarkTheme()) {
|
||||
Color(0xFFBB5504) // yellow-400
|
||||
} else {
|
||||
Color(0xFFD97917) // yellow-300
|
||||
}
|
||||
|
||||
val ColorScheme.onWarning: Color
|
||||
get() = Color(0xFFFFFFFF) // white
|
||||
|
||||
val ColorScheme.warningContainer: Color
|
||||
get() = Color(0xFFFFFAEE) // orange-0
|
||||
|
||||
val ColorScheme.onWarningContainer: Color
|
||||
get() = Color(0xFF7E1E22) // orange-600
|
||||
|
||||
val ColorScheme.success: Color
|
||||
get() = Color(0xFF0A825D) // green-400
|
||||
|
||||
val ColorScheme.onSuccess: Color
|
||||
get() = Color(0xFFFFFFFF) // white
|
||||
|
||||
val ColorScheme.successContainer: Color
|
||||
get() = Color(0xFFEFFEEC) // green-0
|
||||
|
||||
val ColorScheme.onSuccessContainer: Color
|
||||
get() = Color(0xFF0E4B3B) // green-600
|
||||
|
||||
val ColorScheme.on: Color
|
||||
get() = Color(0xFF1CA672) // green-300
|
||||
|
||||
val ColorScheme.off: Color
|
||||
@Composable
|
||||
get() =
|
||||
if (isSystemInDarkTheme()) {
|
||||
Color(0xFF444342) // gray-600
|
||||
} else {
|
||||
Color(0xFFD9D6D5) // gray-300
|
||||
}
|
||||
|
||||
val ColorScheme.link: Color
|
||||
get() = onPrimaryContainer
|
||||
|
||||
val ColorScheme.customError: Color
|
||||
@Composable
|
||||
get() =
|
||||
if (isSystemInDarkTheme()) {
|
||||
Color(0xFF940821) // red-600
|
||||
} else {
|
||||
Color(0xFFB22D30) // red-500
|
||||
}
|
||||
|
||||
val ColorScheme.customErrorContainer: Color
|
||||
@Composable
|
||||
get() =
|
||||
if (isSystemInDarkTheme()) {
|
||||
Color(0xFF760012) // red-700
|
||||
} else {
|
||||
Color(0xFF940821) // red-600
|
||||
}
|
||||
|
||||
/**
|
||||
* Main color scheme for list items, uses onPrimaryContainer color for leading and trailing icons.
|
||||
*/
|
||||
val ColorScheme.listItem: ListItemColors
|
||||
@Composable
|
||||
get() {
|
||||
val default = ListItemDefaults.colors()
|
||||
return ListItemColors(
|
||||
containerColor = default.containerColor,
|
||||
headlineColor = default.headlineColor,
|
||||
leadingIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
overlineColor = default.overlineColor,
|
||||
supportingTextColor = default.supportingTextColor,
|
||||
trailingIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
disabledHeadlineColor = default.disabledHeadlineColor,
|
||||
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
||||
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
||||
}
|
||||
|
||||
/** Like listItem, but with the overline content using the onSurface color. */
|
||||
val ColorScheme.titledListItem: ListItemColors
|
||||
@Composable
|
||||
get() {
|
||||
val default = listItem
|
||||
return ListItemColors(
|
||||
containerColor = default.containerColor,
|
||||
headlineColor = default.headlineColor,
|
||||
leadingIconColor = default.leadingIconColor,
|
||||
overlineColor = MaterialTheme.colorScheme.onSurface,
|
||||
supportingTextColor = default.supportingTextColor,
|
||||
trailingIconColor = default.trailingIconColor,
|
||||
disabledHeadlineColor = default.disabledHeadlineColor,
|
||||
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
||||
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
||||
}
|
||||
|
||||
/** Color scheme for disabled list items. */
|
||||
val ColorScheme.disabledListItem: ListItemColors
|
||||
@Composable
|
||||
get() {
|
||||
val default = ListItemDefaults.colors()
|
||||
return ListItemColors(
|
||||
containerColor = default.containerColor,
|
||||
headlineColor = MaterialTheme.colorScheme.disabled,
|
||||
leadingIconColor = default.leadingIconColor,
|
||||
overlineColor = default.overlineColor,
|
||||
supportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
trailingIconColor = default.trailingIconColor,
|
||||
disabledHeadlineColor = default.disabledHeadlineColor,
|
||||
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
||||
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
||||
}
|
||||
|
||||
/** Color scheme for list items that should be styled as a surface container. */
|
||||
val ColorScheme.surfaceContainerListItem: ListItemColors
|
||||
@Composable
|
||||
get() {
|
||||
val default = ListItemDefaults.colors()
|
||||
return ListItemColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
headlineColor = MaterialTheme.colorScheme.onSurface,
|
||||
leadingIconColor = MaterialTheme.colorScheme.onSurface,
|
||||
overlineColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
supportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
trailingIconColor = MaterialTheme.colorScheme.onSurface,
|
||||
disabledHeadlineColor = default.disabledHeadlineColor,
|
||||
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
||||
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
||||
}
|
||||
|
||||
/** Color scheme for list items that should be styled as a primary item. */
|
||||
val ColorScheme.primaryListItem: ListItemColors
|
||||
@Composable
|
||||
get() {
|
||||
val default = ListItemDefaults.colors()
|
||||
return ListItemColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
headlineColor = MaterialTheme.colorScheme.onPrimary,
|
||||
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
|
||||
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
|
||||
supportingTextColor = MaterialTheme.colorScheme.onPrimary,
|
||||
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
|
||||
disabledHeadlineColor = default.disabledHeadlineColor,
|
||||
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
||||
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
||||
}
|
||||
|
||||
/** Color scheme for list items that should be styled as a warning item. */
|
||||
val ColorScheme.warningListItem: ListItemColors
|
||||
@Composable
|
||||
get() {
|
||||
val default = ListItemDefaults.colors()
|
||||
return ListItemColors(
|
||||
containerColor = MaterialTheme.colorScheme.warning,
|
||||
headlineColor = MaterialTheme.colorScheme.onPrimary,
|
||||
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
|
||||
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f),
|
||||
supportingTextColor = MaterialTheme.colorScheme.onPrimary,
|
||||
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
|
||||
disabledHeadlineColor = default.disabledHeadlineColor,
|
||||
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
||||
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
||||
}
|
||||
|
||||
/** Color scheme for list items that should be styled as an error item. */
|
||||
val ColorScheme.errorListItem: ListItemColors
|
||||
@Composable
|
||||
get() {
|
||||
val default = ListItemDefaults.colors()
|
||||
return ListItemColors(
|
||||
containerColor = MaterialTheme.colorScheme.customError,
|
||||
headlineColor = MaterialTheme.colorScheme.onPrimary,
|
||||
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
|
||||
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f),
|
||||
supportingTextColor = MaterialTheme.colorScheme.onPrimary,
|
||||
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
|
||||
disabledHeadlineColor = default.disabledHeadlineColor,
|
||||
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
||||
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
||||
}
|
||||
|
||||
/** Main color scheme for top app bar, styles it as a surface container. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
val ColorScheme.topAppBar: TopAppBarColors
|
||||
@Composable
|
||||
get() =
|
||||
TopAppBarDefaults.topAppBarColors()
|
||||
.copy(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
val ColorScheme.secondaryButton: ButtonColors
|
||||
@Composable
|
||||
get() {
|
||||
val defaults = ButtonDefaults.buttonColors()
|
||||
if (isSystemInDarkTheme()) {
|
||||
return ButtonColors(
|
||||
containerColor = Color(0xFF4B70CC), // blue-500
|
||||
contentColor = Color(0xFFFFFFFF), // white
|
||||
disabledContainerColor = defaults.disabledContainerColor,
|
||||
disabledContentColor = defaults.disabledContentColor)
|
||||
} else {
|
||||
return ButtonColors(
|
||||
containerColor = Color(0xFF5A82DC), // blue-400
|
||||
contentColor = Color(0xFFFFFFFF), // white
|
||||
disabledContainerColor = defaults.disabledContainerColor,
|
||||
disabledContentColor = defaults.disabledContentColor)
|
||||
}
|
||||
}
|
||||
|
||||
val ColorScheme.errorButton: ButtonColors
|
||||
@Composable
|
||||
get() {
|
||||
val defaults = ButtonDefaults.buttonColors()
|
||||
if (isSystemInDarkTheme()) {
|
||||
return ButtonColors(
|
||||
containerColor = Color(0xFFB22D30), // red-500
|
||||
contentColor = Color(0xFFFFFFFF), // white
|
||||
disabledContainerColor = defaults.disabledContainerColor,
|
||||
disabledContentColor = defaults.disabledContentColor)
|
||||
} else {
|
||||
return ButtonColors(
|
||||
containerColor = Color(0xFFD04841), // red-400
|
||||
contentColor = Color(0xFFFFFFFF), // white
|
||||
disabledContainerColor = defaults.disabledContainerColor,
|
||||
disabledContentColor = defaults.disabledContentColor)
|
||||
}
|
||||
}
|
||||
|
||||
val ColorScheme.warningButton: ButtonColors
|
||||
@Composable
|
||||
get() {
|
||||
val defaults = ButtonDefaults.buttonColors()
|
||||
if (isSystemInDarkTheme()) {
|
||||
return ButtonColors(
|
||||
containerColor = Color(0xFFD97917), // yellow-300
|
||||
contentColor = Color(0xFFFFFFFF), // white
|
||||
disabledContainerColor = defaults.disabledContainerColor,
|
||||
disabledContentColor = defaults.disabledContentColor)
|
||||
} else {
|
||||
return ButtonColors(
|
||||
containerColor = Color(0xFFE5993E), // yellow-200
|
||||
contentColor = Color(0xFFFFFFFF), // white
|
||||
disabledContainerColor = defaults.disabledContainerColor,
|
||||
disabledContentColor = defaults.disabledContentColor)
|
||||
}
|
||||
}
|
||||
|
||||
val ColorScheme.defaultTextColor: Color
|
||||
@Composable
|
||||
get() =
|
||||
if (isSystemInDarkTheme()) {
|
||||
Color.White
|
||||
} else {
|
||||
Color.Black
|
||||
}
|
||||
|
||||
val ColorScheme.logoBackground: Color
|
||||
@Composable
|
||||
get() =
|
||||
if (isSystemInDarkTheme()) {
|
||||
Color(0xFFFFFFFF) // white
|
||||
} else {
|
||||
Color(0xFF1F1E1E)
|
||||
}
|
||||
|
||||
val ColorScheme.standaloneLogoDotEnabled: Color
|
||||
@Composable
|
||||
get() =
|
||||
if (isSystemInDarkTheme()) {
|
||||
Color(0xFFFFFFFF)
|
||||
} else {
|
||||
Color(0xFF000000)
|
||||
}
|
||||
|
||||
val ColorScheme.standaloneLogoDotDisabled: Color
|
||||
@Composable
|
||||
get() =
|
||||
if (isSystemInDarkTheme()) {
|
||||
Color(0x66FFFFFF)
|
||||
} else {
|
||||
Color(0x661F1E1E)
|
||||
}
|
||||
|
||||
val ColorScheme.onBackgroundLogoDotEnabled: Color
|
||||
@Composable
|
||||
get() =
|
||||
if (isSystemInDarkTheme()) {
|
||||
Color(0xFF141414)
|
||||
} else {
|
||||
Color(0xFFFFFFFF)
|
||||
}
|
||||
|
||||
val ColorScheme.onBackgroundLogoDotDisabled: Color
|
||||
@Composable
|
||||
get() =
|
||||
if (isSystemInDarkTheme()) {
|
||||
Color(0x66141414)
|
||||
} else {
|
||||
Color(0x66FFFFFF)
|
||||
}
|
||||
|
||||
val ColorScheme.exitNodeToggleButton: ButtonColors
|
||||
@Composable
|
||||
get() {
|
||||
val defaults = ButtonDefaults.buttonColors()
|
||||
return if (isSystemInDarkTheme()) {
|
||||
ButtonColors(
|
||||
containerColor = Color(0xFF444342), // grey-600
|
||||
contentColor = Color(0xFFFFFFFF), // white
|
||||
disabledContainerColor = defaults.disabledContainerColor,
|
||||
disabledContentColor = defaults.disabledContentColor)
|
||||
} else {
|
||||
ButtonColors(
|
||||
containerColor = Color(0xFFEDEBEA), // grey-300
|
||||
contentColor = Color(0xFF000000), // black
|
||||
disabledContainerColor = defaults.disabledContainerColor,
|
||||
disabledContentColor = defaults.disabledContentColor)
|
||||
}
|
||||
}
|
||||
|
||||
val ColorScheme.disabled: Color
|
||||
get() = Color(0xFFAFACAB) // gray-400
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
val ColorScheme.searchBarColors: TextFieldColors
|
||||
@Composable
|
||||
get() {
|
||||
return OutlinedTextFieldDefaults.colors(
|
||||
focusedLeadingIconColor = MaterialTheme.colorScheme.onSurface,
|
||||
unfocusedLeadingIconColor = MaterialTheme.colorScheme.onSurface,
|
||||
focusedTextColor = MaterialTheme.colorScheme.onSurface,
|
||||
unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
disabledTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent)
|
||||
}
|
||||
|
||||
val TextStyle.short: TextStyle
|
||||
get() = copy(lineHeight = 20.sp)
|
||||
|
||||
val Typography.minTextSize: TextUnit
|
||||
get() = 10.sp
|
@ -0,0 +1,24 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
import com.tailscale.ipn.ui.model.Ipn
|
||||
|
||||
class AdvertisedRoutesHelper {
|
||||
companion object {
|
||||
fun exitNodeOnFromPrefs(prefs: Ipn.Prefs): Boolean {
|
||||
var v4 = false
|
||||
var v6 = false
|
||||
prefs.AdvertiseRoutes?.forEach {
|
||||
if (it == "0.0.0.0/0") {
|
||||
v4 = true
|
||||
}
|
||||
if (it == "::/0") {
|
||||
v6 = true
|
||||
}
|
||||
}
|
||||
return v4 && v6
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tailscale.ipn.UninitializedApp
|
||||
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
|
||||
|
||||
object AndroidTVUtil {
|
||||
fun isAndroidTV(): Boolean {
|
||||
val pm = UninitializedApp.get().packageManager
|
||||
return (pm.hasSystemFeature(@Suppress("deprecation") PackageManager.FEATURE_TELEVISION) ||
|
||||
pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
|
||||
}
|
||||
}
|
||||
|
||||
// Applies a letterbox effect iff we're running on Android TV to reduce the overall width
|
||||
// of the UI.
|
||||
fun Modifier.universalFit(): Modifier {
|
||||
return when (isAndroidTV()) {
|
||||
true -> this.padding(horizontal = 150.dp, vertical = 10.dp).clip(RoundedCornerShape(10.dp))
|
||||
false -> this
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
import com.tailscale.ipn.BuildConfig
|
||||
|
||||
class AppVersion {
|
||||
companion object {
|
||||
// Returns the short version of the build version, which is what users typically expect.
|
||||
// For instance, if the build version is "1.75.80-t8fdffb8da-g2daeee584df",
|
||||
// this function returns "1.75.80".
|
||||
fun Short(): String {
|
||||
// Split the full version string by hyphen (-)
|
||||
val parts = BuildConfig.VERSION_NAME.split("-")
|
||||
// Return only the part before the first hyphen
|
||||
return parts[0]
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,78 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
|
||||
// AutoResizingText automatically resizes text up to the specified minFontSize in order to avoid
|
||||
// overflowing. It is based on https://stackoverflow.com/a/66090448 licensed under CC BY-SA 4.0.
|
||||
@Composable
|
||||
fun AutoResizingText(
|
||||
text: String,
|
||||
minFontSize: TextUnit,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = Color.Unspecified,
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
fontStyle: FontStyle? = null,
|
||||
fontWeight: FontWeight? = null,
|
||||
fontFamily: FontFamily? = null,
|
||||
letterSpacing: TextUnit = TextUnit.Unspecified,
|
||||
textDecoration: TextDecoration? = null,
|
||||
textAlign: TextAlign? = null,
|
||||
lineHeight: TextUnit = TextUnit.Unspecified,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
maxLines: Int = 1,
|
||||
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
|
||||
style: TextStyle = LocalTextStyle.current
|
||||
) {
|
||||
var textStyle = remember { mutableStateOf(style) }
|
||||
var textOverflow = remember { mutableStateOf(TextOverflow.Clip) }
|
||||
var readyToDraw = remember { mutableStateOf(false) }
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
modifier = modifier.drawWithContent { if (readyToDraw.value) drawContent() },
|
||||
color = color,
|
||||
fontSize = fontSize,
|
||||
fontStyle = fontStyle,
|
||||
fontWeight = fontWeight,
|
||||
fontFamily = fontFamily,
|
||||
letterSpacing = letterSpacing,
|
||||
textDecoration = textDecoration,
|
||||
textAlign = textAlign,
|
||||
lineHeight = lineHeight,
|
||||
overflow = textOverflow.value,
|
||||
maxLines = maxLines,
|
||||
softWrap = false,
|
||||
style = textStyle.value,
|
||||
onTextLayout = { result ->
|
||||
if (result.didOverflowWidth) {
|
||||
var newSize = textStyle.value.fontSize * 0.9
|
||||
if (newSize < minFontSize) {
|
||||
newSize = minFontSize
|
||||
textOverflow.value = overflow
|
||||
}
|
||||
textStyle.value = textStyle.value.copy(fontSize = newSize)
|
||||
} else {
|
||||
readyToDraw.value = true
|
||||
}
|
||||
onTextLayout?.let { it(result) }
|
||||
})
|
||||
}
|
@ -0,0 +1,61 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tailscale.ipn.R
|
||||
|
||||
@Composable
|
||||
fun ClipboardValueView(value: String, title: String? = null, subtitle: String? = null) {
|
||||
val isFocused = remember { mutableStateOf(false) }
|
||||
val localClipboardManager = LocalClipboardManager.current
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier
|
||||
.focusable(interactionSource = interactionSource)
|
||||
.onFocusChanged { focusState -> isFocused.value = focusState.isFocused }
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = LocalIndication.current
|
||||
) { localClipboardManager.setText(AnnotatedString(value)) }
|
||||
.background(
|
||||
if (isFocused.value) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
||||
else Color.Transparent
|
||||
),
|
||||
overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } },
|
||||
headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) },
|
||||
supportingContent = subtitle?.let {
|
||||
{ Text(it, modifier = Modifier.padding(top = 8.dp), style = MaterialTheme.typography.bodyMedium) }
|
||||
},
|
||||
trailingContent = {
|
||||
Icon(
|
||||
painterResource(R.drawable.clipboard),
|
||||
contentDescription = stringResource(R.string.copy_to_clipboard),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.tailscale.ipn.R
|
||||
|
||||
// Convenience wrapper for passing formatted strings to Composables
|
||||
class ComposableStringFormatter(
|
||||
@StringRes val stringRes: Int = R.string.template,
|
||||
private vararg val params: Any
|
||||
) {
|
||||
|
||||
// Convenience constructor for passing a non-formatted string directly
|
||||
constructor(string: String) : this(stringRes = R.string.template, string)
|
||||
|
||||
// Returns the fully formatted string
|
||||
@Composable fun getString(): String = stringResource(id = stringRes, *params)
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.theme.on
|
||||
|
||||
sealed class ConnectionMode {
|
||||
class NotConnected : ConnectionMode()
|
||||
|
||||
class Derp(val relayName: String) : ConnectionMode()
|
||||
|
||||
class Direct : ConnectionMode()
|
||||
|
||||
@Composable
|
||||
fun titleString(): String {
|
||||
return when (this) {
|
||||
is NotConnected -> stringResource(id = R.string.not_connected)
|
||||
is Derp -> stringResource(R.string.relayed_connection, relayName)
|
||||
is Direct -> stringResource(R.string.direct_connection)
|
||||
}
|
||||
}
|
||||
|
||||
fun contentKey(): String {
|
||||
return when (this) {
|
||||
is NotConnected -> "NotConnected"
|
||||
is Derp -> "Derp($relayName)"
|
||||
is Direct -> "Direct"
|
||||
}
|
||||
}
|
||||
|
||||
fun iconDrawable(): Int {
|
||||
return when (this) {
|
||||
is NotConnected -> R.drawable.xmark_circle
|
||||
is Derp -> R.drawable.link_off
|
||||
is Direct -> R.drawable.link
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun color(): Color {
|
||||
return when (this) {
|
||||
is NotConnected -> MaterialTheme.colorScheme.onPrimary
|
||||
is Derp -> MaterialTheme.colorScheme.error
|
||||
is Direct -> MaterialTheme.colorScheme.on
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
class DisplayAddress(ip: String) {
|
||||
enum class addrType {
|
||||
V4,
|
||||
V6,
|
||||
MagicDNS
|
||||
}
|
||||
|
||||
val type: addrType =
|
||||
when {
|
||||
ip.isIPV6() -> addrType.V6
|
||||
ip.isIPV4() -> addrType.V4
|
||||
else -> addrType.MagicDNS
|
||||
}
|
||||
|
||||
val typeString: String =
|
||||
when (type) {
|
||||
addrType.V4 -> "IPv4"
|
||||
addrType.V6 -> "IPv6"
|
||||
addrType.MagicDNS -> "MagicDNS"
|
||||
}
|
||||
|
||||
val address: String =
|
||||
when (type) {
|
||||
addrType.MagicDNS -> ip
|
||||
else -> ip.split("/").first()
|
||||
}
|
||||
}
|
||||
|
||||
fun String.isIPV6(): Boolean {
|
||||
return this.contains(":")
|
||||
}
|
||||
|
||||
fun String.isIPV4(): Boolean {
|
||||
val parts = this.split("/").first().split(".")
|
||||
if (parts.size != 4) return false
|
||||
for (part in parts) {
|
||||
val value = part.toIntOrNull() ?: return false
|
||||
if (value !in 0..255) return false
|
||||
}
|
||||
return true
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
/**
|
||||
* Code adapted from
|
||||
* https://github.com/piashcse/Compose-museum/blob/master/app/src/main/java/com/piashcse/compose_museum/screens/CountryList.kt#L75
|
||||
*/
|
||||
|
||||
// Copyright 2023 piashcse (Mehedi Hassan Piash)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
/** Flag turns an ISO3166 country code into a flag emoji. */
|
||||
fun String.flag(): String {
|
||||
val caps = this.uppercase()
|
||||
val flagOffset = 0x1F1E6
|
||||
val asciiOffset = 0x41
|
||||
val firstChar = Character.codePointAt(caps, 0) - asciiOffset + flagOffset
|
||||
val secondChar = Character.codePointAt(caps, 1) - asciiOffset + flagOffset
|
||||
return String(Character.toChars(firstChar)) + String(Character.toChars(secondChar))
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
import java.io.InputStream
|
||||
|
||||
class InputStreamAdapter(private val inputStream: InputStream) : libtailscale.InputStream {
|
||||
override fun read(): ByteArray? {
|
||||
val b = ByteArray(4096)
|
||||
val i = inputStream.read(b)
|
||||
if (i == -1) {
|
||||
return null
|
||||
}
|
||||
return b.sliceArray(0 ..< i)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
inputStream.close()
|
||||
}
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageManager
|
||||
|
||||
data class InstalledApp(val name: String, val packageName: String)
|
||||
|
||||
class InstalledAppsManager(
|
||||
val packageManager: PackageManager,
|
||||
) {
|
||||
fun fetchInstalledApps(): List<InstalledApp> {
|
||||
return packageManager
|
||||
.getInstalledApplications(PackageManager.GET_META_DATA)
|
||||
.filter(appIsIncluded)
|
||||
.map {
|
||||
InstalledApp(
|
||||
name = it.loadLabel(packageManager).toString(),
|
||||
packageName = it.packageName,
|
||||
)
|
||||
}
|
||||
.sortedBy { it.name }
|
||||
}
|
||||
|
||||
private val appIsIncluded: (ApplicationInfo) -> Boolean = { app ->
|
||||
app.packageName != "com.tailscale.ipn" &&
|
||||
// Only show apps that can access the Internet
|
||||
packageManager.checkPermission(Manifest.permission.INTERNET, app.packageName) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
@ -0,0 +1,125 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
object Lists {
|
||||
@Composable
|
||||
fun SectionDivider(title: String? = null) {
|
||||
Box(Modifier.size(0.dp, 16.dp))
|
||||
title?.let { LargeTitle(title) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ItemDivider() {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LargeTitle(
|
||||
title: String,
|
||||
bottomPadding: Dp = 0.dp,
|
||||
style: TextStyle = MaterialTheme.typography.titleMedium,
|
||||
fontWeight: FontWeight? = null,
|
||||
focusable: Boolean = false
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.background(color = MaterialTheme.colorScheme.surface, shape = RectangleShape)) {
|
||||
Text(
|
||||
title,
|
||||
modifier =
|
||||
Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding)
|
||||
.focusable(focusable),
|
||||
style = style,
|
||||
fontWeight = fontWeight)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MutedHeader(text: String) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.background(color = MaterialTheme.colorScheme.surface, shape = RectangleShape)) {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 16.dp, top = 16.dp),
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InfoItem(text: CharSequence, onClick: (() -> Unit)? = null) {
|
||||
val style =
|
||||
MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Box(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
onClick?.let {
|
||||
ClickableText(text = text as AnnotatedString, style = style, onClick = { onClick() })
|
||||
} ?: run { Text(text as String, style = style) }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MultilineDescription(headlineContent: @Composable () -> Unit) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Box(modifier = Modifier.padding(vertical = 8.dp)) { headlineContent() }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Similar to items() but includes a horizontal divider between items. */
|
||||
|
||||
/** Similar to items() but includes a horizontal divider between items. */
|
||||
inline fun <T> LazyListScope.itemsWithDividers(
|
||||
items: List<T>,
|
||||
noinline key: ((item: T) -> Any)? = null,
|
||||
forceLeading: Boolean = false,
|
||||
crossinline contentType: (item: T) -> Any? = { _ -> null },
|
||||
crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
|
||||
) =
|
||||
items(
|
||||
count = items.size,
|
||||
key = if (key != null) { index: Int -> key(items[index]) } else null,
|
||||
contentType = { index -> contentType(items[index]) }) {
|
||||
if (forceLeading && it == 0 || it > 0 && it < items.size) {
|
||||
Lists.ItemDivider()
|
||||
}
|
||||
itemContent(items[it])
|
||||
}
|
||||
|
||||
inline fun <T> LazyListScope.itemsWithDividers(
|
||||
items: Array<T>,
|
||||
noinline key: ((item: T) -> Any)? = null,
|
||||
forceLeading: Boolean = false,
|
||||
crossinline contentType: (item: T) -> Any? = { _ -> null },
|
||||
crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
|
||||
) = itemsWithDividers(items.toList(), key, forceLeading, contentType, itemContent)
|
@ -0,0 +1,65 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tailscale.ipn.ui.view.TailscaleLogoView
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
object LoadingIndicator {
|
||||
private val loading = MutableStateFlow(false)
|
||||
|
||||
fun start() {
|
||||
loading.value = true
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Wrap(content: @Composable () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
content()
|
||||
val isLoading by loading.collectAsState()
|
||||
if (isLoading) {
|
||||
Box(Modifier.clickable {}.matchParentSize().background(Color.Gray.copy(alpha = 0.0f)))
|
||||
|
||||
val showSpinner: State<Boolean> =
|
||||
produceState(initialValue = false) {
|
||||
delay(300)
|
||||
value = true
|
||||
}
|
||||
|
||||
if (showSpinner.value) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
TailscaleLogoView(
|
||||
true, usesOnBackgroundColors = false, Modifier.size(72.dp).alpha(0.4f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,129 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
import androidx.compose.ui.util.fastAny
|
||||
import com.tailscale.ipn.mdm.MDMSettings
|
||||
import com.tailscale.ipn.ui.model.Netmap
|
||||
import com.tailscale.ipn.ui.model.Tailcfg
|
||||
import com.tailscale.ipn.ui.model.UserID
|
||||
|
||||
data class PeerSet(val user: Tailcfg.UserProfile?, val peers: List<Tailcfg.Node>)
|
||||
|
||||
class PeerCategorizer {
|
||||
var peerSets: List<PeerSet> = emptyList()
|
||||
var lastSearchResult: List<PeerSet> = emptyList()
|
||||
var lastSearchTerm: String = ""
|
||||
|
||||
fun regenerateGroupedPeers(netmap: Netmap.NetworkMap) {
|
||||
val peers: List<Tailcfg.Node> = netmap.Peers ?: return
|
||||
val selfNode = netmap.SelfNode
|
||||
var grouped = mutableMapOf<UserID, MutableList<Tailcfg.Node>>()
|
||||
|
||||
val mdm = MDMSettings.hiddenNetworkDevices.flow.value.value
|
||||
val hideMyDevices = mdm?.contains("current-user") ?: false
|
||||
val hideOtherDevices = mdm?.contains("other-users") ?: false
|
||||
val hideTaggedDevices = mdm?.contains("tagged-devices") ?: false
|
||||
|
||||
val me = netmap.currentUserProfile()
|
||||
|
||||
for (peer in (peers + selfNode)) {
|
||||
|
||||
val userId = peer.User
|
||||
val profile = netmap.userProfile(userId)
|
||||
|
||||
// Mullvad nodes should not be shown in the peer list
|
||||
if (peer.isMullvadNode) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Hide devices based on MDM settings
|
||||
if (hideMyDevices && userId == me?.ID) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (hideOtherDevices && userId != me?.ID) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (hideTaggedDevices && (profile?.isTaggedDevice() == true)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!grouped.containsKey(userId)) {
|
||||
grouped[userId] = mutableListOf()
|
||||
}
|
||||
grouped[userId]?.add(peer)
|
||||
}
|
||||
|
||||
peerSets =
|
||||
grouped
|
||||
.map { (userId, peers) ->
|
||||
val profile = netmap.userProfile(userId)
|
||||
PeerSet(
|
||||
profile,
|
||||
peers.sortedWith { a, b ->
|
||||
when {
|
||||
a.StableID == b.StableID -> 0
|
||||
a.isSelfNode(netmap) -> -1
|
||||
b.isSelfNode(netmap) -> 1
|
||||
else ->
|
||||
(a.ComputedName?.lowercase() ?: "").compareTo(
|
||||
b.ComputedName?.lowercase() ?: "")
|
||||
}
|
||||
})
|
||||
}
|
||||
.sortedBy {
|
||||
if (it.user?.ID == me?.ID) {
|
||||
""
|
||||
} else {
|
||||
it.user?.DisplayName?.lowercase() ?: "unknown user"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun groupedAndFilteredPeers(searchTerm: String = ""): List<PeerSet> {
|
||||
if (searchTerm.isEmpty()) {
|
||||
return peerSets
|
||||
}
|
||||
|
||||
if (searchTerm == this.lastSearchTerm) {
|
||||
return lastSearchResult
|
||||
}
|
||||
|
||||
// We can optimize out typing... If the search term starts with the last search term, we can
|
||||
// just search the last result
|
||||
val setsToSearch =
|
||||
if (this.lastSearchTerm.isNotEmpty() && searchTerm.startsWith(this.lastSearchTerm))
|
||||
lastSearchResult
|
||||
else peerSets
|
||||
this.lastSearchTerm = searchTerm
|
||||
|
||||
val matchingSets =
|
||||
setsToSearch
|
||||
.map { peerSet ->
|
||||
val user = peerSet.user
|
||||
val peers = peerSet.peers
|
||||
|
||||
val userMatches = user?.DisplayName?.contains(searchTerm, ignoreCase = true) ?: false
|
||||
if (userMatches) {
|
||||
return@map peerSet
|
||||
}
|
||||
|
||||
val matchingPeers =
|
||||
peers.filter {
|
||||
it.displayName.contains(searchTerm, ignoreCase = true) ||
|
||||
(it.Addresses ?: emptyList()).fastAny { addr -> addr.contains(searchTerm) }
|
||||
}
|
||||
if (matchingPeers.isNotEmpty()) {
|
||||
PeerSet(user, matchingPeers)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
.filterNotNull()
|
||||
lastSearchResult = matchingSets
|
||||
return matchingSets
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/** Provides a way to expose a MutableStateFlow as an immutable StateFlow. */
|
||||
fun <T> StateFlow<T>.set(v: T) {
|
||||
(this as MutableStateFlow<T>).value = v
|
||||
}
|
@ -0,0 +1,124 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.util.TSLog
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Date
|
||||
|
||||
object TimeUtil {
|
||||
val TAG = "TimeUtil"
|
||||
|
||||
fun keyExpiryFromGoTime(goTime: String?): ComposableStringFormatter {
|
||||
|
||||
val time = goTime ?: return ComposableStringFormatter(R.string.empty)
|
||||
val expTime = epochMillisFromGoTime(time)
|
||||
val now = Instant.now().toEpochMilli()
|
||||
|
||||
var diff = (expTime - now) / 1000
|
||||
|
||||
// Rather than use plurals here, we'll just use the singular form for everything and
|
||||
// double the minimum. "in 70 minutes" instead of "in 1 hour". 121 minutes becomes
|
||||
// 2 hours, as does 179 minutes... Close enough for what this is used for.
|
||||
|
||||
// Key is already expired (x minutes ago)
|
||||
if (diff < 0) {
|
||||
diff = -diff
|
||||
return when (diff) {
|
||||
in 0..60 -> ComposableStringFormatter(R.string.under_a_minute)
|
||||
in 61..7200 ->
|
||||
ComposableStringFormatter(R.string.ago_x_minutes, diff / 60) // 1 minute to 1 hour
|
||||
in 7201..172800 ->
|
||||
ComposableStringFormatter(R.string.ago_x_hours, diff / 3600) // 2 hours to 24 hours
|
||||
in 172801..5184000 ->
|
||||
ComposableStringFormatter(R.string.ago_x_days, diff / 86400) // 2 Days to 60 days
|
||||
in 5184001..124416000 ->
|
||||
ComposableStringFormatter(R.string.ago_x_months, diff / 2592000) // ~2 months to 2 years
|
||||
else ->
|
||||
ComposableStringFormatter(
|
||||
R.string.ago_x_years,
|
||||
diff.toDouble() / 31536000.0) // 2 years to n years (in decimal)
|
||||
}
|
||||
}
|
||||
|
||||
// Key is not expired (in x minutes)
|
||||
return when (diff) {
|
||||
in 0..60 -> ComposableStringFormatter(R.string.under_a_minute)
|
||||
in 61..7200 ->
|
||||
ComposableStringFormatter(R.string.in_x_minutes, diff / 60) // 1 minute to 1 hour
|
||||
in 7201..172800 ->
|
||||
ComposableStringFormatter(R.string.in_x_hours, diff / 3600) // 2 hours to 24 hours
|
||||
in 172801..5184000 ->
|
||||
ComposableStringFormatter(R.string.in_x_days, diff / 86400) // 2 Days to 60 days
|
||||
in 5184001..124416000 ->
|
||||
ComposableStringFormatter(R.string.in_x_months, diff / 2592000) // ~2 months to 2 years
|
||||
else ->
|
||||
ComposableStringFormatter(
|
||||
R.string.in_x_years, diff.toDouble() / 31536000.0) // 2 years to n years (in decimal)
|
||||
}
|
||||
}
|
||||
|
||||
fun epochMillisFromGoTime(goTime: String): Long {
|
||||
val ta = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(goTime)
|
||||
val i = Instant.from(ta)
|
||||
return i.toEpochMilli()
|
||||
}
|
||||
|
||||
fun dateFromGoString(goTime: String): Date {
|
||||
val ta = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(goTime)
|
||||
val i = Instant.from(ta)
|
||||
return Date.from(i)
|
||||
}
|
||||
|
||||
// Returns true if the given Go time string is in the past, or will occur within the given
|
||||
// duration from now.
|
||||
fun isWithinExpiryNotificationWindow(window: Duration, goTime: String): Boolean {
|
||||
val expTime = epochMillisFromGoTime(goTime)
|
||||
val now = Instant.now().toEpochMilli()
|
||||
return (expTime - now) / 1000 < window.seconds
|
||||
}
|
||||
|
||||
// Parses a Go duration string (e.g. "2h3.2m4s") and returns a Java Duration object.
|
||||
// Returns null if the input string is not a valid Go duration or contains
|
||||
// units other than y,w,d,h,m,s (ms and us are explicitly not supported).
|
||||
fun duration(goDuration: String): Duration? {
|
||||
if (goDuration.contains("ms") || goDuration.contains("us")) {
|
||||
return null
|
||||
}
|
||||
|
||||
var duration = 0.0
|
||||
var valStr = ""
|
||||
for (c in goDuration) {
|
||||
// Scan digits and decimal points
|
||||
if (c.isDigit() || c == '.') {
|
||||
valStr += c
|
||||
} else {
|
||||
try {
|
||||
val durationFragment = valStr.toDouble()
|
||||
duration +=
|
||||
when (c) {
|
||||
'y' -> durationFragment * 31536000.0 // 365 days
|
||||
'w' -> durationFragment * 604800.0
|
||||
'd' -> durationFragment * 86400.0
|
||||
'h' -> durationFragment * 3600.0
|
||||
'm' -> durationFragment * 60.0
|
||||
's' -> durationFragment
|
||||
else -> {
|
||||
TSLog.e(TAG, "Invalid duration string: $goDuration")
|
||||
return null
|
||||
}
|
||||
}
|
||||
} catch (e: NumberFormatException) {
|
||||
TSLog.e(TAG, "Invalid duration string: $goDuration")
|
||||
return null
|
||||
}
|
||||
valStr = ""
|
||||
}
|
||||
}
|
||||
return Duration.ofSeconds(duration.toLong())
|
||||
}
|
||||
}
|
@ -0,0 +1,104 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tailscale.ipn.BuildConfig
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.Links
|
||||
import com.tailscale.ipn.ui.theme.logoBackground
|
||||
import com.tailscale.ipn.ui.util.AppVersion
|
||||
|
||||
@Composable
|
||||
fun AboutView(backToSettings: BackNavigation) {
|
||||
val localClipboardManager = LocalClipboardManager.current
|
||||
|
||||
Scaffold(topBar = { Header(R.string.about_view_header, onBack = backToSettings) }) { innerPadding
|
||||
->
|
||||
Column(
|
||||
verticalArrangement =
|
||||
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())) {
|
||||
TailscaleLogoView(
|
||||
usesOnBackgroundColors = true,
|
||||
modifier =
|
||||
Modifier.width(100.dp)
|
||||
.height(100.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(MaterialTheme.colorScheme.logoBackground)
|
||||
.padding(25.dp))
|
||||
|
||||
Column(
|
||||
verticalArrangement =
|
||||
Arrangement.spacedBy(space = 2.dp, alignment = Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
stringResource(R.string.about_view_title),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = MaterialTheme.typography.titleLarge.fontSize)
|
||||
Text(
|
||||
modifier =
|
||||
Modifier.clickable {
|
||||
// When users tap on the version number, the extended version string
|
||||
// (including commit hashes) is copied to the clipboard.
|
||||
// This may be useful for debugging purposes...
|
||||
localClipboardManager.setText(AnnotatedString(BuildConfig.VERSION_NAME))
|
||||
},
|
||||
// ... but we always display the short version in the UI to avoid user
|
||||
// confusion.
|
||||
text = "${stringResource(R.string.version)} ${AppVersion.Short()}",
|
||||
fontWeight = MaterialTheme.typography.bodyMedium.fontWeight,
|
||||
fontSize = MaterialTheme.typography.bodyMedium.fontSize)
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
OpenURLButton(stringResource(R.string.acknowledgements), Links.LICENSES_URL)
|
||||
OpenURLButton(stringResource(R.string.privacy_policy), Links.PRIVACY_POLICY_URL)
|
||||
OpenURLButton(stringResource(R.string.terms_of_service), Links.TERMS_URL)
|
||||
}
|
||||
|
||||
Text(
|
||||
stringResource(R.string.about_view_footnotes),
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = MaterialTheme.typography.labelMedium.fontSize,
|
||||
textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AboutPreview() {
|
||||
AboutView({})
|
||||
}
|
@ -0,0 +1,95 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.compose.AsyncImage
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.model.IpnLocal
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
@Composable
|
||||
fun Avatar(
|
||||
profile: IpnLocal.LoginProfile?,
|
||||
size: Int = 50,
|
||||
action: (() -> Unit)? = null,
|
||||
isFocusable: Boolean = false
|
||||
) {
|
||||
var isFocused = remember { mutableStateOf(false) }
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
// Outer Box for the larger focusable and clickable area
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.size((size * 1.5f).dp) // Focusable area is larger than the avatar
|
||||
.clip(CircleShape) // Ensure both the focus and click area are circular
|
||||
.background(
|
||||
if (isFocused.value) MaterialTheme.colorScheme.surface
|
||||
else Color.Transparent,
|
||||
)
|
||||
.onFocusChanged { focusState ->
|
||||
isFocused.value = focusState.isFocused
|
||||
}
|
||||
.focusable() // Make this outer Box focusable (after onFocusChanged)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = ripple(bounded = true), // Apply ripple effect inside circular bounds
|
||||
onClick = {
|
||||
action?.invoke()
|
||||
focusManager.clearFocus() // Clear focus after clicking the avatar
|
||||
}
|
||||
)
|
||||
) {
|
||||
// Inner Box to hold the avatar content (Icon or AsyncImage)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(size.dp)
|
||||
.clip(CircleShape)
|
||||
) {
|
||||
// Always display the default icon as a background layer
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = stringResource(R.string.settings_title),
|
||||
modifier =
|
||||
Modifier.size((size * 0.8f).dp)
|
||||
.clip(CircleShape) // Icon size slightly smaller than the Box
|
||||
)
|
||||
|
||||
// Overlay the profile picture if available
|
||||
profile?.UserProfile?.ProfilePicURL?.let { url ->
|
||||
AsyncImage(
|
||||
model = url,
|
||||
modifier = Modifier.size(size.dp).clip(CircleShape),
|
||||
contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,94 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.Links
|
||||
import com.tailscale.ipn.ui.theme.defaultTextColor
|
||||
import com.tailscale.ipn.ui.theme.link
|
||||
import com.tailscale.ipn.ui.util.ClipboardValueView
|
||||
import com.tailscale.ipn.ui.util.Lists
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.ui.viewModel.BugReportViewModel
|
||||
|
||||
@Composable
|
||||
fun BugReportView(backToSettings: BackNavigation, model: BugReportViewModel = viewModel()) {
|
||||
val handler = LocalUriHandler.current
|
||||
val bugReportID by model.bugReportID.collectAsState()
|
||||
|
||||
Scaffold(topBar = { Header(R.string.bug_report_title, onBack = backToSettings) }) { innerPadding
|
||||
->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.padding(innerPadding)
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(rememberScrollState())) {
|
||||
Lists.MultilineDescription {
|
||||
ClickableText(
|
||||
text = contactText(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
onClick = { handler.openUri(Links.SUPPORT_URL) })
|
||||
}
|
||||
|
||||
ClipboardValueView(bugReportID, title = stringResource(R.string.bug_report_id))
|
||||
|
||||
Lists.InfoItem(stringResource(id = R.string.bug_report_id_desc))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun contactText(): AnnotatedString {
|
||||
val annotatedString = buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
|
||||
append(stringResource(id = R.string.bug_report_instructions_prefix))
|
||||
}
|
||||
|
||||
pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL)
|
||||
withStyle(
|
||||
style =
|
||||
SpanStyle(
|
||||
color = MaterialTheme.colorScheme.link,
|
||||
textDecoration = TextDecoration.Underline)) {
|
||||
append(stringResource(id = R.string.bug_report_instructions_linktext))
|
||||
}
|
||||
pop()
|
||||
|
||||
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
|
||||
append(stringResource(id = R.string.bug_report_instructions_suffix))
|
||||
}
|
||||
}
|
||||
return annotatedString
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun BugReportPreview() {
|
||||
val vm = BugReportViewModel()
|
||||
vm.bugReportID.set("12345678ABCDEF-12345678ABCDEF")
|
||||
BugReportView({}, vm)
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tailscale.ipn.ui.theme.link
|
||||
|
||||
@Composable
|
||||
fun PrimaryActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
contentPadding = PaddingValues(vertical = 12.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
content = content)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OpenURLButton(title: String, url: String) {
|
||||
val handler = LocalUriHandler.current
|
||||
|
||||
TextButton(onClick = { handler.openUri(url) }) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.link,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,157 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.theme.listItem
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.ui.viewModel.LoginWithAuthKeyViewModel
|
||||
import com.tailscale.ipn.ui.viewModel.LoginWithCustomControlURLViewModel
|
||||
|
||||
data class LoginViewStrings(
|
||||
var title: String,
|
||||
var explanation: String,
|
||||
var inputTitle: String,
|
||||
var placeholder: String,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun LoginWithCustomControlURLView(
|
||||
onNavigateHome: BackNavigation,
|
||||
backToSettings: BackNavigation,
|
||||
viewModel: LoginWithCustomControlURLViewModel = LoginWithCustomControlURLViewModel()
|
||||
) {
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Header(
|
||||
R.string.add_account,
|
||||
onBack = backToSettings,
|
||||
)
|
||||
}) { innerPadding ->
|
||||
val error by viewModel.errorDialog.collectAsState()
|
||||
val strings =
|
||||
LoginViewStrings(
|
||||
title = stringResource(id = R.string.custom_control_menu),
|
||||
explanation = stringResource(id = R.string.custom_control_menu_desc),
|
||||
inputTitle = stringResource(id = R.string.custom_control_url_title),
|
||||
placeholder = stringResource(id = R.string.custom_control_placeholder),
|
||||
)
|
||||
|
||||
error?.let { ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) }) }
|
||||
|
||||
LoginView(
|
||||
innerPadding = innerPadding,
|
||||
strings = strings,
|
||||
onSubmitAction = { viewModel.setControlURL(it, onNavigateHome) })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoginWithAuthKeyView(
|
||||
onNavigateHome: BackNavigation,
|
||||
backToSettings: BackNavigation,
|
||||
viewModel: LoginWithAuthKeyViewModel = LoginWithAuthKeyViewModel()
|
||||
) {
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Header(
|
||||
R.string.add_account,
|
||||
onBack = backToSettings,
|
||||
)
|
||||
}) { innerPadding ->
|
||||
val error by viewModel.errorDialog.collectAsState()
|
||||
val strings =
|
||||
LoginViewStrings(
|
||||
title = stringResource(id = R.string.auth_key_title),
|
||||
explanation = stringResource(id = R.string.auth_key_explanation),
|
||||
inputTitle = stringResource(id = R.string.auth_key_input_title),
|
||||
placeholder = stringResource(id = R.string.auth_key_placeholder),
|
||||
)
|
||||
// Show the error overlay if need be
|
||||
error?.let { ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) }) }
|
||||
|
||||
LoginView(
|
||||
innerPadding = innerPadding,
|
||||
strings = strings,
|
||||
onSubmitAction = { viewModel.setAuthKey(it, onNavigateHome) })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoginView(
|
||||
innerPadding: PaddingValues = PaddingValues(16.dp),
|
||||
strings: LoginViewStrings,
|
||||
onSubmitAction: (String) -> Unit,
|
||||
) {
|
||||
|
||||
var textVal by remember { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.padding(innerPadding)
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)) {
|
||||
ListItem(
|
||||
colors = MaterialTheme.colorScheme.listItem,
|
||||
headlineContent = { Text(text = strings.title) },
|
||||
supportingContent = { Text(text = strings.explanation) })
|
||||
|
||||
ListItem(
|
||||
colors = MaterialTheme.colorScheme.listItem,
|
||||
headlineContent = { Text(text = strings.inputTitle) },
|
||||
supportingContent = {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent),
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
value = textVal,
|
||||
onValueChange = { textVal = it },
|
||||
placeholder = {
|
||||
Text(strings.placeholder, style = MaterialTheme.typography.bodySmall)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None)
|
||||
)
|
||||
})
|
||||
|
||||
ListItem(
|
||||
colors = MaterialTheme.colorScheme.listItem,
|
||||
headlineContent = {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Button(
|
||||
onClick = { onSubmitAction(textVal) },
|
||||
content = { Text(stringResource(id = R.string.add_account_short)) })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,116 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.mdm.MDMSettings
|
||||
import com.tailscale.ipn.ui.model.DnsType
|
||||
import com.tailscale.ipn.ui.notifier.Notifier
|
||||
import com.tailscale.ipn.ui.util.ClipboardValueView
|
||||
import com.tailscale.ipn.ui.util.Lists
|
||||
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||
import com.tailscale.ipn.ui.util.itemsWithDividers
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.ui.viewModel.DNSEnablementState
|
||||
import com.tailscale.ipn.ui.viewModel.DNSSettingsViewModel
|
||||
import com.tailscale.ipn.ui.viewModel.DNSSettingsViewModelFactory
|
||||
|
||||
data class ViewableRoute(val name: String, val resolvers: List<DnsType.Resolver>)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun DNSSettingsView(
|
||||
backToSettings: BackNavigation,
|
||||
model: DNSSettingsViewModel = viewModel(factory = DNSSettingsViewModelFactory())
|
||||
) {
|
||||
val state: DNSEnablementState by model.enablementState.collectAsState()
|
||||
val resolvers = model.dnsConfig.collectAsState().value?.Resolvers ?: emptyList()
|
||||
val domains = model.dnsConfig.collectAsState().value?.Domains ?: emptyList()
|
||||
val routes: List<ViewableRoute> =
|
||||
model.dnsConfig.collectAsState().value?.Routes?.mapNotNull { entry ->
|
||||
entry.value?.let { resolvers -> ViewableRoute(name = entry.key, resolvers) } ?: run { null }
|
||||
} ?: emptyList()
|
||||
val useCorpDNS = Notifier.prefs.collectAsState().value?.CorpDNS == true
|
||||
val dnsSettingsMDMDisposition by MDMSettings.useTailscaleDNSSettings.flow.collectAsState()
|
||||
|
||||
Scaffold(topBar = { Header(R.string.dns_settings, onBack = backToSettings) }) { innerPadding ->
|
||||
LoadingIndicator.Wrap {
|
||||
LazyColumn(Modifier.padding(innerPadding)) {
|
||||
item("state") {
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(
|
||||
painter = painterResource(state.symbolDrawable),
|
||||
contentDescription = null,
|
||||
tint = state.tint(),
|
||||
modifier = Modifier.size(36.dp))
|
||||
},
|
||||
headlineContent = {
|
||||
Text(stringResource(state.title), style = MaterialTheme.typography.titleMedium)
|
||||
},
|
||||
supportingContent = { Text(stringResource(state.caption)) })
|
||||
|
||||
if (!dnsSettingsMDMDisposition.value.hiddenFromUser) {
|
||||
Lists.ItemDivider()
|
||||
Setting.Switch(
|
||||
R.string.use_ts_dns,
|
||||
isOn = useCorpDNS,
|
||||
onToggle = {
|
||||
LoadingIndicator.start()
|
||||
model.toggleCorpDNS { LoadingIndicator.stop() }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvers.isNotEmpty()) {
|
||||
item("resolversHeader") { Lists.SectionDivider(stringResource(R.string.resolvers)) }
|
||||
|
||||
itemsWithDividers(resolvers) { resolver -> ClipboardValueView(resolver.Addr.orEmpty()) }
|
||||
}
|
||||
|
||||
if (domains.isNotEmpty()) {
|
||||
item("domainsHeader") { Lists.SectionDivider(stringResource(R.string.search_domains)) }
|
||||
|
||||
itemsWithDividers(domains) { domain -> ClipboardValueView(domain) }
|
||||
}
|
||||
|
||||
if (routes.isNotEmpty()) {
|
||||
routes.forEach { route ->
|
||||
item { Lists.SectionDivider("Route: ${route.name}") }
|
||||
|
||||
itemsWithDividers(route.resolvers) { resolver ->
|
||||
ClipboardValueView(resolver.Addr.orEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun DNSSettingsViewPreview() {
|
||||
val vm = DNSSettingsViewModel()
|
||||
vm.enablementState.set(DNSEnablementState.ENABLED)
|
||||
DNSSettingsView(backToSettings = {}, vm)
|
||||
}
|
@ -0,0 +1,82 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.theme.AppTheme
|
||||
|
||||
|
||||
enum class ErrorDialogType {
|
||||
INVALID_CUSTOM_URL,
|
||||
LOGOUT_FAILED,
|
||||
SWITCH_USER_FAILED,
|
||||
ADD_PROFILE_FAILED,
|
||||
SHARE_DEVICE_NOT_CONNECTED,
|
||||
SHARE_FAILED,
|
||||
INVALID_AUTH_KEY;
|
||||
|
||||
val message: Int
|
||||
get() {
|
||||
return when (this) {
|
||||
INVALID_CUSTOM_URL -> R.string.invalidCustomUrl
|
||||
LOGOUT_FAILED -> R.string.logout_failed
|
||||
SWITCH_USER_FAILED -> R.string.switch_user_failed
|
||||
ADD_PROFILE_FAILED -> R.string.add_profile_failed
|
||||
SHARE_DEVICE_NOT_CONNECTED -> R.string.share_device_not_connected
|
||||
SHARE_FAILED -> R.string.taildrop_share_failed
|
||||
INVALID_AUTH_KEY -> R.string.invalidAuthKey
|
||||
}
|
||||
}
|
||||
|
||||
val title: Int
|
||||
get() {
|
||||
return when (this) {
|
||||
INVALID_CUSTOM_URL -> R.string.invalidCustomURLTitle
|
||||
LOGOUT_FAILED -> R.string.logout_failed_title
|
||||
SWITCH_USER_FAILED -> R.string.switch_user_failed_title
|
||||
ADD_PROFILE_FAILED -> R.string.add_profile_failed_title
|
||||
SHARE_DEVICE_NOT_CONNECTED -> R.string.share_device_not_connected_title
|
||||
SHARE_FAILED -> R.string.taildrop_share_failed_title
|
||||
INVALID_AUTH_KEY -> R.string.invalidAuthKeyTitle
|
||||
}
|
||||
}
|
||||
|
||||
val buttonText: Int = R.string.ok
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ErrorDialog(type: ErrorDialogType, action: () -> Unit = {}) {
|
||||
ErrorDialog(
|
||||
title = type.title, message = type.message, buttonText = type.buttonText, onDismiss = action)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ErrorDialog(
|
||||
@StringRes title: Int = R.string.error,
|
||||
@StringRes message: Int,
|
||||
@StringRes buttonText: Int = R.string.ok,
|
||||
onDismiss: () -> Unit = {}
|
||||
) {
|
||||
AppTheme {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(text = stringResource(id = title)) },
|
||||
text = { Text(text = stringResource(id = message)) },
|
||||
confirmButton = {
|
||||
PrimaryActionButton(onClick = onDismiss) { Text(text = stringResource(id = buttonText)) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ErrorDialogPreview() {
|
||||
ErrorDialog(ErrorDialogType.LOGOUT_FAILED)
|
||||
}
|
@ -0,0 +1,225 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Check
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.mdm.MDMSettings
|
||||
import com.tailscale.ipn.mdm.ShowHide
|
||||
import com.tailscale.ipn.ui.notifier.Notifier
|
||||
import com.tailscale.ipn.ui.theme.disabledListItem
|
||||
import com.tailscale.ipn.ui.theme.listItem
|
||||
import com.tailscale.ipn.ui.util.Lists
|
||||
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||
import com.tailscale.ipn.ui.util.itemsWithDividers
|
||||
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
|
||||
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
|
||||
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
|
||||
import com.tailscale.ipn.ui.viewModel.selected
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@Composable
|
||||
fun ExitNodePicker(
|
||||
nav: ExitNodePickerNav,
|
||||
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
|
||||
) {
|
||||
LoadingIndicator.Wrap {
|
||||
Scaffold(topBar = { Header(R.string.choose_exit_node, onBack = nav.onNavigateBackHome) }) {
|
||||
innerPadding ->
|
||||
val tailnetExitNodes by model.tailnetExitNodes.collectAsState()
|
||||
val mullvadExitNodesByCountryCode by model.mullvadExitNodesByCountryCode.collectAsState()
|
||||
val mullvadExitNodeCount by model.mullvadExitNodeCount.collectAsState()
|
||||
val anyActive by model.anyActive.collectAsState()
|
||||
val shouldShowMullvadInfo by model.shouldShowMullvadInfo.collectAsState()
|
||||
val allowLANAccess = Notifier.prefs.collectAsState().value?.ExitNodeAllowLANAccess == true
|
||||
val showRunAsExitNode by MDMSettings.runExitNode.flow.collectAsState()
|
||||
val allowLanAccessMDMDisposition by MDMSettings.exitNodeAllowLANAccess.flow.collectAsState()
|
||||
val managedByOrganization by model.managedByOrganization.collectAsState()
|
||||
val forcedExitNodeId = MDMSettings.exitNodeID.flow.collectAsState().value.value
|
||||
|
||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||
item(key = "header") {
|
||||
if (forcedExitNodeId != null) {
|
||||
Text(
|
||||
text =
|
||||
managedByOrganization.value?.let {
|
||||
stringResource(R.string.exit_node_mdm_orgname, it)
|
||||
} ?: stringResource(R.string.exit_node_mdm),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp))
|
||||
} else {
|
||||
ExitNodeItem(
|
||||
model,
|
||||
ExitNodePickerViewModel.ExitNode(
|
||||
label = stringResource(R.string.none),
|
||||
online = MutableStateFlow(true),
|
||||
selected = !anyActive,
|
||||
))
|
||||
}
|
||||
if (showRunAsExitNode.value == ShowHide.Show) {
|
||||
Lists.ItemDivider()
|
||||
RunAsExitNodeItem(nav = nav, viewModel = model, anyActive)
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "divider1") { Lists.SectionDivider() }
|
||||
|
||||
itemsWithDividers(tailnetExitNodes, key = { it.id!! }) { node -> ExitNodeItem(model, node) }
|
||||
|
||||
if (mullvadExitNodeCount > 0) {
|
||||
item(key = "mullvad") {
|
||||
Lists.SectionDivider()
|
||||
MullvadItem(
|
||||
nav, mullvadExitNodesByCountryCode.size, mullvadExitNodesByCountryCode.selected)
|
||||
}
|
||||
} else if (shouldShowMullvadInfo) {
|
||||
item(key = "mullvad_info") {
|
||||
Lists.SectionDivider()
|
||||
MullvadInfoItem(nav)
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowLanAccessMDMDisposition.value.hiddenFromUser) {
|
||||
item(key = "allowLANAccess") {
|
||||
Lists.SectionDivider()
|
||||
|
||||
Setting.Switch(R.string.allow_lan_access, isOn = allowLANAccess) {
|
||||
LoadingIndicator.start()
|
||||
model.toggleAllowLANAccess { LoadingIndicator.stop() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExitNodeItem(
|
||||
viewModel: ExitNodePickerViewModel,
|
||||
node: ExitNodePickerViewModel.ExitNode,
|
||||
) {
|
||||
val online by node.online.collectAsState()
|
||||
val isRunningExitNode = viewModel.isRunningExitNode.collectAsState().value
|
||||
val forcedExitNodeId = MDMSettings.exitNodeID.flow.collectAsState().value.value
|
||||
|
||||
Box {
|
||||
var modifier: Modifier = Modifier
|
||||
if (online && !isRunningExitNode && forcedExitNodeId == null) {
|
||||
modifier = modifier.clickable { viewModel.setExitNode(node) }
|
||||
}
|
||||
ListItem(
|
||||
modifier = modifier,
|
||||
colors =
|
||||
if (online && !isRunningExitNode) MaterialTheme.colorScheme.listItem
|
||||
else MaterialTheme.colorScheme.disabledListItem,
|
||||
headlineContent = {
|
||||
Text(node.city.ifEmpty { node.label }, style = MaterialTheme.typography.bodyMedium)
|
||||
},
|
||||
supportingContent = {
|
||||
if (!online)
|
||||
Text(stringResource(R.string.offline), style = MaterialTheme.typography.bodyMedium)
|
||||
},
|
||||
trailingContent = {
|
||||
Row {
|
||||
if (node.selected) {
|
||||
Icon(Icons.Outlined.Check, null)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MullvadItem(nav: ExitNodePickerNav, count: Int, selected: Boolean) {
|
||||
Box {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { nav.onNavigateToMullvad() },
|
||||
headlineContent = {
|
||||
Text(
|
||||
stringResource(R.string.mullvad_exit_nodes),
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
"$count ${stringResource(R.string.countries)}",
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
},
|
||||
trailingContent = {
|
||||
if (selected) {
|
||||
Icon(Icons.Outlined.Check, null)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MullvadInfoItem(nav: ExitNodePickerNav) {
|
||||
Box {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { nav.onNavigateToMullvadInfo() },
|
||||
headlineContent = {
|
||||
Text(
|
||||
stringResource(R.string.mullvad_exit_nodes),
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
stringResource(R.string.enable_in_the_admin_console),
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RunAsExitNodeItem(
|
||||
nav: ExitNodePickerNav,
|
||||
viewModel: ExitNodePickerViewModel,
|
||||
anyActive: Boolean
|
||||
) {
|
||||
val isRunningExitNode = viewModel.isRunningExitNode.collectAsState().value
|
||||
|
||||
Box {
|
||||
var modifier: Modifier = Modifier
|
||||
if (!anyActive) {
|
||||
modifier = modifier.clickable { nav.onNavigateToRunAsExitNode() }
|
||||
}
|
||||
ListItem(
|
||||
modifier = modifier,
|
||||
colors =
|
||||
if (!anyActive) MaterialTheme.colorScheme.listItem
|
||||
else MaterialTheme.colorScheme.disabledListItem,
|
||||
headlineContent = {
|
||||
Text(
|
||||
stringResource(id = R.string.run_as_exit_node),
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
},
|
||||
supportingContent = {
|
||||
if (isRunningExitNode) {
|
||||
Text(stringResource(R.string.enabled))
|
||||
} else {
|
||||
Text(stringResource(R.string.disabled))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,99 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.model.Health
|
||||
import com.tailscale.ipn.ui.theme.success
|
||||
import com.tailscale.ipn.ui.viewModel.HealthViewModel
|
||||
|
||||
@Composable
|
||||
fun HealthView(backToSettings: BackNavigation, model: HealthViewModel = viewModel()) {
|
||||
val warnings by model.warnings.collectAsState()
|
||||
|
||||
Scaffold(topBar = { Header(titleRes = R.string.health_warnings, onBack = backToSettings) }) {
|
||||
innerPadding ->
|
||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||
if (warnings.isEmpty()) {
|
||||
item("allGood") {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.Top),
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.check_circle),
|
||||
modifier = Modifier.size(48.dp),
|
||||
contentDescription = "A green checkmark",
|
||||
tint = MaterialTheme.colorScheme.success)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement =
|
||||
Arrangement.spacedBy(2.dp, alignment = Alignment.CenterVertically),
|
||||
modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_issues_found),
|
||||
fontSize = MaterialTheme.typography.titleMedium.fontSize,
|
||||
fontWeight = MaterialTheme.typography.titleMedium.fontWeight)
|
||||
Text(
|
||||
text = stringResource(R.string.tailscale_is_operating_normally),
|
||||
color = MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(warnings) { HealthWarningView(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HealthWarningView(warning: Health.UnhealthyState) {
|
||||
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainerLow)) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
|
||||
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
|
||||
.fillMaxWidth()) {
|
||||
ListItem(
|
||||
colors = warning.Severity.listItemColors(),
|
||||
headlineContent = {
|
||||
if (warning.Title.isNotEmpty()) {
|
||||
Text(
|
||||
warning.Title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
},
|
||||
supportingContent = {
|
||||
Text(warning.Text, style = MaterialTheme.typography.bodyMedium)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,68 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.theme.AppTheme
|
||||
|
||||
@Composable
|
||||
fun IntroView(onContinue: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxHeight().fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center) {
|
||||
TailscaleLogoView(modifier = Modifier.width(60.dp).height(60.dp))
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 40.dp, end = 40.dp, bottom = 40.dp),
|
||||
text = stringResource(R.string.welcome1),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center)
|
||||
|
||||
Button(onClick = onContinue) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.getStarted),
|
||||
fontSize = MaterialTheme.typography.titleMedium.fontSize)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxHeight().padding(start = 20.dp, end = 20.dp, bottom = 40.dp),
|
||||
contentAlignment = Alignment.BottomCenter) {
|
||||
Text(
|
||||
text = stringResource(R.string.welcome2),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun IntroViewPreview() {
|
||||
AppTheme { Surface { IntroView({}) } }
|
||||
}
|
@ -0,0 +1,88 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.theme.AppTheme
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.ui.viewModel.LoginQRViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel()) {
|
||||
Surface(color = MaterialTheme.colorScheme.scrim, modifier = Modifier.fillMaxSize()) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
val image by model.qrCode.collectAsState()
|
||||
val numCode by model.numCode.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = stringResource(R.string.scan_to_connect_to_your_tailnet),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(200.dp)
|
||||
.background(MaterialTheme.colorScheme.onSurface)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center) {
|
||||
image?.let {
|
||||
Image(
|
||||
bitmap = it,
|
||||
contentDescription = "Scan to login",
|
||||
modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
numCode?.let { it ->
|
||||
Text(
|
||||
text = stringResource(R.string.enter_code_to_connect_to_tailnet, it),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
Button(onClick = onDismiss) { Text(text = stringResource(R.string.dismiss)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun LoginQRViewPreview() {
|
||||
val vm = LoginQRViewModel()
|
||||
vm.qrCode.set(vm.generateQRCode("https://tailscale.com", 200, 0))
|
||||
vm.numCode.set("123456789")
|
||||
AppTheme { LoginQRView({}, vm) }
|
||||
}
|
@ -0,0 +1,61 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.mdm.MDMSetting
|
||||
import com.tailscale.ipn.mdm.MDMSettings
|
||||
import com.tailscale.ipn.ui.util.itemsWithDividers
|
||||
import com.tailscale.ipn.ui.viewModel.IpnViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MDMSettingsDebugView(
|
||||
backToSettings: BackNavigation,
|
||||
@Suppress("UNUSED_PARAMETER") model: IpnViewModel = viewModel()
|
||||
) {
|
||||
Scaffold(topBar = { Header(R.string.current_mdm_settings, onBack = backToSettings) }) {
|
||||
innerPadding ->
|
||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||
itemsWithDividers(MDMSettings.allSettings.sortedBy { "${it::class.java.name}|${it.key}" }) {
|
||||
setting ->
|
||||
MDMSettingView(setting)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MDMSettingView(setting: MDMSetting<*>) {
|
||||
val value by setting.flow.collectAsState()
|
||||
ListItem(
|
||||
headlineContent = { Text(setting.localizedTitle, maxLines = 3) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
setting.key,
|
||||
fontSize = MaterialTheme.typography.labelSmall.fontSize,
|
||||
fontFamily = FontFamily.Monospace)
|
||||
},
|
||||
trailingContent = {
|
||||
Text(
|
||||
if (value.isSet) value.value.toString() else "[not set]",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
fontWeight = FontWeight.SemiBold)
|
||||
})
|
||||
}
|
@ -0,0 +1,813 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.outlined.ArrowDropDown
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SearchBar
|
||||
import androidx.compose.material3.SearchBarDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.tailscale.ipn.App
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.mdm.MDMSettings
|
||||
import com.tailscale.ipn.mdm.ShowHide
|
||||
import com.tailscale.ipn.ui.model.Ipn
|
||||
import com.tailscale.ipn.ui.model.IpnLocal
|
||||
import com.tailscale.ipn.ui.model.Netmap
|
||||
import com.tailscale.ipn.ui.model.Permissions
|
||||
import com.tailscale.ipn.ui.model.Tailcfg
|
||||
import com.tailscale.ipn.ui.theme.customErrorContainer
|
||||
import com.tailscale.ipn.ui.theme.disabled
|
||||
import com.tailscale.ipn.ui.theme.errorButton
|
||||
import com.tailscale.ipn.ui.theme.errorListItem
|
||||
import com.tailscale.ipn.ui.theme.exitNodeToggleButton
|
||||
import com.tailscale.ipn.ui.theme.listItem
|
||||
import com.tailscale.ipn.ui.theme.minTextSize
|
||||
import com.tailscale.ipn.ui.theme.primaryListItem
|
||||
import com.tailscale.ipn.ui.theme.secondaryButton
|
||||
import com.tailscale.ipn.ui.theme.short
|
||||
import com.tailscale.ipn.ui.theme.surfaceContainerListItem
|
||||
import com.tailscale.ipn.ui.theme.warningButton
|
||||
import com.tailscale.ipn.ui.theme.warningListItem
|
||||
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
|
||||
import com.tailscale.ipn.ui.util.AutoResizingText
|
||||
import com.tailscale.ipn.ui.util.Lists
|
||||
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||
import com.tailscale.ipn.ui.util.PeerSet
|
||||
import com.tailscale.ipn.ui.util.itemsWithDividers
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.ui.viewModel.IpnViewModel.NodeState
|
||||
import com.tailscale.ipn.ui.viewModel.MainViewModel
|
||||
import com.tailscale.ipn.ui.viewModel.VpnViewModel
|
||||
|
||||
// Navigation actions for the MainView
|
||||
data class MainViewNavigation(
|
||||
val onNavigateToSettings: () -> Unit,
|
||||
val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
|
||||
val onNavigateToExitNodes: () -> Unit,
|
||||
val onNavigateToHealth: () -> Unit
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainView(
|
||||
loginAtUrl: (String) -> Unit,
|
||||
navigation: MainViewNavigation,
|
||||
viewModel: MainViewModel
|
||||
) {
|
||||
val currentPingDevice by viewModel.pingViewModel.peer.collectAsState()
|
||||
val healthIcon by viewModel.healthIcon.collectAsState()
|
||||
|
||||
LoadingIndicator.Wrap {
|
||||
Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(paddingInsets),
|
||||
verticalArrangement = Arrangement.Center) {
|
||||
// Assume VPN has been prepared for optimistic UI. Whether or not it has been prepared
|
||||
// cannot be known
|
||||
// until permission has been granted to prepare the VPN.
|
||||
val isPrepared by viewModel.isVpnPrepared.collectAsState(initial = true)
|
||||
val isOn by viewModel.vpnToggleState.collectAsState(initial = false)
|
||||
val state by viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
|
||||
val user by viewModel.loggedInUser.collectAsState(initial = null)
|
||||
val stateVal by viewModel.stateRes.collectAsState(initial = R.string.placeholder)
|
||||
val stateStr = stringResource(id = stateVal)
|
||||
val netmap by viewModel.netmap.collectAsState(initial = null)
|
||||
val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState()
|
||||
val disableToggle by MDMSettings.forceEnabled.flow.collectAsState()
|
||||
val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false)
|
||||
|
||||
// Hide the header only on Android TV when the user needs to login
|
||||
val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin)
|
||||
|
||||
ListItem(
|
||||
colors = MaterialTheme.colorScheme.surfaceContainerListItem,
|
||||
leadingContent = {
|
||||
if (!hideHeader) {
|
||||
TintedSwitch(
|
||||
onCheckedChange = {
|
||||
if (!disableToggle.value) {
|
||||
viewModel.toggleVpn()
|
||||
}
|
||||
},
|
||||
enabled = !disableToggle.value,
|
||||
checked = isOn)
|
||||
}
|
||||
},
|
||||
headlineContent = {
|
||||
user?.NetworkProfile?.DomainName?.let { domain ->
|
||||
AutoResizingText(
|
||||
text = domain,
|
||||
style = MaterialTheme.typography.titleMedium.short,
|
||||
minFontSize = MaterialTheme.typography.minTextSize,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
},
|
||||
supportingContent = {
|
||||
if (!hideHeader) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = stateStr, style = MaterialTheme.typography.bodyMedium.short)
|
||||
healthIcon?.let {
|
||||
Spacer(modifier = Modifier.size(4.dp))
|
||||
IconButton(
|
||||
onClick = { navigation.onNavigateToHealth() },
|
||||
modifier = Modifier.size(16.dp)) {
|
||||
Icon(
|
||||
painterResource(id = it),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Box(modifier = Modifier.padding(8.dp), contentAlignment = Alignment.CenterEnd) {
|
||||
when (user) {
|
||||
null -> SettingsButton { navigation.onNavigateToSettings() }
|
||||
else -> {
|
||||
Avatar(profile = user, size = 36, { navigation.onNavigateToSettings() }, isFocusable=true)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
when (state) {
|
||||
Ipn.State.Running -> {
|
||||
|
||||
PromptPermissionsIfNecessary()
|
||||
|
||||
viewModel.showVPNPermissionLauncherIfUnauthorized()
|
||||
|
||||
if (showKeyExpiry) {
|
||||
ExpiryNotification(netmap = netmap, action = { viewModel.login() })
|
||||
}
|
||||
|
||||
if (showExitNodePicker.value == ShowHide.Show) {
|
||||
ExitNodeStatus(
|
||||
navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
|
||||
}
|
||||
|
||||
PeerList(
|
||||
viewModel = viewModel,
|
||||
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
|
||||
onSearch = { viewModel.searchPeers(it) })
|
||||
}
|
||||
Ipn.State.NoState,
|
||||
Ipn.State.Starting -> StartingView()
|
||||
else -> {
|
||||
ConnectView(
|
||||
state,
|
||||
isPrepared,
|
||||
// If Tailscale is stopping, don't automatically restart; wait for user to take
|
||||
// action (eg, if the user connected to another VPN).
|
||||
state != Ipn.State.Stopping,
|
||||
user,
|
||||
{ viewModel.toggleVpn() },
|
||||
{ viewModel.login() },
|
||||
loginAtUrl,
|
||||
netmap?.SelfNode,
|
||||
{ viewModel.showVPNPermissionLauncherIfUnauthorized() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentPingDevice?.let { _ ->
|
||||
ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) {
|
||||
PingView(model = viewModel.pingViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
|
||||
val nodeState by viewModel.nodeState.collectAsState()
|
||||
val maybePrefs by viewModel.prefs.collectAsState()
|
||||
val netmap by viewModel.netmap.collectAsState()
|
||||
|
||||
// There's nothing to render if we haven't loaded the prefs yet
|
||||
val prefs = maybePrefs ?: return
|
||||
|
||||
// The activeExitNode is the source of truth. The selectedExitNode is only relevant if we
|
||||
// don't have an active node.
|
||||
val chosenExitNodeId = prefs.activeExitNodeID ?: prefs.selectedExitNodeID
|
||||
|
||||
val exitNodePeer = chosenExitNodeId?.let { id -> netmap?.Peers?.find { it.StableID == id } }
|
||||
val name = exitNodePeer?.exitNodeName
|
||||
|
||||
val managedByOrganization by viewModel.managedByOrganization.collectAsState()
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surfaceContainer)) {
|
||||
if (nodeState == NodeState.OFFLINE_MDM) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.padding(start = 16.dp, end = 16.dp, top = 56.dp, bottom = 16.dp)
|
||||
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
|
||||
.background(MaterialTheme.colorScheme.customErrorContainer)
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.TopCenter)) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.padding(start = 16.dp, end = 16.dp, top = 36.dp, bottom = 16.dp)) {
|
||||
Text(
|
||||
text =
|
||||
managedByOrganization.value?.let {
|
||||
stringResource(R.string.exit_node_offline_mdm_orgname, it)
|
||||
} ?: stringResource(R.string.exit_node_offline_mdm),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp)
|
||||
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
|
||||
.fillMaxWidth()) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { navAction() },
|
||||
colors =
|
||||
when (nodeState) {
|
||||
NodeState.ACTIVE_AND_RUNNING -> MaterialTheme.colorScheme.primaryListItem
|
||||
NodeState.ACTIVE_NOT_RUNNING -> MaterialTheme.colorScheme.listItem
|
||||
NodeState.RUNNING_AS_EXIT_NODE -> MaterialTheme.colorScheme.warningListItem
|
||||
NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorListItem
|
||||
NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorListItem
|
||||
NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorListItem
|
||||
else ->
|
||||
ListItemDefaults.colors(
|
||||
containerColor = MaterialTheme.colorScheme.surface)
|
||||
},
|
||||
overlineContent = {
|
||||
Text(
|
||||
text =
|
||||
if (nodeState == NodeState.OFFLINE_ENABLED ||
|
||||
nodeState == NodeState.OFFLINE_DISABLED ||
|
||||
nodeState == NodeState.OFFLINE_MDM)
|
||||
stringResource(R.string.exit_node_offline)
|
||||
else stringResource(R.string.exit_node),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text =
|
||||
when (nodeState) {
|
||||
NodeState.NONE -> stringResource(id = R.string.none)
|
||||
NodeState.RUNNING_AS_EXIT_NODE ->
|
||||
stringResource(id = R.string.running_exit_node)
|
||||
else -> name ?: ""
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
tint =
|
||||
if (nodeState == NodeState.NONE)
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
if (nodeState != NodeState.NONE) {
|
||||
Button(
|
||||
colors =
|
||||
when (nodeState) {
|
||||
NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorButton
|
||||
NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorButton
|
||||
NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorButton
|
||||
NodeState.RUNNING_AS_EXIT_NODE ->
|
||||
MaterialTheme.colorScheme.warningButton
|
||||
NodeState.ACTIVE_NOT_RUNNING ->
|
||||
MaterialTheme.colorScheme.exitNodeToggleButton
|
||||
else -> MaterialTheme.colorScheme.secondaryButton
|
||||
},
|
||||
onClick = {
|
||||
if (nodeState == NodeState.RUNNING_AS_EXIT_NODE)
|
||||
viewModel.setRunningExitNode(false)
|
||||
else viewModel.toggleExitNode()
|
||||
}) {
|
||||
Text(
|
||||
when (nodeState) {
|
||||
NodeState.OFFLINE_DISABLED -> stringResource(id = R.string.enable)
|
||||
NodeState.ACTIVE_NOT_RUNNING ->
|
||||
stringResource(id = R.string.enable)
|
||||
NodeState.RUNNING_AS_EXIT_NODE ->
|
||||
stringResource(id = R.string.stop)
|
||||
else -> stringResource(id = R.string.disable)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsButton(action: () -> Unit) {
|
||||
IconButton(modifier = Modifier.size(24.dp), onClick = { action() }) {
|
||||
Icon(
|
||||
Icons.Outlined.Settings,
|
||||
contentDescription = "Open settings",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StartingView() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
TailscaleLogoView(
|
||||
animated = true, usesOnBackgroundColors = false, Modifier.size(40.dp).alpha(0.3f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectView(
|
||||
state: Ipn.State,
|
||||
isPrepared: Boolean,
|
||||
shouldStartAutomatically: Boolean,
|
||||
user: IpnLocal.LoginProfile?,
|
||||
connectAction: () -> Unit,
|
||||
loginAction: () -> Unit,
|
||||
loginAtUrlAction: (String) -> Unit,
|
||||
selfNode: Tailcfg.Node?,
|
||||
showVPNPermissionLauncherIfUnauthorized: () -> Unit
|
||||
) {
|
||||
LaunchedEffect(isPrepared) {
|
||||
if (!isPrepared && shouldStartAutomatically) {
|
||||
showVPNPermissionLauncherIfUnauthorized()
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(8.dp).fillMaxWidth(0.7f).fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (!isPrepared) {
|
||||
TailscaleLogoView(modifier = Modifier.size(50.dp))
|
||||
Spacer(modifier = Modifier.size(1.dp))
|
||||
Text(
|
||||
text = stringResource(id = R.string.welcome_to_tailscale),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center)
|
||||
Text(
|
||||
stringResource(R.string.give_permissions),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
textAlign = TextAlign.Center)
|
||||
Spacer(modifier = Modifier.size(1.dp))
|
||||
PrimaryActionButton(onClick = connectAction) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.connect),
|
||||
fontSize = MaterialTheme.typography.titleMedium.fontSize)
|
||||
}
|
||||
} else if (state == Ipn.State.NeedsMachineAuth) {
|
||||
Icon(
|
||||
modifier = Modifier.size(40.dp),
|
||||
imageVector = Icons.Outlined.Lock,
|
||||
contentDescription = "Device requires authentication")
|
||||
Text(
|
||||
text = stringResource(id = R.string.machine_auth_required),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center)
|
||||
Text(
|
||||
text = stringResource(id = R.string.machine_auth_explainer),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center)
|
||||
Spacer(modifier = Modifier.size(1.dp))
|
||||
selfNode?.let {
|
||||
PrimaryActionButton(onClick = { loginAtUrlAction(it.nodeAdminUrl) }) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.open_admin_console),
|
||||
fontSize = MaterialTheme.typography.titleMedium.fontSize)
|
||||
}
|
||||
}
|
||||
} else if (state != Ipn.State.NeedsLogin && user != null && !user.isEmpty()) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.power),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
tint = MaterialTheme.colorScheme.disabled)
|
||||
Text(
|
||||
text = stringResource(id = R.string.not_connected),
|
||||
fontSize = MaterialTheme.typography.titleMedium.fontSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = MaterialTheme.typography.titleMedium.fontFamily)
|
||||
val tailnetName = user.NetworkProfile?.DomainName ?: ""
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
append(stringResource(id = R.string.connect_to_tailnet_prefix))
|
||||
pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||
append(tailnetName)
|
||||
pop()
|
||||
append(stringResource(id = R.string.connect_to_tailnet_suffix))
|
||||
},
|
||||
fontSize = MaterialTheme.typography.titleMedium.fontSize,
|
||||
fontWeight = FontWeight.Normal,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(1.dp))
|
||||
PrimaryActionButton(onClick = connectAction) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.connect),
|
||||
fontSize = MaterialTheme.typography.titleMedium.fontSize)
|
||||
}
|
||||
} else {
|
||||
TailscaleLogoView(modifier = Modifier.size(50.dp))
|
||||
Spacer(modifier = Modifier.size(1.dp))
|
||||
Text(
|
||||
text = stringResource(id = R.string.welcome_to_tailscale),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center)
|
||||
Text(
|
||||
stringResource(R.string.login_to_join_your_tailnet),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
textAlign = TextAlign.Center)
|
||||
Spacer(modifier = Modifier.size(1.dp))
|
||||
PrimaryActionButton(onClick = loginAction) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.log_in),
|
||||
fontSize = MaterialTheme.typography.titleMedium.fontSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun PeerList(
|
||||
viewModel: MainViewModel,
|
||||
onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
|
||||
onSearch: (String) -> Unit
|
||||
) {
|
||||
val peerList by viewModel.peers.collectAsState(initial = emptyList<PeerSet>())
|
||||
val searchTermStr by viewModel.searchTerm.collectAsState(initial = "")
|
||||
val showNoResults =
|
||||
remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value
|
||||
|
||||
val netmap = viewModel.netmap.collectAsState()
|
||||
val focusManager = LocalFocusManager.current
|
||||
var isFocussed by remember { mutableStateOf(false) }
|
||||
var isListFocussed by remember { mutableStateOf(false) }
|
||||
val expandedPeer = viewModel.expandedMenuPeer.collectAsState()
|
||||
val localClipboardManager = LocalClipboardManager.current
|
||||
val enableSearch = !isAndroidTV()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
if (enableSearch) {
|
||||
SearchWithDynamicSuggestions(viewModel, onSearch)
|
||||
|
||||
Spacer(modifier = Modifier.height(if (showNoResults) 0.dp else 8.dp))
|
||||
}
|
||||
|
||||
// Peers display
|
||||
LazyColumn(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.weight(1f) // LazyColumn gets the remaining vertical space
|
||||
.onFocusChanged { isListFocussed = it.isFocused }
|
||||
.background(color = MaterialTheme.colorScheme.surface)) {
|
||||
|
||||
// Handle case when no results are found
|
||||
if (showNoResults) {
|
||||
item {
|
||||
Spacer(
|
||||
Modifier.height(16.dp)
|
||||
.fillMaxSize()
|
||||
.focusable(false)
|
||||
.background(color = MaterialTheme.colorScheme.surface))
|
||||
Lists.LargeTitle(
|
||||
stringResource(id = R.string.no_results),
|
||||
bottomPadding = 8.dp,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Light)
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate over peer sets to display them
|
||||
var first = true
|
||||
peerList.forEach { peerSet ->
|
||||
if (!first) {
|
||||
item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() }
|
||||
}
|
||||
first = false
|
||||
|
||||
if (isAndroidTV()) {
|
||||
item { NodesSectionHeader(peerSet = peerSet) }
|
||||
} else {
|
||||
stickyHeader { NodesSectionHeader(peerSet = peerSet) }
|
||||
}
|
||||
|
||||
itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer ->
|
||||
ListItem(
|
||||
modifier =
|
||||
Modifier.combinedClickable(
|
||||
onClick = { onNavigateToPeerDetails(peer) },
|
||||
onLongClick = { viewModel.expandedMenuPeer.set(peer) }),
|
||||
colors = MaterialTheme.colorScheme.listItem,
|
||||
headlineContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.padding(top = 2.dp)
|
||||
.size(10.dp)
|
||||
.background(
|
||||
color = peer.connectedColor(netmap.value),
|
||||
shape = RoundedCornerShape(percent = 50))) {}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(text = peer.displayName, style = MaterialTheme.typography.titleMedium)
|
||||
DropdownMenu(
|
||||
expanded = expandedPeer.value?.StableID == peer.StableID,
|
||||
onDismissRequest = { viewModel.hidePeerDropdownMenu() }) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.clipboard),
|
||||
contentDescription = null)
|
||||
},
|
||||
text = { Text(text = stringResource(R.string.copy_ip_address)) },
|
||||
onClick = {
|
||||
viewModel.copyIpAddress(peer, localClipboardManager)
|
||||
viewModel.hidePeerDropdownMenu()
|
||||
})
|
||||
netmap.value?.let { netMap ->
|
||||
if (!peer.isSelfNode(netMap)) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.timer),
|
||||
contentDescription = null)
|
||||
},
|
||||
text = { Text(text = stringResource(R.string.ping)) },
|
||||
onClick = {
|
||||
viewModel.hidePeerDropdownMenu()
|
||||
viewModel.startPing(peer)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = peer.Addresses?.first()?.split("/")?.first() ?: "",
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
lineHeight = MaterialTheme.typography.titleMedium.lineHeight))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NodesSectionHeader(peerSet: PeerSet) {
|
||||
Spacer(Modifier.height(16.dp).fillMaxSize().background(color = MaterialTheme.colorScheme.surface))
|
||||
|
||||
Lists.LargeTitle(
|
||||
peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user),
|
||||
bottomPadding = 8.dp,
|
||||
focusable = isAndroidTV(),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) {
|
||||
if (netmap == null) return
|
||||
|
||||
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
|
||||
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
|
||||
.fillMaxWidth()) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { action() },
|
||||
colors = MaterialTheme.colorScheme.warningListItem,
|
||||
headlineContent = {
|
||||
Text(
|
||||
netmap.SelfNode.expiryLabel(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
stringResource(id = R.string.keyExpiryExplainer),
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun PromptPermissionsIfNecessary() {
|
||||
Permissions.prompt.forEach { (permission, state) ->
|
||||
ErrorDialog(
|
||||
title = permission.title,
|
||||
message = permission.description,
|
||||
buttonText = R.string._continue) {
|
||||
state.launchPermissionRequest()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SearchWithDynamicSuggestions(viewModel: MainViewModel, onSearch: (String) -> Unit) {
|
||||
val searchTerm by viewModel.searchTerm.collectAsState()
|
||||
val filteredPeers by viewModel.peers.collectAsState()
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
val netmap by viewModel.netmap.collectAsState()
|
||||
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().focusRequester(focusRequester).clickable {
|
||||
focusRequester.requestFocus()
|
||||
keyboardController?.show()
|
||||
}) {
|
||||
SearchBar(
|
||||
modifier = Modifier.fillMaxWidth().align(Alignment.CenterHorizontally),
|
||||
inputField = {
|
||||
SearchBarDefaults.InputField(
|
||||
query = searchTerm,
|
||||
onQueryChange = { query ->
|
||||
viewModel.updateSearchTerm(query)
|
||||
onSearch(query)
|
||||
expanded = query.isNotEmpty()
|
||||
},
|
||||
onSearch = { query ->
|
||||
viewModel.updateSearchTerm(query)
|
||||
onSearch(query)
|
||||
expanded = false
|
||||
},
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
placeholder = { Text("Search") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
if (expanded) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
viewModel.updateSearchTerm("")
|
||||
onSearch("")
|
||||
expanded = false
|
||||
focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
}) {
|
||||
Icon(Icons.Default.Clear, contentDescription = "Clear search")
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
content = {
|
||||
// Search results or suggestions
|
||||
Column(Modifier.verticalScroll(rememberScrollState()).fillMaxSize()) {
|
||||
filteredPeers.forEach { peerSet ->
|
||||
val userName = peerSet.user?.DisplayName ?: "Unknown User"
|
||||
peerSet.peers.forEach { peer ->
|
||||
val deviceName = peer.displayName ?: "Unknown Device"
|
||||
val ipAddress = peer.Addresses?.firstOrNull()?.split("/")?.first() ?: "No IP"
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text(userName) },
|
||||
supportingContent = {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val onlineColor = peer.connectedColor(netmap)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(10.dp)
|
||||
.background(onlineColor, shape = RoundedCornerShape(50)))
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(deviceName)
|
||||
}
|
||||
Text(ipAddress)
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier =
|
||||
Modifier.clickable {
|
||||
viewModel.updateSearchTerm(userName)
|
||||
onSearch(userName)
|
||||
expanded = false
|
||||
focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
}
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun MainViewPreview() {
|
||||
val vpnViewModel = VpnViewModel(App.get())
|
||||
val vm = MainViewModel(vpnViewModel)
|
||||
|
||||
MainView(
|
||||
{},
|
||||
MainViewNavigation(
|
||||
onNavigateToSettings = {},
|
||||
onNavigateToPeerDetails = {},
|
||||
onNavigateToExitNodes = {},
|
||||
onNavigateToHealth = {}),
|
||||
vm)
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.safeContentPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.mdm.MDMSettings
|
||||
import com.tailscale.ipn.ui.viewModel.IpnViewModel
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
@Composable
|
||||
fun ManagedByView(backToSettings: BackNavigation, model: IpnViewModel = viewModel()) {
|
||||
Scaffold(topBar = { Header(R.string.managed_by, onBack = backToSettings) }) { _ ->
|
||||
Column(
|
||||
verticalArrangement =
|
||||
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().safeContentPadding().verticalScroll(rememberScrollState())) {
|
||||
val managedByOrganization =
|
||||
MDMSettings.managedByOrganizationName.flow.collectAsState().value.value
|
||||
val managedByCaption = MDMSettings.managedByCaption.flow.collectAsState().value.value
|
||||
val managedByURL = MDMSettings.managedByURL.flow.collectAsState().value.value
|
||||
managedByOrganization?.let {
|
||||
Text(stringResource(R.string.managed_by_explainer_orgName, it))
|
||||
} ?: run { Text(stringResource(R.string.managed_by_explainer)) }
|
||||
managedByCaption?.let {
|
||||
if (it.isNotEmpty()) {
|
||||
Text(it)
|
||||
}
|
||||
}
|
||||
managedByURL?.let { OpenURLButton(stringResource(R.string.open_support), it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ManagedByViewPreview() {
|
||||
val vm = IpnViewModel()
|
||||
ManagedByView(backToSettings = {}, vm)
|
||||
}
|
@ -0,0 +1,67 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.util.Lists
|
||||
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||
import com.tailscale.ipn.ui.util.flag
|
||||
import com.tailscale.ipn.ui.util.itemsWithDividers
|
||||
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
|
||||
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
|
||||
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MullvadExitNodePicker(
|
||||
countryCode: String,
|
||||
nav: ExitNodePickerNav,
|
||||
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
|
||||
) {
|
||||
val mullvadExitNodes by model.mullvadExitNodesByCountryCode.collectAsState()
|
||||
val bestAvailableByCountry by model.mullvadBestAvailableByCountry.collectAsState()
|
||||
|
||||
mullvadExitNodes[countryCode]?.toList()?.let { nodes ->
|
||||
val any = nodes.first()
|
||||
|
||||
LoadingIndicator.Wrap {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Header(
|
||||
title = { Text("${countryCode.flag()} ${any.country}") },
|
||||
onBack = nav.onNavigateBackToMullvad)
|
||||
}) { innerPadding ->
|
||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||
if (nodes.size > 1) {
|
||||
val bestAvailableNode = bestAvailableByCountry[countryCode]!!
|
||||
item {
|
||||
ExitNodeItem(
|
||||
model,
|
||||
ExitNodePickerViewModel.ExitNode(
|
||||
id = bestAvailableNode.id,
|
||||
label = stringResource(R.string.best_available),
|
||||
online = bestAvailableNode.online,
|
||||
selected = false,
|
||||
))
|
||||
Lists.SectionDivider()
|
||||
}
|
||||
}
|
||||
|
||||
itemsWithDividers(nodes) { node -> ExitNodeItem(model, node) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,99 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Check
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||
import com.tailscale.ipn.ui.util.flag
|
||||
import com.tailscale.ipn.ui.util.itemsWithDividers
|
||||
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
|
||||
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
|
||||
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
|
||||
import com.tailscale.ipn.ui.viewModel.selected
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MullvadExitNodePickerList(
|
||||
nav: ExitNodePickerNav,
|
||||
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
|
||||
) {
|
||||
LoadingIndicator.Wrap {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Header(R.string.choose_mullvad_exit_node, onBack = nav.onNavigateBackToExitNodes)
|
||||
}) { innerPadding ->
|
||||
val mullvadExitNodes by model.mullvadExitNodesByCountryCode.collectAsState()
|
||||
|
||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||
val sortedCountries =
|
||||
mullvadExitNodes.entries.toList().sortedBy {
|
||||
it.value.first().country.lowercase()
|
||||
}
|
||||
itemsWithDividers(sortedCountries) { (countryCode, nodes) ->
|
||||
val first = nodes.first()
|
||||
|
||||
// TODO(oxtoacart): the modifier on the ListItem occasionally causes a crash
|
||||
// with java.lang.ClassCastException: androidx.compose.ui.ComposedModifier cannot be
|
||||
// cast
|
||||
// to androidx.compose.runtime.RecomposeScopeImpl
|
||||
// Wrapping it in a Box eliminates this. It appears to be some kind of
|
||||
// interaction between the LazyList and the modifier.
|
||||
Box {
|
||||
ListItem(
|
||||
modifier =
|
||||
Modifier.clickable {
|
||||
if (nodes.size > 1) {
|
||||
nav.onNavigateToMullvadCountry(countryCode)
|
||||
} else {
|
||||
model.setExitNode(first)
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
Text(
|
||||
countryCode.flag(),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(first.country, style = MaterialTheme.typography.bodyMedium)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
if (nodes.size == 1) first.city
|
||||
else "${nodes.size} ${stringResource(R.string.cities_available)}",
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
},
|
||||
trailingContent = {
|
||||
if (nodes.size > 1 && nodes.selected || first.selected) {
|
||||
if (nodes.selected) {
|
||||
Icon(
|
||||
Icons.Outlined.Check,
|
||||
contentDescription = stringResource(R.string.selected))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
|
||||
|
||||
@Composable
|
||||
fun MullvadInfoView(nav: ExitNodePickerNav) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Header(R.string.choose_mullvad_exit_node, onBack = nav.onNavigateBackToExitNodes)
|
||||
}) { innerPadding ->
|
||||
LazyColumn(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 48.dp),
|
||||
modifier = Modifier.padding(innerPadding)) {
|
||||
item {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.mullvad_logo),
|
||||
contentDescription = stringResource(R.string.the_mullvad_vpn_logo))
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.mullvad_info_title),
|
||||
fontFamily = MaterialTheme.typography.titleLarge.fontFamily,
|
||||
fontSize = MaterialTheme.typography.titleLarge.fontSize,
|
||||
fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.mullvad_info_explainer),
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,154 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.theme.listItem
|
||||
import com.tailscale.ipn.ui.theme.short
|
||||
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
|
||||
import com.tailscale.ipn.ui.util.Lists
|
||||
import com.tailscale.ipn.ui.util.itemsWithDividers
|
||||
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
|
||||
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModelFactory
|
||||
import com.tailscale.ipn.ui.viewModel.PingViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PeerDetails(
|
||||
backToHome: BackNavigation,
|
||||
nodeId: String,
|
||||
pingViewModel: PingViewModel,
|
||||
model: PeerDetailsViewModel =
|
||||
viewModel(
|
||||
factory =
|
||||
PeerDetailsViewModelFactory(nodeId, LocalContext.current.filesDir, pingViewModel))
|
||||
) {
|
||||
val isPinging by model.isPinging.collectAsState()
|
||||
|
||||
model.netmap.collectAsState().value?.let { netmap ->
|
||||
model.node.collectAsState().value?.let { node ->
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Header(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = node.displayName,
|
||||
style = MaterialTheme.typography.titleMedium.short,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(8.dp)
|
||||
.background(
|
||||
color = node.connectedColor(netmap),
|
||||
shape = RoundedCornerShape(percent = 50))) {}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = stringResource(id = node.connectedStrRes(netmap)),
|
||||
style = MaterialTheme.typography.bodyMedium.short,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { model.startPing() }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.timer),
|
||||
contentDescription = "Ping device")
|
||||
}
|
||||
},
|
||||
onBack = backToHome)
|
||||
},
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
) {
|
||||
item(key = "tailscaleAddresses") {
|
||||
Lists.MutedHeader(stringResource(R.string.tailscale_addresses))
|
||||
}
|
||||
|
||||
itemsWithDividers(node.displayAddresses, key = { it.address }) {
|
||||
AddressRow(address = it.address, type = it.typeString)
|
||||
}
|
||||
|
||||
item(key = "infoDivider") { Lists.SectionDivider() }
|
||||
|
||||
itemsWithDividers(node.info, key = { "info_${it.titleRes}" }) {
|
||||
ValueRow(title = stringResource(id = it.titleRes), value = it.value.getString())
|
||||
}
|
||||
}
|
||||
if (isPinging) {
|
||||
ModalBottomSheet(onDismissRequest = { model.onPingDismissal() }) {
|
||||
PingView(model = model.pingViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddressRow(address: String, type: String) {
|
||||
val localClipboardManager = LocalClipboardManager.current
|
||||
|
||||
// Android TV doesn't have a clipboard, nor any way to use the values, so visible only.
|
||||
val modifier =
|
||||
if (isAndroidTV()) {
|
||||
Modifier.focusable(false)
|
||||
} else {
|
||||
Modifier.clickable { localClipboardManager.setText(AnnotatedString(address)) }
|
||||
}
|
||||
|
||||
ListItem(
|
||||
modifier = modifier,
|
||||
colors = MaterialTheme.colorScheme.listItem,
|
||||
headlineContent = { Text(text = address) },
|
||||
supportingContent = { Text(text = type) },
|
||||
trailingContent = {
|
||||
// TODO: there is some overlap with other uses of clipboard, DRY
|
||||
if (!isAndroidTV()) {
|
||||
Icon(painter = painterResource(id = R.drawable.clipboard), null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ValueRow(title: String, value: String) {
|
||||
ListItem(
|
||||
colors = MaterialTheme.colorScheme.listItem,
|
||||
headlineContent = { Text(text = title) },
|
||||
supportingContent = { Text(text = value) })
|
||||
}
|
@ -0,0 +1,66 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tailscale.ipn.ui.model.Ipn
|
||||
import com.tailscale.ipn.ui.model.Tailcfg
|
||||
import com.tailscale.ipn.ui.theme.off
|
||||
import com.tailscale.ipn.ui.theme.on
|
||||
|
||||
@Composable
|
||||
fun PeerView(
|
||||
peer: Tailcfg.Node,
|
||||
selfPeer: String? = null,
|
||||
stateVal: Ipn.State? = null,
|
||||
subtitle: () -> String = { peer.primaryIPv4Address ?: peer.primaryIPv6Address ?: "" },
|
||||
onClick: (Tailcfg.Node) -> Unit = {},
|
||||
trailingContent: @Composable () -> Unit = {}
|
||||
) {
|
||||
val disabled = !(peer.Online ?: false)
|
||||
val textColor = if (disabled) MaterialTheme.colorScheme.onSurfaceVariant else Color.Unspecified
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { onClick(peer) },
|
||||
headlineContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// By definition, SelfPeer is online since we will not show the peer list
|
||||
// unless you're connected.
|
||||
val isSelfAndRunning = (peer.StableID == selfPeer && stateVal == Ipn.State.Running)
|
||||
val color: Color =
|
||||
if ((peer.Online == true) || isSelfAndRunning) {
|
||||
MaterialTheme.colorScheme.on
|
||||
} else {
|
||||
MaterialTheme.colorScheme.off
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(8.dp)
|
||||
.background(color = color, shape = RoundedCornerShape(percent = 50))) {}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = peer.displayName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = textColor)
|
||||
}
|
||||
},
|
||||
supportingContent = {
|
||||
Text(text = subtitle(), style = MaterialTheme.typography.bodyMedium, color = textColor)
|
||||
},
|
||||
trailingContent = trailingContent)
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.model.Permissions
|
||||
import com.tailscale.ipn.ui.theme.success
|
||||
import com.tailscale.ipn.ui.util.itemsWithDividers
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun PermissionsView(backToSettings: BackNavigation, openApplicationSettings: () -> Unit) {
|
||||
val permissions = Permissions.withGrantedStatus
|
||||
Scaffold(topBar = { Header(titleRes = R.string.permissions, onBack = backToSettings) }) {
|
||||
innerPadding ->
|
||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||
itemsWithDividers(permissions) { (permission, granted) ->
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { openApplicationSettings() },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
if (granted) painterResource(R.drawable.check_circle)
|
||||
else painterResource(R.drawable.xmark_circle),
|
||||
tint =
|
||||
if (granted) MaterialTheme.colorScheme.success
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp),
|
||||
contentDescription =
|
||||
stringResource(if (granted) R.string.ok else R.string.warning))
|
||||
},
|
||||
headlineContent = {
|
||||
Text(stringResource(permission.title), style = MaterialTheme.typography.titleMedium)
|
||||
},
|
||||
supportingContent = { Text(stringResource(permission.description)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,119 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
|
||||
import com.tailscale.ipn.ui.viewModel.IpnViewModel
|
||||
|
||||
@Composable
|
||||
fun RunExitNodeView(nav: ExitNodePickerNav, model: IpnViewModel = viewModel()) {
|
||||
val isRunningExitNode by model.isRunningExitNode.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = { Header(R.string.run_as_exit_node, onBack = nav.onNavigateBackToExitNodes) }) {
|
||||
innerPadding ->
|
||||
LoadingIndicator.Wrap {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement =
|
||||
Arrangement.spacedBy(24.dp, alignment = Alignment.CenterVertically),
|
||||
modifier =
|
||||
Modifier.padding(innerPadding)
|
||||
.padding(24.dp)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(rememberScrollState())) {
|
||||
RunExitNodeGraphic()
|
||||
|
||||
if (isRunningExitNode) {
|
||||
Text(
|
||||
stringResource(R.string.running_as_exit_node),
|
||||
fontFamily = MaterialTheme.typography.titleLarge.fontFamily,
|
||||
fontSize = MaterialTheme.typography.titleLarge.fontSize,
|
||||
fontWeight = FontWeight.SemiBold)
|
||||
Text(stringResource(R.string.run_exit_node_explainer_running))
|
||||
} else {
|
||||
Text(
|
||||
stringResource(R.string.run_this_device_as_an_exit_node),
|
||||
fontFamily = MaterialTheme.typography.titleLarge.fontFamily,
|
||||
fontSize = MaterialTheme.typography.titleLarge.fontSize,
|
||||
fontWeight = FontWeight.SemiBold)
|
||||
Text(stringResource(R.string.run_exit_node_explainer))
|
||||
}
|
||||
Text(stringResource(R.string.run_exit_node_caution))
|
||||
|
||||
Button(onClick = { model.setRunningExitNode(!isRunningExitNode) }) {
|
||||
if (isRunningExitNode) {
|
||||
Text(stringResource(R.string.stop_running_as_exit_node))
|
||||
} else {
|
||||
Text(stringResource(R.string.start_running_as_exit_node))
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RunExitNodeGraphic() {
|
||||
@Composable
|
||||
fun ArrowForward() {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Outlined.ArrowForward,
|
||||
"Arrow Forward",
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 18.dp)) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.computer),
|
||||
"Computer icon",
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(36.dp))
|
||||
ArrowForward()
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.android),
|
||||
"Android icon",
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(36.dp))
|
||||
ArrowForward()
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.globe),
|
||||
"Globe icon",
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(36.dp))
|
||||
}
|
||||
}
|
@ -0,0 +1,214 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tailscale.ipn.BuildConfig
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.mdm.MDMSettings
|
||||
import com.tailscale.ipn.mdm.ShowHide
|
||||
import com.tailscale.ipn.ui.Links
|
||||
import com.tailscale.ipn.ui.theme.link
|
||||
import com.tailscale.ipn.ui.theme.listItem
|
||||
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
|
||||
import com.tailscale.ipn.ui.util.Lists
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.ui.viewModel.SettingsNav
|
||||
import com.tailscale.ipn.ui.viewModel.SettingsViewModel
|
||||
import com.tailscale.ipn.ui.viewModel.VpnViewModel
|
||||
import com.tailscale.ipn.ui.notifier.Notifier
|
||||
import com.tailscale.ipn.ui.util.AndroidTVUtil
|
||||
import com.tailscale.ipn.ui.util.AppVersion
|
||||
|
||||
@Composable
|
||||
fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel(), vpnViewModel: VpnViewModel = viewModel()) {
|
||||
val handler = LocalUriHandler.current
|
||||
|
||||
val user by viewModel.loggedInUser.collectAsState()
|
||||
val isAdmin by viewModel.isAdmin.collectAsState()
|
||||
val managedByOrganization by viewModel.managedByOrganization.collectAsState()
|
||||
val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState()
|
||||
val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState()
|
||||
val isVPNPrepared by vpnViewModel.vpnPrepared.collectAsState()
|
||||
val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Header(titleRes = R.string.settings_title, onBack = settingsNav.onNavigateBackHome)
|
||||
}) { innerPadding ->
|
||||
Column(modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) {
|
||||
if (isVPNPrepared) {
|
||||
UserView(
|
||||
profile = user,
|
||||
actionState = UserActionState.NAV,
|
||||
onClick = settingsNav.onNavigateToUserSwitcher)
|
||||
}
|
||||
|
||||
if (isAdmin && !isAndroidTV()) {
|
||||
Lists.ItemDivider()
|
||||
AdminTextView { handler.openUri(Links.ADMIN_URL) }
|
||||
}
|
||||
|
||||
Lists.SectionDivider()
|
||||
Setting.Text(
|
||||
R.string.dns_settings,
|
||||
subtitle =
|
||||
corpDNSEnabled?.let {
|
||||
stringResource(
|
||||
if (it) R.string.using_tailscale_dns else R.string.not_using_tailscale_dns)
|
||||
},
|
||||
onClick = settingsNav.onNavigateToDNSSettings)
|
||||
|
||||
Lists.ItemDivider()
|
||||
Setting.Text(
|
||||
R.string.split_tunneling,
|
||||
subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale),
|
||||
onClick = settingsNav.onNavigateToSplitTunneling)
|
||||
|
||||
if (showTailnetLock.value == ShowHide.Show) {
|
||||
Lists.ItemDivider()
|
||||
Setting.Text(
|
||||
R.string.tailnet_lock,
|
||||
subtitle =
|
||||
tailnetLockEnabled?.let {
|
||||
stringResource(if (it) R.string.enabled else R.string.disabled)
|
||||
},
|
||||
onClick = settingsNav.onNavigateToTailnetLock)
|
||||
}
|
||||
if (!AndroidTVUtil.isAndroidTV()){
|
||||
Lists.ItemDivider()
|
||||
Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions)
|
||||
}
|
||||
|
||||
managedByOrganization.value?.let {
|
||||
Lists.ItemDivider()
|
||||
Setting.Text(
|
||||
title = stringResource(R.string.managed_by_orgName, it),
|
||||
onClick = settingsNav.onNavigateToManagedBy)
|
||||
}
|
||||
|
||||
Lists.SectionDivider()
|
||||
Setting.Text(R.string.bug_report, onClick = settingsNav.onNavigateToBugReport)
|
||||
|
||||
Lists.ItemDivider()
|
||||
Setting.Text(
|
||||
R.string.about_tailscale,
|
||||
subtitle = "${stringResource(id = R.string.version)} ${AppVersion.Short()}",
|
||||
onClick = settingsNav.onNavigateToAbout)
|
||||
|
||||
// TODO: put a heading for the debug section
|
||||
if (BuildConfig.DEBUG) {
|
||||
Lists.SectionDivider()
|
||||
Lists.MutedHeader(text = stringResource(R.string.internal_debug_options))
|
||||
Setting.Text(R.string.mdm_settings, onClick = settingsNav.onNavigateToMDMSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object Setting {
|
||||
@Composable
|
||||
fun Text(
|
||||
titleRes: Int = 0,
|
||||
title: String? = null,
|
||||
subtitle: String? = null,
|
||||
destructive: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
var modifier: Modifier = Modifier
|
||||
if (enabled) {
|
||||
onClick?.let { modifier = modifier.clickable(onClick = it) }
|
||||
}
|
||||
ListItem(
|
||||
modifier = modifier,
|
||||
colors = MaterialTheme.colorScheme.listItem,
|
||||
headlineContent = {
|
||||
Text(
|
||||
title ?: stringResource(titleRes),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified)
|
||||
},
|
||||
supportingContent =
|
||||
subtitle?.let {
|
||||
{
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Switch(
|
||||
titleRes: Int = 0,
|
||||
title: String? = null,
|
||||
isOn: Boolean,
|
||||
enabled: Boolean = true,
|
||||
onToggle: (Boolean) -> Unit = {}
|
||||
) {
|
||||
ListItem(
|
||||
colors = MaterialTheme.colorScheme.listItem,
|
||||
headlineContent = {
|
||||
Text(
|
||||
title ?: stringResource(titleRes),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
TintedSwitch(checked = isOn, onCheckedChange = onToggle, enabled = enabled)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
|
||||
val adminStr = buildAnnotatedString {
|
||||
append(stringResource(id = R.string.settings_admin_prefix))
|
||||
|
||||
pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL)
|
||||
withStyle(
|
||||
style =
|
||||
SpanStyle(
|
||||
color = MaterialTheme.colorScheme.link,
|
||||
textDecoration = TextDecoration.Underline)) {
|
||||
append(stringResource(id = R.string.settings_admin_link))
|
||||
}
|
||||
}
|
||||
|
||||
Lists.InfoItem(adminStr, onClick = onNavigateToAdminConsole)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SettingsPreview() {
|
||||
val vm = SettingsViewModel()
|
||||
vm.corpDNSEnabled.set(true)
|
||||
vm.tailNetLockEnabled.set(true)
|
||||
vm.isAdmin.set(true)
|
||||
vm.managedByOrganization.set("Tails and Scales Inc.")
|
||||
SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
|
||||
}
|
@ -0,0 +1,104 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tailscale.ipn.ui.theme.topAppBar
|
||||
import com.tailscale.ipn.ui.theme.ts_color_light_blue
|
||||
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
|
||||
|
||||
typealias BackNavigation = () -> Unit
|
||||
|
||||
// Header view for all secondary screens
|
||||
// @see TopAppBar actions for additional actions (usually a row of icons)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun Header(
|
||||
@StringRes titleRes: Int = 0,
|
||||
title: (@Composable () -> Unit)? = null,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
onBack: (() -> Unit)? = null
|
||||
) {
|
||||
val f = FocusRequester()
|
||||
|
||||
if (isAndroidTV()) {
|
||||
LaunchedEffect(Unit) { f.requestFocus() }
|
||||
}
|
||||
|
||||
TopAppBar(
|
||||
title = {
|
||||
title?.let { title() }
|
||||
?: Text(
|
||||
stringResource(titleRes),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
},
|
||||
colors = MaterialTheme.colorScheme.topAppBar,
|
||||
actions = actions,
|
||||
navigationIcon = { onBack?.let { BackArrow(action = it, focusRequester = f) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BackArrow(action: () -> Unit, focusRequester: FocusRequester) {
|
||||
|
||||
Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Go back to the previous screen",
|
||||
modifier =
|
||||
Modifier.focusRequester(focusRequester)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = ripple(bounded = false),
|
||||
onClick = { action() }))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CheckedIndicator() {
|
||||
Icon(Icons.Default.CheckCircle, null, tint = ts_color_light_blue)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleActivityIndicator(size: Int = 32) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.width(size.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActivityIndicator(progress: Double, size: Int = 32) {
|
||||
LinearProgressIndicator(
|
||||
progress = { progress.toFloat() },
|
||||
modifier = Modifier.width(size.dp),
|
||||
color = ts_color_light_blue,
|
||||
trackColor = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
}
|
@ -0,0 +1,111 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tailscale.ipn.App
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.util.Lists
|
||||
import com.tailscale.ipn.ui.viewModel.SplitTunnelAppPickerViewModel
|
||||
|
||||
@Composable
|
||||
fun SplitTunnelAppPickerView(
|
||||
backToSettings: BackNavigation,
|
||||
model: SplitTunnelAppPickerViewModel = viewModel()
|
||||
) {
|
||||
val installedApps by model.installedApps.collectAsState()
|
||||
val excludedPackageNames by model.excludedPackageNames.collectAsState()
|
||||
val builtInDisallowedPackageNames: List<String> = App.get().builtInDisallowedPackageNames
|
||||
val mdmIncludedPackages by model.mdmIncludedPackages.collectAsState()
|
||||
val mdmExcludedPackages by model.mdmExcludedPackages.collectAsState()
|
||||
|
||||
Scaffold(topBar = { Header(titleRes = R.string.split_tunneling, onBack = backToSettings) }) {
|
||||
innerPadding ->
|
||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||
item(key = "header") {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string
|
||||
.selected_apps_will_access_the_internet_directly_without_using_tailscale))
|
||||
})
|
||||
}
|
||||
if (mdmExcludedPackages.value?.isNotEmpty() == true) {
|
||||
item("mdmExcludedNotice") {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(stringResource(R.string.certain_apps_are_not_routed_via_tailscale))
|
||||
})
|
||||
}
|
||||
} else if (mdmIncludedPackages.value?.isNotEmpty() == true) {
|
||||
item("mdmIncludedNotice") {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(stringResource(R.string.only_specific_apps_are_routed_via_tailscale))
|
||||
})
|
||||
}
|
||||
} else {
|
||||
item("resolversHeader") {
|
||||
Lists.SectionDivider(
|
||||
stringResource(R.string.count_excluded_apps, excludedPackageNames.count()))
|
||||
}
|
||||
items(installedApps) { app ->
|
||||
ListItem(
|
||||
headlineContent = { Text(app.name, fontWeight = FontWeight.SemiBold) },
|
||||
leadingContent = {
|
||||
Image(
|
||||
bitmap =
|
||||
model.installedAppsManager.packageManager
|
||||
.getApplicationIcon(app.packageName)
|
||||
.toBitmap()
|
||||
.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.width(40.dp).height(40.dp))
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
app.packageName,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
letterSpacing = MaterialTheme.typography.bodySmall.letterSpacing)
|
||||
},
|
||||
trailingContent = {
|
||||
Checkbox(
|
||||
checked = excludedPackageNames.contains(app.packageName),
|
||||
enabled = !builtInDisallowedPackageNames.contains(app.packageName),
|
||||
onCheckedChange = { checked ->
|
||||
if (checked) {
|
||||
model.exclude(packageName = app.packageName)
|
||||
} else {
|
||||
model.unexclude(packageName = app.packageName)
|
||||
}
|
||||
})
|
||||
})
|
||||
Lists.ItemDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,199 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import android.text.format.Formatter
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.model.Ipn
|
||||
import com.tailscale.ipn.ui.model.Tailcfg
|
||||
import com.tailscale.ipn.ui.util.Lists.SectionDivider
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.ui.viewModel.TaildropViewModel
|
||||
import com.tailscale.ipn.ui.viewModel.TaildropViewModelFactory
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@Composable
|
||||
fun TaildropView(
|
||||
requestedTransfers: StateFlow<List<Ipn.OutgoingFile>>,
|
||||
applicationScope: CoroutineScope,
|
||||
viewModel: TaildropViewModel =
|
||||
viewModel(factory = TaildropViewModelFactory(requestedTransfers, applicationScope))
|
||||
) {
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets.Companion.statusBars,
|
||||
topBar = { Header(R.string.share) }) { paddingInsets ->
|
||||
val showDialog = viewModel.showDialog.collectAsState().value
|
||||
|
||||
// Show the error overlay
|
||||
showDialog?.let { ErrorDialog(type = it, action = { viewModel.showDialog.set(null) }) }
|
||||
|
||||
Column(modifier = Modifier.padding(paddingInsets)) {
|
||||
FileShareHeader(
|
||||
fileTransfers = requestedTransfers.collectAsState().value,
|
||||
totalSize = viewModel.totalSize)
|
||||
|
||||
when (viewModel.state.collectAsState().value) {
|
||||
Ipn.State.Running -> {
|
||||
val peers by viewModel.myPeers.collectAsState()
|
||||
val context = LocalContext.current
|
||||
FileSharePeerList(
|
||||
peers = peers,
|
||||
stateViewGenerator = { peerId ->
|
||||
viewModel.TrailingContentForPeer(peerId = peerId)
|
||||
},
|
||||
onShare = { viewModel.share(context, it) })
|
||||
}
|
||||
else -> {
|
||||
FileShareConnectView { viewModel.startVPN() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FileSharePeerList(
|
||||
peers: List<Tailcfg.Node>,
|
||||
stateViewGenerator: @Composable (String) -> Unit,
|
||||
onShare: (Tailcfg.Node) -> Unit
|
||||
) {
|
||||
SectionDivider(stringResource(R.string.my_devices))
|
||||
|
||||
when (peers.isEmpty()) {
|
||||
true -> {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 8.dp).fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
stringResource(R.string.no_devices_to_share_with),
|
||||
style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
false -> {
|
||||
LazyColumn {
|
||||
peers.forEach { peer ->
|
||||
item {
|
||||
PeerView(
|
||||
peer = peer,
|
||||
onClick = { onShare(peer) },
|
||||
subtitle = { peer.Hostinfo.OS ?: "" },
|
||||
trailingContent = { stateViewGenerator(peer.StableID) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FileShareConnectView(onToggle: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp).fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp, alignment = Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
stringResource(R.string.connect_to_your_tailnet_to_share_files),
|
||||
style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.size(1.dp))
|
||||
PrimaryActionButton(onClick = onToggle) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.connect),
|
||||
fontSize = MaterialTheme.typography.titleMedium.fontSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FileShareHeader(fileTransfers: List<Ipn.OutgoingFile>, totalSize: Long) {
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconForTransfer(fileTransfers)
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
|
||||
when (fileTransfers.isEmpty()) {
|
||||
true ->
|
||||
Text(
|
||||
stringResource(R.string.no_files_to_share),
|
||||
style = MaterialTheme.typography.titleMedium)
|
||||
false -> {
|
||||
|
||||
when (fileTransfers.size) {
|
||||
1 -> Text(fileTransfers[0].Name, style = MaterialTheme.typography.titleMedium)
|
||||
else ->
|
||||
Text(
|
||||
stringResource(R.string.file_count, fileTransfers.size),
|
||||
style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
val size = Formatter.formatFileSize(LocalContext.current, totalSize.toLong())
|
||||
Text(
|
||||
size,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IconForTransfer(transfers: List<Ipn.OutgoingFile>) {
|
||||
// (jonathan) TODO: Thumbnails?
|
||||
when (transfers.size) {
|
||||
0 ->
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.warning),
|
||||
contentDescription = "no files",
|
||||
modifier = Modifier.size(32.dp))
|
||||
1 -> {
|
||||
// Show a thumbnail for single image shares.
|
||||
val context = LocalContext.current
|
||||
context.contentResolver.getType(transfers[0].uri)?.let {
|
||||
if (it.startsWith("image/")) {
|
||||
AsyncImage(
|
||||
model = transfers[0].uri,
|
||||
contentDescription = "one file",
|
||||
modifier = Modifier.size(40.dp))
|
||||
return
|
||||
}
|
||||
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.single_file),
|
||||
contentDescription = "files",
|
||||
modifier = Modifier.size(40.dp))
|
||||
}
|
||||
}
|
||||
else ->
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.single_file),
|
||||
contentDescription = "files",
|
||||
modifier = Modifier.size(40.dp))
|
||||
}
|
||||
}
|
@ -0,0 +1,140 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.Links
|
||||
import com.tailscale.ipn.ui.theme.defaultTextColor
|
||||
import com.tailscale.ipn.ui.theme.link
|
||||
import com.tailscale.ipn.ui.util.ClipboardValueView
|
||||
import com.tailscale.ipn.ui.util.Lists
|
||||
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.ui.viewModel.TailnetLockSetupViewModel
|
||||
import com.tailscale.ipn.ui.viewModel.TailnetLockSetupViewModelFactory
|
||||
|
||||
@Composable
|
||||
fun TailnetLockSetupView(
|
||||
backToSettings: BackNavigation,
|
||||
model: TailnetLockSetupViewModel = viewModel(factory = TailnetLockSetupViewModelFactory())
|
||||
) {
|
||||
val statusItems by model.statusItems.collectAsState()
|
||||
val nodeKey by model.nodeKey.collectAsState()
|
||||
val tailnetLockKey by model.tailnetLockKey.collectAsState()
|
||||
val tailnetLockTlPubKey = tailnetLockKey.replace("nlpub", "tlpub")
|
||||
|
||||
Scaffold(topBar = { Header(R.string.tailnet_lock, onBack = backToSettings) }) { innerPadding ->
|
||||
LoadingIndicator.Wrap {
|
||||
LazyColumn(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
|
||||
item { ExplainerView() }
|
||||
|
||||
items(statusItems) { statusItem ->
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
ListItem(
|
||||
modifier =
|
||||
Modifier.focusable(
|
||||
interactionSource = interactionSource)
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = LocalIndication.current
|
||||
) {},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
painter = painterResource(id = statusItem.icon),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
},
|
||||
headlineContent = { Text(stringResource(statusItem.title)) })
|
||||
}
|
||||
|
||||
item {
|
||||
// Node key section
|
||||
Lists.SectionDivider()
|
||||
ClipboardValueView(
|
||||
value = nodeKey,
|
||||
title = stringResource(R.string.node_key),
|
||||
subtitle = stringResource(R.string.node_key_explainer))
|
||||
|
||||
// Tailnet lock key section
|
||||
Lists.SectionDivider()
|
||||
ClipboardValueView(
|
||||
value = tailnetLockTlPubKey,
|
||||
title = stringResource(R.string.tailnet_lock_key),
|
||||
subtitle = stringResource(R.string.tailnet_lock_key_explainer))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExplainerView() {
|
||||
val handler = LocalUriHandler.current
|
||||
|
||||
Lists.MultilineDescription {
|
||||
ClickableText(
|
||||
explainerText(),
|
||||
onClick = { handler.openUri(Links.TAILNET_LOCK_KB_URL) },
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun explainerText(): AnnotatedString {
|
||||
return buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
|
||||
append(stringResource(id = R.string.tailnet_lock_explainer))
|
||||
}
|
||||
|
||||
pushStringAnnotation(tag = "tailnetLockSupportURL", annotation = Links.TAILNET_LOCK_KB_URL)
|
||||
|
||||
withStyle(
|
||||
style =
|
||||
SpanStyle(
|
||||
color = MaterialTheme.colorScheme.link,
|
||||
textDecoration = TextDecoration.Underline)) {
|
||||
append(stringResource(id = R.string.learn_more))
|
||||
}
|
||||
pop()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun TailnetLockSetupViewPreview() {
|
||||
val vm = TailnetLockSetupViewModel()
|
||||
vm.nodeKey.set("8BADF00D-EA7-1337-DEAD-BEEF")
|
||||
vm.tailnetLockKey.set("C0FFEE-CAFE-50DA")
|
||||
TailnetLockSetupView(backToSettings = {}, vm)
|
||||
}
|
@ -0,0 +1,154 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.tailscale.ipn.ui.theme.onBackgroundLogoDotDisabled
|
||||
import com.tailscale.ipn.ui.theme.onBackgroundLogoDotEnabled
|
||||
import com.tailscale.ipn.ui.theme.standaloneLogoDotDisabled
|
||||
import com.tailscale.ipn.ui.theme.standaloneLogoDotEnabled
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlin.concurrent.timer
|
||||
|
||||
// DotsMatrix represents the state of the progress indicator.
|
||||
typealias DotsMatrix = List<List<Boolean>>
|
||||
|
||||
// The initial DotsMatrix that represents the Tailscale logo (T-shaped).
|
||||
val logoDotsMatrix: DotsMatrix =
|
||||
listOf(
|
||||
listOf(false, false, false),
|
||||
listOf(true, true, true),
|
||||
listOf(false, true, false),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TailscaleLogoView(
|
||||
animated: Boolean = false,
|
||||
usesOnBackgroundColors: Boolean = false,
|
||||
modifier: Modifier
|
||||
) {
|
||||
|
||||
val primaryColor: Color =
|
||||
if (usesOnBackgroundColors) {
|
||||
MaterialTheme.colorScheme.onBackgroundLogoDotEnabled
|
||||
} else {
|
||||
MaterialTheme.colorScheme.standaloneLogoDotEnabled
|
||||
}
|
||||
val secondaryColor: Color =
|
||||
if (usesOnBackgroundColors) {
|
||||
MaterialTheme.colorScheme.onBackgroundLogoDotDisabled
|
||||
} else {
|
||||
MaterialTheme.colorScheme.standaloneLogoDotDisabled
|
||||
}
|
||||
|
||||
val currentDotsMatrix: StateFlow<DotsMatrix> = MutableStateFlow(logoDotsMatrix)
|
||||
var currentDotsMatrixIndex = 0
|
||||
fun advanceToNextMatrix() {
|
||||
currentDotsMatrixIndex = (currentDotsMatrixIndex + 1) % gameOfLife.size
|
||||
val newMatrix =
|
||||
if (animated) {
|
||||
gameOfLife[currentDotsMatrixIndex]
|
||||
} else {
|
||||
logoDotsMatrix
|
||||
}
|
||||
currentDotsMatrix.set(newMatrix)
|
||||
}
|
||||
|
||||
if (animated) {
|
||||
timer(period = 300L) { advanceToNextMatrix() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EnabledDot(modifier: Modifier) {
|
||||
Canvas(modifier = modifier, onDraw = { drawCircle(primaryColor) })
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisabledDot(modifier: Modifier) {
|
||||
Canvas(modifier = modifier, onDraw = { drawCircle(secondaryColor) })
|
||||
}
|
||||
|
||||
BoxWithConstraints(modifier) {
|
||||
val currentMatrix = currentDotsMatrix.collectAsState().value
|
||||
Column(verticalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
|
||||
for (y in 0..2) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
|
||||
for (x in 0..2) {
|
||||
if (currentMatrix[y][x]) {
|
||||
EnabledDot(Modifier.size(this@BoxWithConstraints.maxWidth.div(4)))
|
||||
} else {
|
||||
DisabledDot(Modifier.size(this@BoxWithConstraints.maxWidth.div(4)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val gameOfLife: List<DotsMatrix> =
|
||||
listOf(
|
||||
listOf(
|
||||
listOf(false, true, true),
|
||||
listOf(true, false, true),
|
||||
listOf(false, false, true),
|
||||
),
|
||||
listOf(
|
||||
listOf(false, true, true),
|
||||
listOf(false, false, true),
|
||||
listOf(false, true, false),
|
||||
),
|
||||
listOf(
|
||||
listOf(false, true, true),
|
||||
listOf(false, false, false),
|
||||
listOf(false, false, true),
|
||||
),
|
||||
listOf(
|
||||
listOf(false, false, true),
|
||||
listOf(false, true, false),
|
||||
listOf(false, false, false),
|
||||
),
|
||||
listOf(
|
||||
listOf(false, true, false),
|
||||
listOf(false, false, false),
|
||||
listOf(false, false, false),
|
||||
),
|
||||
listOf(
|
||||
listOf(false, false, false),
|
||||
listOf(false, false, true),
|
||||
listOf(false, false, false),
|
||||
),
|
||||
listOf(
|
||||
listOf(false, false, false),
|
||||
listOf(false, false, false),
|
||||
listOf(false, false, false),
|
||||
),
|
||||
listOf(
|
||||
listOf(false, false, true),
|
||||
listOf(false, false, false),
|
||||
listOf(false, false, false),
|
||||
),
|
||||
listOf(
|
||||
listOf(false, false, false),
|
||||
listOf(false, false, false),
|
||||
listOf(true, false, false),
|
||||
),
|
||||
listOf(listOf(false, false, false), listOf(false, false, false), listOf(true, true, false)),
|
||||
listOf(listOf(false, false, false), listOf(true, false, false), listOf(true, true, false)),
|
||||
listOf(listOf(false, false, false), listOf(true, true, false), listOf(false, true, false)),
|
||||
listOf(listOf(false, false, false), listOf(true, true, false), listOf(false, true, true)),
|
||||
listOf(listOf(false, false, false), listOf(true, true, true), listOf(false, false, true)),
|
||||
listOf(listOf(false, true, false), listOf(true, true, true), listOf(true, false, true)))
|
@ -0,0 +1,12 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
fun TintedSwitch(checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?, enabled: Boolean = true) {
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
|
||||
}
|
@ -0,0 +1,193 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.util.Lists
|
||||
import com.tailscale.ipn.ui.util.itemsWithDividers
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.ui.viewModel.UserSwitcherViewModel
|
||||
|
||||
data class UserSwitcherNav(
|
||||
val backToSettings: BackNavigation,
|
||||
val onNavigateHome: () -> Unit,
|
||||
val onNavigateCustomControl: () -> Unit,
|
||||
val onNavigateToAuthKey: () -> Unit
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = viewModel()) {
|
||||
|
||||
val users by viewModel.loginProfiles.collectAsState()
|
||||
val currentUser by viewModel.loggedInUser.collectAsState()
|
||||
val showHeaderMenu by viewModel.showHeaderMenu.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Header(
|
||||
R.string.accounts,
|
||||
onBack = nav.backToSettings,
|
||||
actions = {
|
||||
Row {
|
||||
FusMenu(
|
||||
viewModel = viewModel,
|
||||
onAuthKeyClick = nav.onNavigateToAuthKey,
|
||||
onCustomClick = nav.onNavigateCustomControl)
|
||||
IconButton(onClick = { viewModel.showHeaderMenu.set(!showHeaderMenu) }) {
|
||||
Icon(Icons.Default.MoreVert, "menu")
|
||||
}
|
||||
}
|
||||
})
|
||||
}) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val showErrorDialog by viewModel.errorDialog.collectAsState()
|
||||
|
||||
// Show the error overlay if need be
|
||||
showErrorDialog?.let {
|
||||
ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) })
|
||||
}
|
||||
|
||||
// When switch is invoked, this stores the ID of the user we're trying to switch to
|
||||
// so we can decorate it with a spinner. The actual logged in user will not change
|
||||
// until
|
||||
// we get our first netmap update back with the new userId for SelfNode.
|
||||
// (jonathan) TODO: This user switch is not immediate. We may need to represent the
|
||||
// "switching users" state globally (if ipnState is insufficient)
|
||||
val nextUserId = remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LazyColumn {
|
||||
itemsWithDividers(users ?: emptyList()) { user ->
|
||||
if (user.ID == currentUser?.ID) {
|
||||
UserView(profile = user, actionState = UserActionState.CURRENT)
|
||||
} else {
|
||||
val state =
|
||||
if (user.ID == nextUserId.value) UserActionState.SWITCHING
|
||||
else UserActionState.NONE
|
||||
UserView(
|
||||
profile = user,
|
||||
actionState = state,
|
||||
onClick = {
|
||||
nextUserId.value = user.ID
|
||||
viewModel.switchProfile(user) {
|
||||
if (it.isFailure) {
|
||||
viewModel.errorDialog.set(ErrorDialogType.LOGOUT_FAILED)
|
||||
nextUserId.value = null
|
||||
} else {
|
||||
nav.onNavigateHome()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Lists.SectionDivider()
|
||||
Setting.Text(R.string.add_account) {
|
||||
viewModel.addProfile {
|
||||
if (it.isFailure) {
|
||||
viewModel.errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Lists.ItemDivider()
|
||||
Setting.Text(R.string.reauthenticate) { viewModel.login() }
|
||||
|
||||
if (currentUser != null) {
|
||||
Lists.ItemDivider()
|
||||
Setting.Text(
|
||||
R.string.log_out,
|
||||
destructive = true,
|
||||
onClick = {
|
||||
viewModel.logout {
|
||||
it.onSuccess { nav.onNavigateHome() }
|
||||
.onFailure {
|
||||
viewModel.errorDialog.set(ErrorDialogType.LOGOUT_FAILED)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FusMenu(
|
||||
onCustomClick: () -> Unit,
|
||||
onAuthKeyClick: () -> Unit,
|
||||
viewModel: UserSwitcherViewModel
|
||||
) {
|
||||
val expanded by viewModel.showHeaderMenu.collectAsState()
|
||||
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { viewModel.showHeaderMenu.set(false) },
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)) {
|
||||
MenuItem(
|
||||
onClick = {
|
||||
onCustomClick()
|
||||
viewModel.showHeaderMenu.set(false)
|
||||
},
|
||||
text = stringResource(id = R.string.custom_control_menu))
|
||||
MenuItem(
|
||||
onClick = {
|
||||
onAuthKeyClick()
|
||||
viewModel.showHeaderMenu.set(false)
|
||||
},
|
||||
text = stringResource(id = R.string.auth_key_menu))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MenuItem(text: String, onClick: () -> Unit) {
|
||||
DropdownMenuItem(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 0.dp),
|
||||
onClick = onClick,
|
||||
text = { Text(text = text) })
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun UserSwitcherViewPreview() {
|
||||
val vm = UserSwitcherViewModel()
|
||||
val nav =
|
||||
UserSwitcherNav(
|
||||
backToSettings = {},
|
||||
onNavigateHome = {},
|
||||
onNavigateCustomControl = {},
|
||||
onNavigateToAuthKey = {})
|
||||
UserSwitcherView(nav, vm)
|
||||
}
|
@ -0,0 +1,102 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemColors
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.model.IpnLocal
|
||||
import com.tailscale.ipn.ui.theme.minTextSize
|
||||
import com.tailscale.ipn.ui.theme.short
|
||||
import com.tailscale.ipn.ui.util.AutoResizingText
|
||||
|
||||
// Used to decorate UserViews.
|
||||
// NONE indicates no decoration
|
||||
// CURRENT indicates the user is the current user and will be "checked"
|
||||
// SWITCHING indicates the user is being switched to and will be "loading"
|
||||
// NAV will show a chevron
|
||||
enum class UserActionState {
|
||||
CURRENT,
|
||||
SWITCHING,
|
||||
NAV,
|
||||
NONE
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserView(
|
||||
profile: IpnLocal.LoginProfile?,
|
||||
onClick: (() -> Unit)? = null,
|
||||
colors: ListItemColors = ListItemDefaults.colors(),
|
||||
actionState: UserActionState = UserActionState.NONE,
|
||||
) {
|
||||
Box {
|
||||
var modifier: Modifier = Modifier
|
||||
onClick?.let { modifier = modifier.clickable { it() } }
|
||||
profile?.let {
|
||||
ListItem(
|
||||
modifier = modifier,
|
||||
colors = colors,
|
||||
leadingContent = { Avatar(profile = profile, size = 36) },
|
||||
headlineContent = {
|
||||
AutoResizingText(
|
||||
text = profile.UserProfile.LoginName,
|
||||
style = MaterialTheme.typography.titleMedium.short,
|
||||
minFontSize = MaterialTheme.typography.minTextSize,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
supportingContent = {
|
||||
Column {
|
||||
AutoResizingText(
|
||||
text = profile.NetworkProfile?.DomainName ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium.short,
|
||||
minFontSize = MaterialTheme.typography.minTextSize,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
|
||||
profile.customControlServerHostname()?.let {
|
||||
AutoResizingText(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium.short,
|
||||
minFontSize = MaterialTheme.typography.minTextSize,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
when (actionState) {
|
||||
UserActionState.CURRENT -> CheckedIndicator()
|
||||
UserActionState.SWITCHING -> SimpleActivityIndicator(size = 26)
|
||||
UserActionState.NAV ->
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight, null, Modifier.offset(x = 6.dp))
|
||||
UserActionState.NONE -> Unit
|
||||
}
|
||||
})
|
||||
}
|
||||
?: run {
|
||||
ListItem(
|
||||
modifier = modifier,
|
||||
colors = colors,
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = stringResource(id = R.string.accounts),
|
||||
style = MaterialTheme.typography.titleMedium)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tailscale.ipn.ui.localapi.Client
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class BugReportViewModel : ViewModel() {
|
||||
val bugReportID: StateFlow<String> = MutableStateFlow("")
|
||||
|
||||
init {
|
||||
Client(viewModelScope).bugReportId { result ->
|
||||
result
|
||||
.onSuccess { bugReportID.set(it.trim()) }
|
||||
.onFailure { bugReportID.set("(Error fetching ID)") }
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.ui.view.ErrorDialogType
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
const val AUTH_KEY_LENGTH = 16
|
||||
|
||||
open class CustomLoginViewModel : IpnViewModel() {
|
||||
val errorDialog: StateFlow<ErrorDialogType?> = MutableStateFlow(null)
|
||||
}
|
||||
|
||||
class LoginWithAuthKeyViewModel : CustomLoginViewModel() {
|
||||
// Sets the auth key and invokes the login flow
|
||||
fun setAuthKey(authKey: String, onSuccess: () -> Unit) {
|
||||
// The most basic of checks for auth key syntax
|
||||
if (authKey.isEmpty()) {
|
||||
errorDialog.set(ErrorDialogType.INVALID_AUTH_KEY)
|
||||
return
|
||||
}
|
||||
loginWithAuthKey(authKey) {
|
||||
it.onFailure { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) }
|
||||
it.onSuccess { onSuccess() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LoginWithCustomControlURLViewModel : CustomLoginViewModel() {
|
||||
// Sets the custom control URL and invokes the login flow
|
||||
fun setControlURL(urlStr: String, onSuccess: () -> Unit) {
|
||||
// Some basic checks that the entered URL is "reasonable". The underlying
|
||||
// localAPIClient will use the default server if we give it a broken URL,
|
||||
// but we can make sure we can construct a URL from the input string and
|
||||
// ensure it has an http/https scheme
|
||||
when (urlStr.startsWith("http", ignoreCase = true) &&
|
||||
urlStr.contains("://") &&
|
||||
urlStr.length > 7) {
|
||||
false -> {
|
||||
errorDialog.set(ErrorDialogType.INVALID_CUSTOM_URL)
|
||||
return
|
||||
}
|
||||
true -> {
|
||||
loginWithCustomControlURL(urlStr) {
|
||||
it.onFailure { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) }
|
||||
it.onSuccess { onSuccess() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue