Compare commits
220 Commits
1.57.72-tc
...
main
Author | SHA1 | Date |
---|---|---|
kari-ts | 4fa86dbf03 | 14 hours ago |
Jonathan Nobels | 77c2d924ee | 18 hours ago |
Jonathan Nobels | b37492a547 | 18 hours ago |
kari-ts | 999c6f2357 | 19 hours ago |
Andrea Gottardo | 006b1e6852 | 19 hours ago |
kari-ts | 32e29c4efd | 19 hours ago |
kari-ts | 9aa3a840de | 1 day ago |
kari-ts | 0ff47f7ab5 | 2 days ago |
kari-ts | 12ad295706 | 2 days ago |
kari-ts | d842ccde22 | 2 days ago |
Andrea Gottardo | cbcc773b98 | 3 days ago |
Andrea Gottardo | cbc0035dfe | 4 days ago |
kari-ts | c47ead9412 | 4 days ago |
Percy Wegmann | 46cdbb7b9b | 5 days ago |
kari-ts | 5476288100 | 5 days ago |
kari-ts | a3b356a81c | 5 days ago |
Percy Wegmann | 411d7b2597 | 5 days ago |
Percy Wegmann | 59a88ffbab | 5 days ago |
kari-ts | f684bf696d | 1 week ago |
Percy Wegmann | 698fb868a7 | 1 week ago |
Andrea Gottardo | 82c17a4d1d | 1 week ago |
Jonathan Nobels | b615eb38b4 | 1 week ago |
Andrea Gottardo | 24d6cc7a08 | 1 week ago |
kari-ts | ec1dc8b0be | 1 week ago |
Percy Wegmann | edb3f5b0c5 | 1 week ago |
kari-ts | 7f66c373ea | 2 weeks ago |
kari-ts | 2d7d6e1357 | 2 weeks ago |
Jonathan Nobels | 45fd2e0661 | 2 weeks ago |
Percy Wegmann | 31b0ec8865 | 2 weeks ago |
Will Norris | 9703d48f1a | 2 weeks ago |
Jonathan Nobels | 17ad0c8cc0 | 2 weeks ago |
Jonathan Nobels | a2471d38cb | 2 weeks ago |
kari-ts | e6f6d35a99 | 2 weeks ago |
kari-ts | 5e3236260f | 2 weeks ago |
kari-ts | d330726ba1 | 2 weeks ago |
Andrea Gottardo | 0c0853a962 | 2 weeks ago |
James Tucker | 3f864b28c7 | 2 weeks ago |
kari-ts | 22c129ee1c | 3 weeks ago |
Andrea Gottardo | 427e2d29b4 | 3 weeks ago |
kari-ts | 1c0aef5418 | 3 weeks ago |
kari-ts | 39628be8a6 | 3 weeks ago |
Brad Fitzpatrick | 9dda2cc470 | 3 weeks ago |
kari-ts | a6bc2244b6 | 3 weeks ago |
kari-ts | 24dd83090c | 3 weeks ago |
kari-ts | ad3b6a5a64 | 3 weeks ago |
Percy Wegmann | 16fa0e9b9e | 3 weeks ago |
Andrea Gottardo | 88b0af2c9b | 3 weeks ago |
Andrea Gottardo | 7119424e32 | 4 weeks ago |
Jonathan Nobels | b06342629f | 4 weeks ago |
Percy Wegmann | 07d04ca750 | 4 weeks ago |
Percy Wegmann | 057e25c23d | 4 weeks ago |
Will Norris | a54ebf75ef | 4 weeks ago |
Jonathan Nobels | f4d2a277a5 | 4 weeks ago |
kari-ts | 75e2d8983b | 4 weeks ago |
kari-ts | bbb3c86fa8 | 4 weeks ago |
Percy Wegmann | bc8985126d | 4 weeks ago |
Brad Fitzpatrick | eb8d731a04 | 1 month ago |
kari-ts | 81acaef5b7 | 1 month ago |
kari-ts | 19177df1e2 | 1 month ago |
Praneet Loke | 6197cb9576 | 1 month ago |
kari-ts | 253c116f9b | 1 month ago |
Jonathan Nobels | 1c3af6713c | 1 month ago |
kari-ts | 39d1d0b3c3 | 1 month ago |
Andrea Gottardo | 56da7b66d0 | 1 month ago |
kari-ts | f95428f7fa | 1 month ago |
Percy Wegmann | 0c58841350 | 1 month ago |
Andrea Gottardo | 8a7148c085 | 1 month ago |
Jonathan Nobels | 372af99c53 | 1 month ago |
Andrea Gottardo | a73025b36f | 1 month ago |
Andrea Gottardo | 4d86c1a6f6 | 1 month ago |
Andrea Gottardo | a1d97baeb0 | 1 month ago |
Matt Drollette | 9533db44b7 | 1 month ago |
Andrea Gottardo | 44ac22c29d | 1 month ago |
kari-ts | 5ad25262ad | 1 month ago |
Jonathan Nobels | be6364ca95 | 1 month ago |
kari-ts | 3e32e97261 | 1 month ago |
Andrea Gottardo | 164a243b77 | 1 month ago |
Percy Wegmann | a77edc6724 | 1 month ago |
Percy Wegmann | d396fdab27 | 1 month ago |
Percy Wegmann | 0ae9da385e | 1 month ago |
Percy Wegmann | 9054264363 | 1 month ago |
Jonathan Nobels | 11f52ad96b | 1 month ago |
Percy Wegmann | 482b350ce0 | 1 month ago |
kari-ts | c8d1b30918 | 1 month ago |
kari-ts | 6a00880f61 | 1 month ago |
Jonathan Nobels | a3638f9fc7 | 1 month ago |
Percy Wegmann | c59c8537cf | 1 month ago |
Jonathan Nobels | cc244812a6 | 1 month ago |
kari-ts | a325a90558 | 1 month ago |
kari-ts | f14836a750 | 1 month ago |
kari-ts | 38f57b4737 | 1 month ago |
Percy Wegmann | d676dca4f4 | 1 month ago |
Jonathan Nobels | 32e407d06b | 1 month ago |
Percy Wegmann | 9bfa839380 | 1 month ago |
Percy Wegmann | 2e237e375e | 1 month ago |
Percy Wegmann | 71f03cf0d2 | 1 month ago |
kari-ts | 5745854297 | 1 month ago |
Jonathan Nobels | b4c0a6931d | 1 month ago |
Jonathan Nobels | dbc809167e | 1 month ago |
kari-ts | f54e476328 | 1 month ago |
Percy Wegmann | ccda0499a7 | 1 month ago |
Percy Wegmann | e7539f5ff3 | 1 month ago |
Percy Wegmann | c0ffd5016b | 1 month ago |
Jonathan Nobels | a0e7777958 | 1 month ago |
Percy Wegmann | ef894fa8ca | 1 month ago |
Percy Wegmann | c3dac5954e | 1 month ago |
Percy Wegmann | 54dccff232 | 1 month ago |
Jonathan Nobels | 31939cc855 | 1 month ago |
Jonathan Nobels | 75ad5cfef6 | 1 month ago |
Jonathan Nobels | d188da3a24 | 1 month ago |
Jonathan Nobels | 9fcc1ddfe1 | 2 months ago |
Jonathan Nobels | 3b21a06c8b | 2 months ago |
kari-ts | 9b27516e96 | 2 months ago |
Percy Wegmann | 1719d5d558 | 2 months ago |
Percy Wegmann | d332ce049e | 2 months ago |
Percy Wegmann | 91c1a8d0f3 | 2 months ago |
Jonathan Nobels | e9465988dd | 2 months ago |
Percy Wegmann | 6e503f29a9 | 2 months ago |
Jonathan Nobels | a321d84dba | 2 months ago |
Will Norris | 77f720dba7 | 2 months ago |
kari-ts | 3f816eac4d | 2 months ago |
Percy Wegmann | 9fb742bd8b | 2 months ago |
kari-ts | dca2fc3bf4 | 2 months ago |
Jonathan Nobels | 67a9320d26 | 2 months ago |
Percy Wegmann | 4897f09e50 | 2 months ago |
Percy Wegmann | 8105271d25 | 2 months ago |
Jonathan Nobels | 2818195400 | 2 months ago |
Percy Wegmann | e024c896c1 | 2 months ago |
Percy Wegmann | cfd01af74a | 2 months ago |
kari-ts | facf6406c3 | 2 months ago |
kari-ts | af2e33d130 | 2 months ago |
Jonathan Nobels | cf56dd6793 | 2 months ago |
kari-ts | 4baec5ff80 | 2 months ago |
Jonathan Nobels | 61fb6bbf8e | 2 months ago |
Percy Wegmann | 5599f2ddeb | 2 months ago |
Jonathan Nobels | e59112a8fb | 2 months ago |
Percy Wegmann | db3ba696eb | 2 months ago |
Percy Wegmann | 44ba20a24e | 2 months ago |
Percy Wegmann | 8e063051b6 | 2 months ago |
Percy Wegmann | 7392c7086e | 2 months ago |
Percy Wegmann | 9f3e871637 | 2 months ago |
Andrea Gottardo | e511430f73 | 2 months ago |
Percy Wegmann | cf6a203f7a | 2 months ago |
Percy Wegmann | fb5635b8a5 | 2 months ago |
Andrea Gottardo | 3fea68ef2e | 2 months ago |
Andrea Gottardo | bf74edd551 | 2 months ago |
Percy Wegmann | 28d0ab4dd6 | 2 months ago |
Percy Wegmann | 6a875e8854 | 2 months ago |
Percy Wegmann | a15fdd44bf | 2 months ago |
Will Norris | 9fcdcfe630 | 2 months ago |
Andrea Gottardo | e187a8db81 | 2 months ago |
Andrea Gottardo | f96e9b923f | 2 months ago |
Andrea Gottardo | 19adff3077 | 2 months ago |
Jonathan Nobels | e953b19189 | 2 months ago |
James Tucker | 5454b34dd1 | 2 months ago |
Andrea Gottardo | 0d1a3cf415 | 2 months ago |
Andrea Gottardo | c3b62124bb | 2 months ago |
Jonathan Nobels | 910511d838 | 2 months ago |
kari-ts | b346321078 | 2 months ago |
Jonathan Nobels | 7b7f7254ba | 2 months ago |
kari-ts | 72753bb82a | 2 months ago |
kari-ts | 7470fcc173 | 2 months ago |
James Tucker | 4e923a65c1 | 2 months ago |
James Tucker | 2a14964878 | 2 months ago |
James Tucker | 244221706f | 2 months ago |
Jonathan Nobels | b4f1989b67 | 2 months ago |
Percy Wegmann | 5e7e36e3bc | 2 months ago |
kari-ts | 98a72c2963 | 2 months ago |
Jonathan Nobels | f12439f9a3 | 2 months ago |
Jonathan Nobels | 113a7c6f9d | 2 months ago |
Jonathan Nobels | e4b0e1f8cd | 2 months ago |
Percy Wegmann | e568741081 | 2 months ago |
Percy Wegmann | a1e67ff1e9 | 2 months ago |
Percy Wegmann | d42329e2e2 | 2 months ago |
Anton Tolchanov | e16303e1d8 | 2 months ago |
Percy Wegmann | 9a6aecb454 | 2 months ago |
Andrea Gottardo | 06e850bbd5 | 2 months ago |
Jonathan Nobels | 4df18951a6 | 2 months ago |
Jonathan Nobels | 2c694b7159 | 2 months ago |
Andrea Gottardo | 7c64091aab | 2 months ago |
James Tucker | bf7bf94b52 | 2 months ago |
Jonathan Nobels | 16ec19757d | 2 months ago |
Jonathan Nobels | f275656c25 | 2 months ago |
Jonathan Nobels | 1f457399b8 | 2 months ago |
Jonathan Nobels | 94a4f55eb2 | 2 months ago |
Jonathan Nobels | 0d867aedce | 2 months ago |
Jonathan Nobels | bf0e56469f | 2 months ago |
Jonathan Nobels | 3926cf4b56 | 2 months ago |
James Tucker | 87a8003d39 | 2 months ago |
Jonathan Nobels | 4f46c38c99 | 2 months ago |
Anton Tolchanov | a0f87846fd | 2 months ago |
Anton Tolchanov | 7d25cf97f8 | 2 months ago |
Jonathan Nobels | 9a206805df | 2 months ago |
kari-ts | 01ec98f29a | 2 months ago |
Aalok Kamble | f23477e796 | 2 months ago |
kari-ts | 464f089388 | 2 months ago |
kari-ts | 9492b01946 | 2 months ago |
Jonathan Nobels | bb7ea7cf9f | 2 months ago |
Percy Wegmann | 37832a5b72 | 3 months ago |
kari-ts | 89e160bd08 | 3 months ago |
kari-ts | bf9be063d7 | 3 months ago |
kari-ts | f6b0734e49 | 3 months ago |
Percy Wegmann | cbe8858427 | 3 months ago |
kari-ts | 60b9884aa2 | 3 months ago |
kari-ts | 98fe1e86e5 | 3 months ago |
Moritz Poldrack | e90f39a58c | 3 months ago |
kari-ts | f9310e7a1f | 3 months ago |
Nicola Beghin | df9c75136b | 3 months ago |
kari-ts | 915e4e3394 | 3 months ago |
kari-ts | b96df2b830 | 3 months ago |
kari-ts | 630a6069c4 | 3 months ago |
Charlotte Brandhorst-Satzkorn | 9e8dfbb2ab | 3 months ago |
Charlotte Brandhorst-Satzkorn | 3615398012 | 3 months ago |
kari-ts | 813ca8adea | 4 months ago |
David Anderson | 3255d55e39 | 4 months ago |
David Anderson | 4c7d66701f | 4 months ago |
kari-ts | a76b36506c | 4 months ago |
kari-ts | 1b42117791 | 4 months ago |
kari-ts | 99c54591e6 | 4 months ago |
Denton Gentry | 52601c0dff | 4 months ago |
@ -1,67 +0,0 @@
|
||||
name: go-licenses
|
||||
|
||||
on:
|
||||
# run action when a change lands in the main branch which updates go.mod or
|
||||
# our license template file. Also allow manual triggering.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- go.mod
|
||||
- .github/licenses.tmpl
|
||||
- .github/workflows/go-licenses.yml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
android:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Check out OSS code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
repository: tailscale/tailscale
|
||||
path: oss
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install go-licenses
|
||||
run: |
|
||||
go install github.com/google/go-licenses@v1.2.2-0.20220825154955-5eedde1c6584
|
||||
|
||||
- name: Run go-licenses
|
||||
run: |
|
||||
go-licenses report ./cmd/tailscale --template .github/licenses.tmpl --ignore tailscale.com | tee oss/licenses/android.md
|
||||
|
||||
- name: Get access token
|
||||
uses: tibdex/github-app-token@f717b5ecd4534d3c4df4ce9b5c1c2214f0f7cd06 # v1.6.0
|
||||
id: generate-token
|
||||
with:
|
||||
app_id: ${{ secrets.LICENSING_APP_ID }}
|
||||
installation_id: ${{ secrets.LICENSING_APP_INSTALLATION_ID }}
|
||||
private_key: ${{ secrets.LICENSING_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Send pull request
|
||||
uses: peter-evans/create-pull-request@18f90432bedd2afd6a825469ffd38aa24712a91d #v4.1.1
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
path: oss
|
||||
author: License Updater <noreply+license-updater@tailscale.com>
|
||||
Committer: License Updater <noreply+license-updater@tailscale.com>
|
||||
branch: licenses/android
|
||||
commit-message: "licenses: update android licenses"
|
||||
title: "licenses: update android licenses"
|
||||
body: Triggered by ${{ github.repository }}@${{ github.sha }}
|
||||
signoff: true
|
||||
delete-branch: true
|
||||
team-reviewers: opensource-license-reviewers
|
@ -0,0 +1,36 @@
|
||||
name: go mod tidy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-branch/*"
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-go-mod-tidy:
|
||||
runs-on: [ubuntu-latest]
|
||||
timeout-minutes: 8
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
cache: false
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Check 'go mod tidy' is clean
|
||||
run: |
|
||||
./tool/go mod tidy
|
||||
echo
|
||||
echo
|
||||
git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go mod tidy'."; exit 1)
|
@ -0,0 +1,19 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
- "release-branch/*"
|
||||
pull_request:
|
||||
# all PRs on all branches
|
||||
merge_group:
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
jobs:
|
||||
license_headers:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: check license headers
|
||||
run: ./scripts/check_license_headers.sh .
|
@ -1,59 +1,146 @@
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.1.0'
|
||||
}
|
||||
ext.kotlin_version = "1.9.22"
|
||||
ext.compose_version = "1.5.10"
|
||||
ext.accompanist_version = "0.34.0"
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven {
|
||||
url = uri("https://plugins.gradle.org/m2/")
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
classpath "com.android.tools.build:gradle:8.1.4"
|
||||
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()
|
||||
mavenCentral()
|
||||
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 {
|
||||
ndkVersion "23.1.7779620"
|
||||
compileSdk 33
|
||||
defaultConfig {
|
||||
minSdkVersion 22
|
||||
targetSdkVersion 33
|
||||
versionCode 193
|
||||
versionName "1.55.148-t86aa0485a-g5ef7bbaff0a"
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility 1.8
|
||||
targetCompatibility 1.8
|
||||
}
|
||||
flavorDimensions "version"
|
||||
productFlavors {
|
||||
fdroid {
|
||||
// The fdroid flavor contains only free dependencies and is suitable
|
||||
// for the F-Droid app store.
|
||||
}
|
||||
play {
|
||||
// The play flavor contains all features and is for the Play Store.
|
||||
}
|
||||
}
|
||||
ndkVersion "23.1.7779620"
|
||||
compileSdkVersion 34
|
||||
defaultConfig {
|
||||
minSdkVersion 26
|
||||
targetSdkVersion 34
|
||||
versionCode 220
|
||||
versionName "1.67.30-t6831a29f8-g8acfc1f7a07"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "$compose_version"
|
||||
}
|
||||
flavorDimensions "version"
|
||||
namespace 'com.tailscale.ipn'
|
||||
|
||||
buildTypes {
|
||||
applicationTest {
|
||||
initWith debug
|
||||
buildConfigField "String", "GITHUB_USERNAME", "\"" + getLocalProperty("githubUsername")+"\""
|
||||
buildConfigField "String", "GITHUB_PASSWORD", "\"" + getLocalProperty("githubPassword")+"\""
|
||||
buildConfigField "String", "GITHUB_2FA_SECRET", "\"" + getLocalProperty("github2FASecret")+"\""
|
||||
}
|
||||
}
|
||||
|
||||
testBuildType "applicationTest"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "androidx.core:core:1.9.0"
|
||||
implementation "androidx.browser:browser:1.5.0"
|
||||
implementation "androidx.security:security-crypto:1.1.0-alpha06"
|
||||
implementation "androidx.work:work-runtime:2.8.1"
|
||||
implementation ':ipn@aar'
|
||||
testImplementation "junit:junit:4.12"
|
||||
|
||||
// Non-free dependencies.
|
||||
playImplementation 'com.google.android.gms:play-services-auth:20.7.0'
|
||||
// Android dependencies.
|
||||
implementation "androidx.core:core:1.12.0"
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation "androidx.browser:browser:1.8.0"
|
||||
implementation "androidx.security:security-crypto:1.1.0-alpha06"
|
||||
implementation "androidx.work:work-runtime:2.9.0"
|
||||
|
||||
// Kotlin dependencies.
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0"
|
||||
implementation 'junit:junit:4.12'
|
||||
runtimeOnly "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
|
||||
|
||||
// Compose dependencies.
|
||||
def composeBom = platform('androidx.compose:compose-bom:2023.06.01')
|
||||
implementation composeBom
|
||||
implementation 'androidx.compose.material3:material3:1.2.1'
|
||||
implementation 'androidx.compose.material:material-icons-core:1.6.3'
|
||||
implementation "androidx.compose.ui:ui:1.6.3"
|
||||
implementation "androidx.compose.ui:ui-tooling:1.6.3"
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
|
||||
implementation 'androidx.activity:activity-compose:1.8.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-alpha02"
|
||||
|
||||
// Navigation dependencies.
|
||||
def nav_version = "2.7.7"
|
||||
implementation "androidx.navigation:navigation-compose:$nav_version"
|
||||
implementation "androidx.navigation:navigation-ui:$nav_version"
|
||||
|
||||
// Supporting libraries.
|
||||
implementation("io.coil-kt:coil-compose:2.6.0")
|
||||
implementation("com.google.zxing:core:3.5.1")
|
||||
|
||||
// Tailscale dependencies.
|
||||
implementation ':libtailscale@aar'
|
||||
|
||||
// Integration Tests
|
||||
androidTestImplementation composeBom
|
||||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
implementation 'androidx.test.uiautomator:uiautomator:2.3.0'
|
||||
|
||||
|
||||
// Authentication only for tests
|
||||
androidTestImplementation 'dev.turingcomplete:kotlin-onetimepassword:2.4.0'
|
||||
androidTestImplementation 'commons-codec:commons-codec:1.16.1'
|
||||
|
||||
// Unit Tests
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
}
|
||||
|
||||
def getLocalProperty(key) {
|
||||
try {
|
||||
Properties properties = new Properties()
|
||||
properties.load(project.file('local.properties').newDataInputStream())
|
||||
return properties.getProperty(key)
|
||||
} catch(Throwable ignored) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,160 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn
|
||||
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import androidx.test.ext.junit.rules.activityScenarioRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.LargeTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.uiautomator.By
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
import androidx.test.uiautomator.UiSelector
|
||||
import dev.turingcomplete.kotlinonetimepassword.HmacAlgorithm
|
||||
import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordConfig
|
||||
import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordGenerator
|
||||
import org.apache.commons.codec.binary.Base32
|
||||
import org.junit.After
|
||||
import org.junit.Assert
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@LargeTest
|
||||
class MainActivityTest {
|
||||
companion object {
|
||||
const val TAG = "MainActivityTest"
|
||||
}
|
||||
|
||||
@get:Rule val activityRule = activityScenarioRule<MainActivity>()
|
||||
|
||||
@Before fun setUp() {}
|
||||
|
||||
@After fun tearDown() {}
|
||||
|
||||
/**
|
||||
* This test starts with a clean install, logs the user in to a tailnet using credentials provided
|
||||
* through a build config, and then makes sure we can hit https://hello.ts.net.
|
||||
*/
|
||||
@Test
|
||||
fun loginAndVisitHello() {
|
||||
val githubUsername = BuildConfig.GITHUB_USERNAME
|
||||
val githubPassword = BuildConfig.GITHUB_PASSWORD
|
||||
val github2FASecret = Base32().decode(BuildConfig.GITHUB_2FA_SECRET)
|
||||
val config =
|
||||
TimeBasedOneTimePasswordConfig(
|
||||
codeDigits = 6,
|
||||
hmacAlgorithm = HmacAlgorithm.SHA1,
|
||||
timeStep = 30,
|
||||
timeStepUnit = TimeUnit.SECONDS)
|
||||
val githubTOTP = TimeBasedOneTimePasswordGenerator(github2FASecret, config)
|
||||
|
||||
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
|
||||
Log.d(TAG, "Wait for VPN permission prompt and accept")
|
||||
device.find(By.text("Connection request"))
|
||||
device.find(By.text("OK")).click()
|
||||
|
||||
Log.d(TAG, "Click through Get Started screen")
|
||||
device.find(By.text("Get Started"))
|
||||
device.find(By.text("Get Started")).click()
|
||||
|
||||
asNecessary(
|
||||
timeout = 2.minutes,
|
||||
{
|
||||
Log.d(TAG, "Log in")
|
||||
device.find(By.text("Log in")).click()
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Accept Chrome terms and conditions (if necessary)")
|
||||
device.find(By.text("Welcome to Chrome"))
|
||||
val dismissIndex =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) 1 else 0
|
||||
device.find(UiSelector().instance(dismissIndex).className(Button::class.java)).click()
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Don't turn on sync")
|
||||
device.find(By.text("Turn on sync?"))
|
||||
device.find(By.text("No thanks")).click()
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Log in with GitHub")
|
||||
device.find(By.text("Sign in with GitHub")).click()
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Make sure GitHub page has loaded")
|
||||
device.find(By.text("New to GitHub"))
|
||||
device.find(By.text("Username or email address"))
|
||||
device.find(By.text("Sign in"))
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Enter credentials")
|
||||
device
|
||||
.find(UiSelector().instance(0).className(EditText::class.java))
|
||||
.setText(githubUsername)
|
||||
device
|
||||
.find(UiSelector().instance(1).className(EditText::class.java))
|
||||
.setText(githubPassword)
|
||||
device.find(By.text("Sign in")).click()
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Enter 2FA")
|
||||
device.find(By.text("Two-factor authentication"))
|
||||
device
|
||||
.find(UiSelector().instance(0).className(EditText::class.java))
|
||||
.setText(githubTOTP.generate())
|
||||
device.find(UiSelector().instance(0).className(Button::class.java)).click()
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Accept Tailscale app")
|
||||
device.find(By.text("Learn more about OAuth"))
|
||||
// Sleep a little to give button time to activate
|
||||
Thread.sleep(5.seconds.inWholeMilliseconds)
|
||||
device.find(UiSelector().instance(1).className(Button::class.java)).click()
|
||||
},
|
||||
{
|
||||
Log.d(TAG, "Connect device")
|
||||
device.find(By.text("Connect device"))
|
||||
device.find(UiSelector().instance(0).className(Button::class.java)).click()
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
Log.d(TAG, "Accept Permission (Either Storage or Notifications)")
|
||||
device.find(By.text("Continue")).click()
|
||||
device.find(By.text("Allow")).click()
|
||||
} catch (t: Throwable) {
|
||||
// we're not always prompted for permissions, that's okay
|
||||
}
|
||||
|
||||
Log.d(TAG, "Wait for VPN to connect")
|
||||
device.find(By.text("Connected"))
|
||||
|
||||
val helloResponse = helloTSNet
|
||||
Assert.assertTrue(
|
||||
"Response from hello.ts.net should show success",
|
||||
helloResponse.contains("You're connected over Tailscale!"))
|
||||
}
|
||||
}
|
||||
|
||||
private val helloTSNet: String
|
||||
get() {
|
||||
return URL("https://hello.ts.net").run {
|
||||
openConnection().run {
|
||||
this as HttpURLConnection
|
||||
connectTimeout = 30000
|
||||
readTimeout = 5000
|
||||
inputStream.bufferedReader().readText()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn
|
||||
|
||||
import android.util.Log
|
||||
import androidx.test.uiautomator.BySelector
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
import androidx.test.uiautomator.UiObject
|
||||
import androidx.test.uiautomator.UiObject2
|
||||
import androidx.test.uiautomator.UiSelector
|
||||
import androidx.test.uiautomator.Until
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
private val defaultTimeout = 10.seconds
|
||||
|
||||
private val threadLocalTimeout = ThreadLocal<Duration>()
|
||||
|
||||
/**
|
||||
* Wait until the specified timeout for the given selector and return the matching UiObject2.
|
||||
* Timeout defaults to 10 seconds.
|
||||
*
|
||||
* @throws Exception if selector is not found within timeout.
|
||||
*/
|
||||
fun UiDevice.find(
|
||||
selector: BySelector,
|
||||
timeout: Duration = threadLocalTimeout.get() ?: defaultTimeout
|
||||
): UiObject2 {
|
||||
wait(Until.findObject(selector), timeout.inWholeMilliseconds)?.let {
|
||||
return it
|
||||
} ?: run { throw Exception("not found") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the specified timeout for the given selector and return the matching UiObject. Timeout
|
||||
* defaults to 10 seconds.
|
||||
*
|
||||
* @throws Exception if selector is not found within timeout.
|
||||
*/
|
||||
fun UiDevice.find(
|
||||
selector: UiSelector,
|
||||
timeout: Duration = threadLocalTimeout.get() ?: defaultTimeout
|
||||
): UiObject {
|
||||
val obj = findObject(selector)
|
||||
if (!obj.waitForExists(timeout.inWholeMilliseconds)) {
|
||||
throw Exception("not found")
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an ordered collection of steps as necessary. If an earlier step fails but a subsequent
|
||||
* step succeeds, this skips the earlier step. This is useful for interruptible sequences like
|
||||
* logging in that may resume in an intermediate state.
|
||||
*/
|
||||
fun asNecessary(timeout: Duration, vararg steps: () -> Unit) {
|
||||
val interval = 250.milliseconds
|
||||
// Use a short timeout to avoid waiting on actions that can be skipped
|
||||
threadLocalTimeout.set(interval)
|
||||
try {
|
||||
val start = System.currentTimeMillis()
|
||||
var furthestSuccessful = -1
|
||||
while (System.currentTimeMillis() - start < timeout.inWholeMilliseconds) {
|
||||
for (i in furthestSuccessful + 1 ..< steps.size) {
|
||||
val step = steps[i]
|
||||
try {
|
||||
step()
|
||||
furthestSuccessful = i
|
||||
Log.d("TestUtil.asNecessary", "SUCCESS!")
|
||||
// Going forward, use the normal timeout on the assumption that subsequent steps will
|
||||
// succeed.
|
||||
threadLocalTimeout.remove()
|
||||
} catch (t: Throwable) {
|
||||
Log.d("TestUtil.asNecessary", t.toString())
|
||||
// Going forward, use a short timeout to avoid waiting on actions that can be skipped
|
||||
threadLocalTimeout.set(interval)
|
||||
}
|
||||
}
|
||||
if (furthestSuccessful == steps.size - 1) {
|
||||
// All steps have completed successfully
|
||||
return
|
||||
}
|
||||
// Still some steps left to run
|
||||
Thread.sleep(interval.inWholeMilliseconds)
|
||||
}
|
||||
throw Exception("failed to complete within timeout")
|
||||
} finally {
|
||||
threadLocalTimeout.remove()
|
||||
}
|
||||
}
|
@ -1,84 +1,120 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
|
||||
|
||||
<!-- Disable input emulation on ChromeOS -->
|
||||
<uses-feature android:name="android.hardware.type.pc" android:required="false"/>
|
||||
<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"/>
|
||||
|
||||
<!-- Signal support for Android TV -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
<!-- 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:banner="@drawable/tv_banner"
|
||||
android:name=".App" android:allowBackup="false">
|
||||
<activity android:name="IPNActivity"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.GioApp"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="application/*" />
|
||||
<data android:mimeType="audio/*" />
|
||||
<data android:mimeType="image/*" />
|
||||
<data android:mimeType="message/*" />
|
||||
<data android:mimeType="multipart/*" />
|
||||
<data android:mimeType="text/*" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="application/*" />
|
||||
<data android:mimeType="audio/*" />
|
||||
<data android:mimeType="image/*" />
|
||||
<data android:mimeType="message/*" />
|
||||
<data android:mimeType="multipart/*" />
|
||||
<data android:mimeType="text/*" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<receiver android:name="IPNReceiver"
|
||||
android:exported="true"
|
||||
>
|
||||
<intent-filter>
|
||||
<action android:name="com.tailscale.ipn.CONNECT_VPN" />
|
||||
<action android:name="com.tailscale.ipn.DISCONNECT_VPN" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<service android:name=".IPNService"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
<service
|
||||
android:name=".QuickToggleService"
|
||||
android:icon="@drawable/ic_tile"
|
||||
android:label="@string/tile_name"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
<!-- Signal support for Android TV -->
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="false"
|
||||
android:banner="@drawable/tv_banner"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="Tailscale"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
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: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" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:name=".IPNService"
|
||||
android:exported="false"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||
android:foregroundServiceType="systemExempted">
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
@ -1,408 +0,0 @@
|
||||
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package com.tailscale.ipn;
|
||||
|
||||
import android.app.Application;
|
||||
import android.app.Activity;
|
||||
import android.app.DownloadManager;
|
||||
import android.app.Fragment;
|
||||
import android.app.FragmentTransaction;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.UiModeManager;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.Signature;
|
||||
import android.content.res.Configuration;
|
||||
import android.provider.MediaStore;
|
||||
import android.provider.Settings;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.LinkProperties;
|
||||
import android.net.Network;
|
||||
import android.net.NetworkInfo;
|
||||
import android.net.NetworkRequest;
|
||||
import android.net.Uri;
|
||||
import android.net.VpnService;
|
||||
import android.view.View;
|
||||
import android.os.Build;
|
||||
import android.os.Environment;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
|
||||
import android.Manifest;
|
||||
import android.webkit.MimeTypeMap;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
|
||||
import java.lang.StringBuilder;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.InterfaceAddress;
|
||||
import java.net.NetworkInterface;
|
||||
|
||||
import java.security.GeneralSecurityException;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import androidx.security.crypto.EncryptedSharedPreferences;
|
||||
import androidx.security.crypto.MasterKey;
|
||||
|
||||
import androidx.browser.customtabs.CustomTabsIntent;
|
||||
|
||||
import org.gioui.Gio;
|
||||
|
||||
public class App extends Application {
|
||||
private final static String PEER_TAG = "peer";
|
||||
|
||||
static final String STATUS_CHANNEL_ID = "tailscale-status";
|
||||
static final int STATUS_NOTIFICATION_ID = 1;
|
||||
|
||||
static final String NOTIFY_CHANNEL_ID = "tailscale-notify";
|
||||
static final int NOTIFY_NOTIFICATION_ID = 2;
|
||||
|
||||
private static final String FILE_CHANNEL_ID = "tailscale-files";
|
||||
private static final int FILE_NOTIFICATION_ID = 3;
|
||||
|
||||
private final static Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
public DnsConfig dns = new DnsConfig(this);
|
||||
public DnsConfig getDnsConfigObj() { return this.dns; }
|
||||
|
||||
@Override public void onCreate() {
|
||||
super.onCreate();
|
||||
// Load and initialize the Go library.
|
||||
Gio.init(this);
|
||||
registerNetworkCallback();
|
||||
|
||||
createNotificationChannel(NOTIFY_CHANNEL_ID, "Notifications", NotificationManagerCompat.IMPORTANCE_DEFAULT);
|
||||
createNotificationChannel(STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW);
|
||||
createNotificationChannel(FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT);
|
||||
|
||||
}
|
||||
|
||||
private void registerNetworkCallback() {
|
||||
ConnectivityManager cMgr = (ConnectivityManager) this.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
cMgr.registerNetworkCallback(new NetworkRequest.Builder().build(), new ConnectivityManager.NetworkCallback() {
|
||||
private void reportConnectivityChange() {
|
||||
NetworkInfo active = cMgr.getActiveNetworkInfo();
|
||||
// https://developer.android.com/training/monitoring-device-state/connectivity-status-type
|
||||
boolean isConnected = active != null && active.isConnectedOrConnecting();
|
||||
onConnectivityChanged(isConnected);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLost(Network network) {
|
||||
super.onLost(network);
|
||||
this.reportConnectivityChange();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLinkPropertiesChanged(Network network, LinkProperties linkProperties) {
|
||||
super.onLinkPropertiesChanged(network, linkProperties);
|
||||
this.reportConnectivityChange();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void startVPN() {
|
||||
Intent intent = new Intent(this, IPNService.class);
|
||||
intent.setAction(IPNService.ACTION_CONNECT);
|
||||
startService(intent);
|
||||
}
|
||||
|
||||
public void stopVPN() {
|
||||
Intent intent = new Intent(this, IPNService.class);
|
||||
intent.setAction(IPNService.ACTION_DISCONNECT);
|
||||
startService(intent);
|
||||
}
|
||||
|
||||
// encryptToPref a byte array of data using the Jetpack Security
|
||||
// library and writes it to a global encrypted preference store.
|
||||
public void encryptToPref(String prefKey, String plaintext) throws IOException, GeneralSecurityException {
|
||||
getEncryptedPrefs().edit().putString(prefKey, plaintext).commit();
|
||||
}
|
||||
|
||||
// decryptFromPref decrypts a encrypted preference using the Jetpack Security
|
||||
// library and returns the plaintext.
|
||||
public String decryptFromPref(String prefKey) throws IOException, GeneralSecurityException {
|
||||
return getEncryptedPrefs().getString(prefKey, null);
|
||||
}
|
||||
|
||||
private SharedPreferences getEncryptedPrefs() throws IOException, GeneralSecurityException {
|
||||
MasterKey key = new MasterKey.Builder(this)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build();
|
||||
|
||||
return EncryptedSharedPreferences.create(
|
||||
this,
|
||||
"secret_shared_prefs",
|
||||
key,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
);
|
||||
}
|
||||
|
||||
public boolean autoConnect = false;
|
||||
public boolean vpnReady = false;
|
||||
|
||||
void setTileReady(boolean ready) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
return;
|
||||
}
|
||||
QuickToggleService.setReady(this, ready);
|
||||
android.util.Log.d("App", "Set Tile Ready: " + ready + " " + autoConnect);
|
||||
|
||||
vpnReady = ready;
|
||||
if (ready && autoConnect) {
|
||||
startVPN();
|
||||
}
|
||||
}
|
||||
|
||||
void setTileStatus(boolean status) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
return;
|
||||
}
|
||||
QuickToggleService.setStatus(this, status);
|
||||
}
|
||||
|
||||
String getHostname() {
|
||||
String userConfiguredDeviceName = getUserConfiguredDeviceName();
|
||||
if (!isEmpty(userConfiguredDeviceName)) return userConfiguredDeviceName;
|
||||
|
||||
return getModelName();
|
||||
}
|
||||
|
||||
String getModelName() {
|
||||
String manu = Build.MANUFACTURER;
|
||||
String model = Build.MODEL;
|
||||
// Strip manufacturer from model.
|
||||
int idx = model.toLowerCase().indexOf(manu.toLowerCase());
|
||||
if (idx != -1) {
|
||||
model = model.substring(idx + manu.length());
|
||||
model = model.trim();
|
||||
}
|
||||
return manu + " " + model;
|
||||
}
|
||||
|
||||
String getOSVersion() {
|
||||
return Build.VERSION.RELEASE;
|
||||
}
|
||||
|
||||
// get user defined nickname from Settings
|
||||
// returns null if not available
|
||||
private String getUserConfiguredDeviceName() {
|
||||
String nameFromSystemDevice = Settings.Secure.getString(getContentResolver(), "device_name");
|
||||
if (!isEmpty(nameFromSystemDevice)) return nameFromSystemDevice;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isEmpty(String str) {
|
||||
return str == null || str.length() == 0;
|
||||
}
|
||||
|
||||
// attachPeer adds a Peer fragment for tracking the Activity
|
||||
// lifecycle.
|
||||
void attachPeer(Activity act) {
|
||||
act.runOnUiThread(new Runnable() {
|
||||
@Override public void run() {
|
||||
FragmentTransaction ft = act.getFragmentManager().beginTransaction();
|
||||
ft.add(new Peer(), PEER_TAG);
|
||||
ft.commit();
|
||||
act.getFragmentManager().executePendingTransactions();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
boolean isChromeOS() {
|
||||
return getPackageManager().hasSystemFeature("android.hardware.type.pc");
|
||||
}
|
||||
|
||||
void prepareVPN(Activity act, int reqCode) {
|
||||
act.runOnUiThread(new Runnable() {
|
||||
@Override public void run() {
|
||||
Intent intent = VpnService.prepare(act);
|
||||
if (intent == null) {
|
||||
onVPNPrepared();
|
||||
} else {
|
||||
startActivityForResult(act, intent, reqCode);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static void startActivityForResult(Activity act, Intent intent, int request) {
|
||||
Fragment f = act.getFragmentManager().findFragmentByTag(PEER_TAG);
|
||||
f.startActivityForResult(intent, request);
|
||||
}
|
||||
|
||||
void showURL(Activity act, String url) {
|
||||
act.runOnUiThread(new Runnable() {
|
||||
@Override public void run() {
|
||||
CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
|
||||
int headerColor = 0xff496495;
|
||||
builder.setToolbarColor(headerColor);
|
||||
CustomTabsIntent intent = builder.build();
|
||||
intent.launchUrl(act, Uri.parse(url));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// getPackageSignatureFingerprint returns the first package signing certificate, if any.
|
||||
byte[] getPackageCertificate() throws Exception {
|
||||
PackageInfo info;
|
||||
info = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES);
|
||||
for (Signature signature : info.signatures) {
|
||||
return signature.toByteArray();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void requestWriteStoragePermission(Activity act) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
||||
// We can write files without permission.
|
||||
return;
|
||||
}
|
||||
if (ContextCompat.checkSelfPermission(act, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
|
||||
return;
|
||||
}
|
||||
act.requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, IPNActivity.WRITE_STORAGE_RESULT);
|
||||
}
|
||||
|
||||
String insertMedia(String name, String mimeType) throws IOException {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
ContentResolver resolver = getContentResolver();
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name);
|
||||
if (!"".equals(mimeType)) {
|
||||
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
|
||||
}
|
||||
Uri root = MediaStore.Files.getContentUri("external");
|
||||
return resolver.insert(root, contentValues).toString();
|
||||
} else {
|
||||
File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
|
||||
dir.mkdirs();
|
||||
File f = new File(dir, name);
|
||||
return Uri.fromFile(f).toString();
|
||||
}
|
||||
}
|
||||
|
||||
int openUri(String uri, String mode) throws IOException {
|
||||
ContentResolver resolver = getContentResolver();
|
||||
return resolver.openFileDescriptor(Uri.parse(uri), mode).detachFd();
|
||||
}
|
||||
|
||||
void deleteUri(String uri) {
|
||||
ContentResolver resolver = getContentResolver();
|
||||
resolver.delete(Uri.parse(uri), null, null);
|
||||
}
|
||||
|
||||
public void notifyFile(String uri, String msg) {
|
||||
Intent viewIntent;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
viewIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));
|
||||
} else {
|
||||
// uri is a file:// which is not allowed to be shared outside the app.
|
||||
viewIntent = new Intent(DownloadManager.ACTION_VIEW_DOWNLOADS);
|
||||
}
|
||||
PendingIntent pending = PendingIntent.getActivity(this, 0, viewIntent, PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, FILE_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle("File received")
|
||||
.setContentText(msg)
|
||||
.setContentIntent(pending)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
|
||||
|
||||
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
|
||||
nm.notify(FILE_NOTIFICATION_ID, builder.build());
|
||||
}
|
||||
|
||||
public void createNotificationChannel(String id, String name, int importance) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return;
|
||||
}
|
||||
NotificationChannel channel = new NotificationChannel(id, name, importance);
|
||||
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
|
||||
nm.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
static native void onVPNPrepared();
|
||||
private static native void onConnectivityChanged(boolean connected);
|
||||
static native void onShareIntent(int nfiles, int[] types, String[] mimes, String[] items, String[] names, long[] sizes);
|
||||
static native void onWriteStorageGranted();
|
||||
|
||||
// Returns details of the interfaces in the system, encoded as a single string for ease
|
||||
// of JNI transfer over to the Go environment.
|
||||
//
|
||||
// Example:
|
||||
// rmnet_data0 10 2000 true false false false false | fe80::4059:dc16:7ed3:9c6e%rmnet_data0/64
|
||||
// dummy0 3 1500 true false false false false | fe80::1450:5cff:fe13:f891%dummy0/64
|
||||
// wlan0 30 1500 true true false false true | fe80::2f60:2c82:4163:8389%wlan0/64 10.1.10.131/24
|
||||
// r_rmnet_data0 21 1500 true false false false false | fe80::9318:6093:d1ad:ba7f%r_rmnet_data0/64
|
||||
// rmnet_data2 12 1500 true false false false false | fe80::3c8c:44dc:46a9:9907%rmnet_data2/64
|
||||
// r_rmnet_data1 22 1500 true false false false false | fe80::b6cd:5cb0:8ae6:fe92%r_rmnet_data1/64
|
||||
// rmnet_data1 11 1500 true false false false false | fe80::51f2:ee00:edce:d68b%rmnet_data1/64
|
||||
// lo 1 65536 true false true false false | ::1/128 127.0.0.1/8
|
||||
// v4-rmnet_data2 68 1472 true true false true true | 192.0.0.4/32
|
||||
//
|
||||
// Where the fields are:
|
||||
// name ifindex mtu isUp hasBroadcast isLoopback isPointToPoint hasMulticast | ip1/N ip2/N ip3/N;
|
||||
String getInterfacesAsString() {
|
||||
List<NetworkInterface> interfaces;
|
||||
try {
|
||||
interfaces = Collections.list(NetworkInterface.getNetworkInterfaces());
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder("");
|
||||
for (NetworkInterface nif : interfaces) {
|
||||
try {
|
||||
// Android doesn't have a supportsBroadcast() but the Go net.Interface wants
|
||||
// one, so we say the interface has broadcast if it has multicast.
|
||||
sb.append(String.format(java.util.Locale.ROOT, "%s %d %d %b %b %b %b %b |", nif.getName(),
|
||||
nif.getIndex(), nif.getMTU(), nif.isUp(), nif.supportsMulticast(),
|
||||
nif.isLoopback(), nif.isPointToPoint(), nif.supportsMulticast()));
|
||||
|
||||
for (InterfaceAddress ia : nif.getInterfaceAddresses()) {
|
||||
// InterfaceAddress == hostname + "/" + IP
|
||||
String[] parts = ia.toString().split("/", 0);
|
||||
if (parts.length > 1) {
|
||||
sb.append(String.format(java.util.Locale.ROOT, "%s/%d ", parts[1], ia.getNetworkPrefixLength()));
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// TODO(dgentry) should log the exception not silently suppress it.
|
||||
continue;
|
||||
}
|
||||
sb.append("\n");
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
boolean isTV() {
|
||||
UiModeManager mm = (UiModeManager)getSystemService(UI_MODE_SERVICE);
|
||||
return mm.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION;
|
||||
}
|
||||
}
|
@ -0,0 +1,436 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
package com.tailscale.ipn
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Application
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.LinkProperties
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
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.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.Notifier
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import libtailscale.Libtailscale
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.InetAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.security.GeneralSecurityException
|
||||
import java.util.Locale
|
||||
|
||||
class App : UninitializedApp(), libtailscale.AppContext {
|
||||
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
companion object {
|
||||
private const val FILE_CHANNEL_ID = "tailscale-files"
|
||||
private const val TAG = "App"
|
||||
private val networkConnectivityRequest =
|
||||
NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
.build()
|
||||
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 fun getPlatformDNSConfig(): String = dns.dnsConfigAsString
|
||||
|
||||
override fun isPlayVersion(): Boolean = MaybeGoogle.isGoogle()
|
||||
|
||||
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)
|
||||
appInstance = this
|
||||
setUnprotectedInstance(this)
|
||||
}
|
||||
|
||||
override fun onTerminate() {
|
||||
super.onTerminate()
|
||||
Notifier.stop()
|
||||
applicationScope.cancel()
|
||||
}
|
||||
|
||||
private var isInitialized = false
|
||||
|
||||
@Synchronized
|
||||
private fun initOnce() {
|
||||
if (isInitialized) {
|
||||
return
|
||||
}
|
||||
isInitialized = true
|
||||
|
||||
val dataDir = this.filesDir.absolutePath
|
||||
|
||||
// Set this to enable direct mode for taildrop whereby downloads will be saved directly
|
||||
// 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)
|
||||
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
setAndRegisterNetworkCallbacks()
|
||||
applicationScope.launch {
|
||||
Notifier.state.collect { state ->
|
||||
val ableToStartVPN = state > Ipn.State.NeedsMachineAuth
|
||||
val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running
|
||||
updateConnStatus(ableToStartVPN, vpnRunning)
|
||||
QuickToggleService.setVPNRunning(vpnRunning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setWantRunning(wantRunning: Boolean) {
|
||||
val callback: (Result<Ipn.Prefs>) -> Unit = { result ->
|
||||
result.fold(
|
||||
onSuccess = {},
|
||||
onFailure = { error ->
|
||||
Log.d("TAG", "Set want running: failed to update preferences: ${error.message}")
|
||||
})
|
||||
}
|
||||
Client(applicationScope)
|
||||
.editPrefs(Ipn.MaskedPrefs().apply { WantRunning = wantRunning }, callback)
|
||||
}
|
||||
|
||||
// requestNetwork attempts to find the best network that matches the passed NetworkRequest. It is
|
||||
// possible that this might return an unusuable network, eg a captive portal.
|
||||
private fun setAndRegisterNetworkCallbacks() {
|
||||
connectivityManager.requestNetwork(
|
||||
networkConnectivityRequest,
|
||||
object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
super.onAvailable(network)
|
||||
|
||||
val sb = StringBuilder()
|
||||
val linkProperties: LinkProperties? = connectivityManager.getLinkProperties(network)
|
||||
val dnsList: MutableList<InetAddress> = linkProperties?.dnsServers ?: mutableListOf()
|
||||
for (ip in dnsList) {
|
||||
sb.append(ip.hostAddress).append(" ")
|
||||
}
|
||||
val searchDomains: String? = linkProperties?.domains
|
||||
if (searchDomains != null) {
|
||||
sb.append("\n")
|
||||
sb.append(searchDomains)
|
||||
}
|
||||
|
||||
if (dns.updateDNSFromNetwork(sb.toString())) {
|
||||
Libtailscale.onDNSConfigChanged(linkProperties?.interfaceName)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
super.onLost(network)
|
||||
if (dns.updateDNSFromNetwork("")) {
|
||||
Libtailscale.onDNSConfigChanged("")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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, vpnRunning: Boolean) {
|
||||
setAbleToStartVPN(ableToStartVPN)
|
||||
QuickToggleService.updateTile()
|
||||
Log.d("App", "Set Tile Ready: $ableToStartVPN")
|
||||
notifyStatus(vpnRunning)
|
||||
}
|
||||
|
||||
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) {
|
||||
Log.e(TAG, "Failed to create downloads folder: $e")
|
||||
downloads = File(this.filesDir, "Taildrop")
|
||||
try {
|
||||
if (!downloads.exists()) {
|
||||
downloads.mkdirs()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.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 {
|
||||
return MDMSettings.allSettingsByKey[key]?.flow?.value?.toString()
|
||||
?: run {
|
||||
Log.d("MDM", "$key is not defined on Android. Throwing NoSuchKeyException.")
|
||||
throw MDMSettings.NoSuchKeyException()
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(
|
||||
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
|
||||
override fun getSyspolicyStringArrayJSONValue(key: String): String {
|
||||
val list = MDMSettings.allSettingsByKey[key]?.flow?.value as? List<String>
|
||||
try {
|
||||
return Json.encodeToString(list)
|
||||
} catch (e: Exception) {
|
||||
Log.d("MDM", "$key is not defined on Android. Throwing NoSuchKeyException.")
|
||||
throw MDMSettings.NoSuchKeyException()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UninitializedApp contains all of the methods of App that can be used without having to initialize
|
||||
* the Go backend. This is useful when you want to access functions on the App without creating side
|
||||
* effects from starting the Go backend (such as launching the VPN).
|
||||
*/
|
||||
open class UninitializedApp : Application() {
|
||||
companion object {
|
||||
const val STATUS_NOTIFICATION_ID = 1
|
||||
const val STATUS_CHANNEL_ID = "tailscale-status"
|
||||
|
||||
// Key for shared preference that tracks whether or not we're able to start
|
||||
// the VPN (i.e. we're logged in and machine is authorized).
|
||||
private const val ABLE_TO_START_VPN_KEY = "ableToStartVPN"
|
||||
|
||||
// File for shared preferences that are not encrypted.
|
||||
private const val UNENCRYPTED_PREFERENCES = "unencrypted"
|
||||
|
||||
private lateinit var appInstance: UninitializedApp
|
||||
|
||||
@JvmStatic
|
||||
fun get(): UninitializedApp {
|
||||
return appInstance
|
||||
}
|
||||
}
|
||||
|
||||
protected fun setUnprotectedInstance(instance: UninitializedApp) {
|
||||
appInstance = instance
|
||||
}
|
||||
|
||||
protected fun setAbleToStartVPN(rdy: Boolean) {
|
||||
getUnencryptedPrefs().edit().putBoolean(ABLE_TO_START_VPN_KEY, rdy).apply()
|
||||
}
|
||||
|
||||
/** This function can be called without initializing the App. */
|
||||
fun isAbleToStartVPN(): Boolean {
|
||||
return getUnencryptedPrefs().getBoolean(ABLE_TO_START_VPN_KEY, false)
|
||||
}
|
||||
|
||||
private fun getUnencryptedPrefs(): SharedPreferences {
|
||||
return getSharedPreferences(UNENCRYPTED_PREFERENCES, MODE_PRIVATE)
|
||||
}
|
||||
|
||||
fun startVPN() {
|
||||
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_START_VPN }
|
||||
startForegroundService(intent)
|
||||
}
|
||||
|
||||
fun stopVPN() {
|
||||
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_STOP_VPN }
|
||||
startService(intent)
|
||||
}
|
||||
|
||||
fun createNotificationChannel(id: String, name: String, description: String, importance: Int) {
|
||||
val channel = NotificationChannel(id, name, importance)
|
||||
channel.description = description
|
||||
val nm: NotificationManagerCompat = NotificationManagerCompat.from(this)
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
protected fun notifyStatus(vpnRunning: Boolean) {
|
||||
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
PackageManager.PERMISSION_GRANTED) {
|
||||
// TODO: Consider calling
|
||||
// ActivityCompat#requestPermissions
|
||||
// here to request the missing permissions, and then overriding
|
||||
// public void onRequestPermissionsResult(int requestCode, String[] permissions,
|
||||
// int[] grantResults)
|
||||
// to handle the case where the user grants the permission. See the documentation
|
||||
// for ActivityCompat#requestPermissions for more details.
|
||||
return
|
||||
}
|
||||
val nm: NotificationManagerCompat = NotificationManagerCompat.from(this)
|
||||
nm.notify(STATUS_NOTIFICATION_ID, buildStatusNotification(vpnRunning))
|
||||
}
|
||||
|
||||
fun buildStatusNotification(vpnRunning: Boolean): Notification {
|
||||
val message = getString(if (vpnRunning) R.string.connected else R.string.not_connected)
|
||||
val icon = if (vpnRunning) R.drawable.ic_notification else R.drawable.ic_notification_disabled
|
||||
val action =
|
||||
if (vpnRunning) IPNReceiver.INTENT_DISCONNECT_VPN else IPNReceiver.INTENT_CONNECT_VPN
|
||||
val actionLabel = getString(if (vpnRunning) R.string.disconnect else R.string.connect)
|
||||
val buttonIntent = Intent(this, IPNReceiver::class.java).apply { this.action = action }
|
||||
val pendingButtonIntent: PendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
this,
|
||||
0,
|
||||
buttonIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
val intent =
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
val pendingIntent: PendingIntent =
|
||||
PendingIntent.getActivity(
|
||||
this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
return NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
|
||||
.setSmallIcon(icon)
|
||||
.setContentTitle("Tailscale")
|
||||
.setContentText(message)
|
||||
.setAutoCancel(!vpnRunning)
|
||||
.setOnlyAlertOnce(!vpnRunning)
|
||||
.setOngoing(vpnRunning)
|
||||
.setSilent(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.addAction(NotificationCompat.Action.Builder(0, actionLabel, pendingButtonIntent).build())
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
}
|
||||
}
|
@ -1,133 +0,0 @@
|
||||
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package com.tailscale.ipn;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.res.AssetFileDescriptor;
|
||||
import android.content.res.Configuration;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.os.Bundle;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.net.Uri;
|
||||
import android.content.pm.PackageManager;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
|
||||
import org.gioui.GioView;
|
||||
|
||||
public final class IPNActivity extends Activity {
|
||||
final static int WRITE_STORAGE_RESULT = 1000;
|
||||
|
||||
private GioView view;
|
||||
|
||||
@Override public void onCreate(Bundle state) {
|
||||
super.onCreate(state);
|
||||
view = new GioView(this);
|
||||
setContentView(view);
|
||||
handleIntent();
|
||||
}
|
||||
|
||||
@Override public void onNewIntent(Intent i) {
|
||||
setIntent(i);
|
||||
handleIntent();
|
||||
}
|
||||
|
||||
private void handleIntent() {
|
||||
Intent it = getIntent();
|
||||
String act = it.getAction();
|
||||
String[] texts;
|
||||
Uri[] uris;
|
||||
if (Intent.ACTION_SEND.equals(act)) {
|
||||
uris = new Uri[]{it.getParcelableExtra(Intent.EXTRA_STREAM)};
|
||||
texts = new String[]{it.getStringExtra(Intent.EXTRA_TEXT)};
|
||||
} else if (Intent.ACTION_SEND_MULTIPLE.equals(act)) {
|
||||
List<Uri> extraUris = it.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
|
||||
uris = extraUris.toArray(new Uri[0]);
|
||||
texts = new String[uris.length];
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
String mime = it.getType();
|
||||
int nitems = uris.length;
|
||||
String[] items = new String[nitems];
|
||||
String[] mimes = new String[nitems];
|
||||
int[] types = new int[nitems];
|
||||
String[] names = new String[nitems];
|
||||
long[] sizes = new long[nitems];
|
||||
int nfiles = 0;
|
||||
for (int i = 0; i < uris.length; i++) {
|
||||
String text = texts[i];
|
||||
Uri uri = uris[i];
|
||||
if (text != null) {
|
||||
types[nfiles] = 1; // FileTypeText
|
||||
names[nfiles] = "file.txt";
|
||||
mimes[nfiles] = mime;
|
||||
items[nfiles] = text;
|
||||
// Determined by len(text) in Go to eliminate UTF-8 encoding differences.
|
||||
sizes[nfiles] = 0;
|
||||
nfiles++;
|
||||
} else if (uri != null) {
|
||||
Cursor c = getContentResolver().query(uri, null, null, null, null);
|
||||
if (c == null) {
|
||||
// Ignore files we have no permission to access.
|
||||
continue;
|
||||
}
|
||||
int nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||
int sizeCol = c.getColumnIndex(OpenableColumns.SIZE);
|
||||
c.moveToFirst();
|
||||
String name = c.getString(nameCol);
|
||||
long size = c.getLong(sizeCol);
|
||||
types[nfiles] = 2; // FileTypeURI
|
||||
mimes[nfiles] = mime;
|
||||
items[nfiles] = uri.toString();
|
||||
names[nfiles] = name;
|
||||
sizes[nfiles] = size;
|
||||
nfiles++;
|
||||
}
|
||||
}
|
||||
App.onShareIntent(nfiles, types, mimes, items, names, sizes);
|
||||
}
|
||||
|
||||
@Override public void onRequestPermissionsResult(int reqCode, String[] perms, int[] grants) {
|
||||
switch (reqCode) {
|
||||
case WRITE_STORAGE_RESULT:
|
||||
if (grants.length > 0 && grants[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
App.onWriteStorageGranted();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void onDestroy() {
|
||||
view.destroy();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override public void onStart() {
|
||||
super.onStart();
|
||||
view.start();
|
||||
}
|
||||
|
||||
@Override public void onStop() {
|
||||
view.stop();
|
||||
super.onStop();
|
||||
}
|
||||
|
||||
@Override public void onConfigurationChanged(Configuration c) {
|
||||
super.onConfigurationChanged(c);
|
||||
view.configurationChanged();
|
||||
}
|
||||
|
||||
@Override public void onLowMemory() {
|
||||
super.onLowMemory();
|
||||
view.onLowMemory();
|
||||
}
|
||||
|
||||
@Override public void onBackPressed() {
|
||||
if (!view.backPressed())
|
||||
super.onBackPressed();
|
||||
}
|
||||
}
|
@ -1,126 +0,0 @@
|
||||
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package com.tailscale.ipn;
|
||||
|
||||
import android.os.Build;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.VpnService;
|
||||
import android.system.OsConstants;
|
||||
|
||||
import org.gioui.GioActivity;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
|
||||
public class IPNService extends VpnService {
|
||||
public static final String ACTION_CONNECT = "com.tailscale.ipn.CONNECT";
|
||||
public static final String ACTION_DISCONNECT = "com.tailscale.ipn.DISCONNECT";
|
||||
|
||||
@Override public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent != null && ACTION_DISCONNECT.equals(intent.getAction())) {
|
||||
((App)getApplicationContext()).autoConnect = false;
|
||||
close();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
connect();
|
||||
App app = ((App)getApplicationContext());
|
||||
if (app.vpnReady && app.autoConnect) {
|
||||
directConnect();
|
||||
}
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
private void close() {
|
||||
stopForeground(true);
|
||||
disconnect();
|
||||
}
|
||||
|
||||
@Override public void onDestroy() {
|
||||
close();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override public void onRevoke() {
|
||||
close();
|
||||
super.onRevoke();
|
||||
}
|
||||
|
||||
private PendingIntent configIntent() {
|
||||
return PendingIntent.getActivity(this, 0, new Intent(this, IPNActivity.class),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
|
||||
}
|
||||
|
||||
private void disallowApp(VpnService.Builder b, String name) {
|
||||
try {
|
||||
b.addDisallowedApplication(name);
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
protected VpnService.Builder newBuilder() {
|
||||
VpnService.Builder b = new VpnService.Builder()
|
||||
.setConfigureIntent(configIntent())
|
||||
.allowFamily(OsConstants.AF_INET)
|
||||
.allowFamily(OsConstants.AF_INET6);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
b.setMetered(false); // Inherit the metered status from the underlying networks.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||
b.setUnderlyingNetworks(null); // Use all available networks.
|
||||
|
||||
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
|
||||
this.disallowApp(b, "com.google.android.apps.messaging");
|
||||
|
||||
// Stadia https://github.com/tailscale/tailscale/issues/3460
|
||||
this.disallowApp(b, "com.google.stadia.android");
|
||||
|
||||
// Android Auto https://github.com/tailscale/tailscale/issues/3828
|
||||
this.disallowApp(b, "com.google.android.projection.gearhead");
|
||||
|
||||
// GoPro https://github.com/tailscale/tailscale/issues/2554
|
||||
this.disallowApp(b, "com.gopro.smarty");
|
||||
|
||||
// Sonos https://github.com/tailscale/tailscale/issues/2548
|
||||
this.disallowApp(b, "com.sonos.acr");
|
||||
this.disallowApp(b, "com.sonos.acr2");
|
||||
|
||||
// Google Chromecast https://github.com/tailscale/tailscale/issues/3636
|
||||
this.disallowApp(b, "com.google.android.apps.chromecast.app");
|
||||
|
||||
return b;
|
||||
}
|
||||
|
||||
public void notify(String title, String message) {
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.NOTIFY_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(message)
|
||||
.setContentIntent(configIntent())
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
|
||||
|
||||
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
|
||||
nm.notify(App.NOTIFY_NOTIFICATION_ID, builder.build());
|
||||
}
|
||||
|
||||
public void updateStatusNotification(String title, String message) {
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.STATUS_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(message)
|
||||
.setContentIntent(configIntent())
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW);
|
||||
|
||||
startForeground(App.STATUS_NOTIFICATION_ID, builder.build());
|
||||
}
|
||||
|
||||
private native void connect();
|
||||
private native void disconnect();
|
||||
|
||||
public native void directConnect();
|
||||
}
|
@ -0,0 +1,132 @@
|
||||
// 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 libtailscale.Libtailscale
|
||||
import java.util.UUID
|
||||
|
||||
open class IPNService : VpnService(), libtailscale.IPNService {
|
||||
private val randomID: String = UUID.randomUUID().toString()
|
||||
|
||||
override fun id(): String {
|
||||
return randomID
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// grab app to make sure it initializes
|
||||
App.get()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
|
||||
when (intent?.action) {
|
||||
ACTION_STOP_VPN -> {
|
||||
App.get().setWantRunning(false)
|
||||
close()
|
||||
START_NOT_STICKY
|
||||
}
|
||||
ACTION_START_VPN -> {
|
||||
showForegroundNotification()
|
||||
App.get().setWantRunning(true)
|
||||
Libtailscale.requestVPN(this)
|
||||
START_STICKY
|
||||
}
|
||||
"android.net.VpnService" -> {
|
||||
// This means we were started by Android due to Always On VPN.
|
||||
// We don't show a foreground notification because we weren't
|
||||
// started as a foreground service.
|
||||
App.get().setWantRunning(true)
|
||||
Libtailscale.requestVPN(this)
|
||||
START_STICKY
|
||||
}
|
||||
else -> {
|
||||
// This means that we were restarted after the service was killed
|
||||
// (potentially due to OOM).
|
||||
if (UninitializedApp.get().isAbleToStartVPN()) {
|
||||
App.get()
|
||||
Libtailscale.requestVPN(this)
|
||||
START_STICKY
|
||||
} else {
|
||||
START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
stopForeground(true)
|
||||
Libtailscale.serviceDisconnect(this)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
close()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onRevoke() {
|
||||
close()
|
||||
super.onRevoke()
|
||||
}
|
||||
|
||||
private fun showForegroundNotification() {
|
||||
startForeground(
|
||||
UninitializedApp.STATUS_NOTIFICATION_ID,
|
||||
UninitializedApp.get().buildStatusNotification(true))
|
||||
}
|
||||
|
||||
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) {}
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
|
||||
disallowApp(b, "com.google.android.apps.messaging")
|
||||
|
||||
// Stadia https://github.com/tailscale/tailscale/issues/3460
|
||||
disallowApp(b, "com.google.stadia.android")
|
||||
|
||||
// Android Auto https://github.com/tailscale/tailscale/issues/3828
|
||||
disallowApp(b, "com.google.android.projection.gearhead")
|
||||
|
||||
// GoPro https://github.com/tailscale/tailscale/issues/2554
|
||||
disallowApp(b, "com.gopro.smarty")
|
||||
|
||||
// Sonos https://github.com/tailscale/tailscale/issues/2548
|
||||
disallowApp(b, "com.sonos.acr")
|
||||
disallowApp(b, "com.sonos.acr2")
|
||||
|
||||
// Google Chromecast https://github.com/tailscale/tailscale/issues/3636
|
||||
disallowApp(b, "com.google.android.apps.chromecast.app")
|
||||
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,371 @@
|
||||
// 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.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.VpnService
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.activity.viewModels
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
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.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.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.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.TailnetLockSetupView
|
||||
import com.tailscale.ipn.ui.view.UserSwitcherNav
|
||||
import com.tailscale.ipn.ui.view.UserSwitcherView
|
||||
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
|
||||
import com.tailscale.ipn.ui.viewModel.MainViewModel
|
||||
import com.tailscale.ipn.ui.viewModel.SettingsNav
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private lateinit var requestVpnPermission: ActivityResultLauncher<Unit>
|
||||
private lateinit var navController: NavHostController
|
||||
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
|
||||
private val viewModel: MainViewModel by viewModels()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "Main Activity"
|
||||
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()
|
||||
|
||||
// (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) {
|
||||
Log.d("VpnPermission", "VPN permission granted")
|
||||
viewModel.setVpnPrepared(true)
|
||||
} else {
|
||||
Log.d("VpnPermission", "VPN permission denied")
|
||||
viewModel.setVpnPrepared(false)
|
||||
}
|
||||
}
|
||||
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher)
|
||||
|
||||
setContent {
|
||||
AppTheme {
|
||||
navController = rememberNavController()
|
||||
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") },
|
||||
)
|
||||
|
||||
val settingsNav =
|
||||
SettingsNav(
|
||||
onNavigateToBugReport = { navController.navigate("bugReport") },
|
||||
onNavigateToAbout = { navController.navigate("about") },
|
||||
onNavigateToDNSSettings = { navController.navigate("dnsSettings") },
|
||||
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") },
|
||||
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("mullvad") { MullvadExitNodePickerList(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") ?: "")
|
||||
}
|
||||
composable("bugReport") { BugReportView(backTo("settings")) }
|
||||
composable("dnsSettings") { DNSSettingsView(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) } }
|
||||
}
|
||||
|
||||
// Returns true if we should render a QR code instead of launching a browser
|
||||
// for login requests
|
||||
private fun useQRCodeLogin(): Boolean {
|
||||
return AndroidTVUtil.isAndroidTV()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
super.onNewIntent(intent)
|
||||
if (intent?.getBooleanExtra(START_AT_ROOT, false) == true) {
|
||||
if (this::navController.isInitialized) {
|
||||
navController.popBackStack(route = "main", inclusive = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun login(urlString: String) {
|
||||
// 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) {
|
||||
Log.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) {
|
||||
Log.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,35 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn;
|
||||
|
||||
import android.app.Activity;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
public class MaybeGoogle {
|
||||
static boolean isGoogle() {
|
||||
return getGoogle() != null;
|
||||
}
|
||||
|
||||
static String getIdTokenForActivity(Activity act) {
|
||||
Class<?> google = getGoogle();
|
||||
if (google == null) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
Method method = google.getMethod("getIdTokenForActivity", Activity.class);
|
||||
return (String) method.invoke(null, act);
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static Class getGoogle() {
|
||||
try {
|
||||
return Class.forName("com.tailscale.ipn.Google");
|
||||
} catch (ClassNotFoundException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package com.tailscale.ipn;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.Fragment;
|
||||
import android.content.Intent;
|
||||
|
||||
public class Peer extends Fragment {
|
||||
@Override public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
onActivityResult0(getActivity(), requestCode, resultCode);
|
||||
}
|
||||
|
||||
private static native void onActivityResult0(Activity act, int reqCode, int resCode);
|
||||
}
|
@ -1,83 +1,97 @@
|
||||
// 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.
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.ComponentName;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.service.quicksettings.Tile;
|
||||
import android.service.quicksettings.TileService;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public class QuickToggleService extends TileService {
|
||||
// lock protects the static fields below it.
|
||||
private static Object lock = new Object();
|
||||
// Active tracks whether the VPN is active.
|
||||
private static boolean active;
|
||||
// Ready tracks whether the tailscale backend is
|
||||
// ready to switch on/off.
|
||||
private static boolean ready;
|
||||
// currentTile tracks getQsTile while service is listening.
|
||||
private static Tile currentTile;
|
||||
// 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;
|
||||
|
||||
@Override public void onStartListening() {
|
||||
synchronized (lock) {
|
||||
currentTile = getQsTile();
|
||||
}
|
||||
updateTile();
|
||||
}
|
||||
// currentTile tracks getQsTile while service is listening.
|
||||
private static Tile currentTile;
|
||||
|
||||
@Override public void onStopListening() {
|
||||
synchronized (lock) {
|
||||
currentTile = null;
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
@Override public void onClick() {
|
||||
boolean r;
|
||||
synchronized (lock) {
|
||||
r = ready;
|
||||
}
|
||||
if (r) {
|
||||
onTileClick();
|
||||
} else {
|
||||
// Start main activity.
|
||||
Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName());
|
||||
startActivityAndCollapse(i);
|
||||
}
|
||||
}
|
||||
static void setVPNRunning(boolean running) {
|
||||
synchronized (lock) {
|
||||
isRunning = running;
|
||||
}
|
||||
updateTile();
|
||||
}
|
||||
|
||||
private static void updateTile() {
|
||||
Tile t;
|
||||
boolean act;
|
||||
synchronized (lock) {
|
||||
t = currentTile;
|
||||
act = active && ready;
|
||||
}
|
||||
if (t == null) {
|
||||
return;
|
||||
}
|
||||
t.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
|
||||
t.updateTile();
|
||||
}
|
||||
@Override
|
||||
public void onStartListening() {
|
||||
synchronized (lock) {
|
||||
currentTile = getQsTile();
|
||||
}
|
||||
updateTile();
|
||||
}
|
||||
|
||||
static void setReady(Context ctx, boolean rdy) {
|
||||
synchronized (lock) {
|
||||
ready = rdy;
|
||||
}
|
||||
updateTile();
|
||||
}
|
||||
@Override
|
||||
public void onStopListening() {
|
||||
synchronized (lock) {
|
||||
currentTile = null;
|
||||
}
|
||||
}
|
||||
|
||||
static void setStatus(Context ctx, boolean act) {
|
||||
synchronized (lock) {
|
||||
active = act;
|
||||
}
|
||||
updateTile();
|
||||
}
|
||||
@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 {
|
||||
startActivityAndCollapse(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static native void onTileClick();
|
||||
private void onTileClick() {
|
||||
UninitializedApp app = UninitializedApp.get();
|
||||
boolean needsToStop;
|
||||
synchronized (lock) {
|
||||
needsToStop = app.isAbleToStartVPN() && isRunning;
|
||||
}
|
||||
if (needsToStop) {
|
||||
app.stopVPN();
|
||||
} else {
|
||||
app.startVPN();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,116 @@
|
||||
// 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.util.Log
|
||||
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.notifier.Notifier
|
||||
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 kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
// 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(state: Bundle?) {
|
||||
super.onCreate(state)
|
||||
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()
|
||||
Notifier.start(lifecycleScope)
|
||||
loadFiles()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
Notifier.stop()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
loadFiles()
|
||||
}
|
||||
|
||||
// Loads the files from the intent.
|
||||
fun loadFiles() {
|
||||
if (intent == null) {
|
||||
Log.e(TAG, "Share failure - No intent found")
|
||||
return
|
||||
}
|
||||
|
||||
val act = intent.action
|
||||
val uris: List<Uri?>?
|
||||
|
||||
uris =
|
||||
when (act) {
|
||||
Intent.ACTION_SEND -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java))
|
||||
} else {
|
||||
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 {
|
||||
intent.getParcelableArrayListExtra<Uri?>(Intent.EXTRA_STREAM)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Log.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 = c.getString(nameCol)
|
||||
val size = c.getLong(sizeCol)
|
||||
c.close()
|
||||
val file = Ipn.OutgoingFile(Name = name, DeclaredSize = size)
|
||||
file.uri = it
|
||||
file
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
if (pendingFiles.isEmpty()) {
|
||||
Log.e(TAG, "Share failure - no files extracted from intent")
|
||||
}
|
||||
|
||||
requestedTransfers.set(pendingFiles)
|
||||
}
|
||||
}
|
@ -1,66 +1,63 @@
|
||||
// Copyright (c) 2023 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.
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.VpnService;
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.work.Worker;
|
||||
import androidx.work.WorkerParameters;
|
||||
|
||||
/**
|
||||
* A worker that exists to support IPNReceiver.
|
||||
*/
|
||||
public final class StartVPNWorker extends Worker {
|
||||
|
||||
public StartVPNWorker(
|
||||
Context appContext,
|
||||
WorkerParameters workerParams) {
|
||||
public StartVPNWorker(Context appContext, WorkerParameters workerParams) {
|
||||
super(appContext, workerParams);
|
||||
}
|
||||
|
||||
@Override public Result doWork() {
|
||||
App app = ((App)getApplicationContext());
|
||||
|
||||
// We will start the VPN from the background
|
||||
app.autoConnect = true;
|
||||
// We need to make sure we prepare the VPN Service, just in case it isn't prepared.
|
||||
@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();
|
||||
}
|
||||
}
|
||||
|
||||
Intent intent = VpnService.prepare(app);
|
||||
if (intent == null) {
|
||||
// If null then the VPN is already prepared and/or it's just been prepared because we have permission
|
||||
app.startVPN();
|
||||
return Result.success();
|
||||
} else {
|
||||
// This VPN possibly doesn't have permission, we need to display a notification which when clicked launches the intent provided.
|
||||
android.util.Log.e("StartVPNWorker", "Tailscale doesn't have permission from the system to start VPN. Launching the intent provided.");
|
||||
// We aren't ready to start the VPN or don't have permission, open the Tailscale app.
|
||||
android.util.Log.e("StartVPNWorker", "Tailscale isn't ready to start the VPN, notify the user.");
|
||||
|
||||
// Send notification
|
||||
NotificationManager notificationManager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
String channelId = "start_vpn_channel";
|
||||
// Send notification
|
||||
NotificationManager notificationManager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
String channelId = "start_vpn_channel";
|
||||
|
||||
// Use createNotificationChannel method from App.java
|
||||
app.createNotificationChannel(channelId, "Start VPN Channel", NotificationManager.IMPORTANCE_DEFAULT);
|
||||
// Use createNotificationChannel method from App.java
|
||||
app.createNotificationChannel(channelId, getApplicationContext().getString(R.string.vpn_start), getApplicationContext().getString(R.string.notifications_delivered_when_user_interaction_is_required_to_establish_the_vpn_tunnel), NotificationManager.IMPORTANCE_HIGH);
|
||||
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
int pendingIntentFlags = PendingIntent.FLAG_ONE_SHOT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_IMMUTABLE : 0);
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(app, 0, intent, pendingIntentFlags);
|
||||
// Use prepareIntent if available.
|
||||
Intent intent = app.getPackageManager().getLaunchIntentForPackage(app.getPackageName());
|
||||
assert intent != null;
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
int pendingIntentFlags = PendingIntent.FLAG_ONE_SHOT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_IMMUTABLE : 0);
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(app, 0, intent, pendingIntentFlags);
|
||||
|
||||
Notification notification = new Notification.Builder(app, channelId)
|
||||
.setContentTitle("Tailscale Connection Failed")
|
||||
.setContentText("Tap here to renew permission.")
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.build();
|
||||
Notification notification = new Notification.Builder(app, channelId).setContentTitle(app.getString(R.string.title_connection_failed)).setContentText(app.getString(R.string.body_open_tailscale)).setSmallIcon(R.drawable.ic_notification).setContentIntent(pendingIntent).setAutoCancel(true).build();
|
||||
|
||||
notificationManager.notify(1, notification);
|
||||
notificationManager.notify(1, notification);
|
||||
|
||||
return Result.failure();
|
||||
}
|
||||
return Result.failure();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,102 @@
|
||||
// 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, since the backend checks the value
|
||||
// returned by the handler for equality using errors.Is().
|
||||
class NoSuchKeyException : Exception("no such key")
|
||||
|
||||
val forceEnabled = BooleanMDMSetting("ForceEnabled", "Force Enabled Connection Toggle")
|
||||
|
||||
// Handled on the backed
|
||||
val exitNodeID = StringMDMSetting("ExitNodeID", "Forced Exit Node: Stable ID")
|
||||
|
||||
// (jonathan) TODO: Unused but required. There is some funky go string duration parsing required
|
||||
// here.
|
||||
val keyExpirationNotice = StringMDMSetting("KeyExpirationNotice", "Key Expiration Notice Period")
|
||||
|
||||
val loginURL = StringMDMSetting("LoginURL", "Custom control server URL")
|
||||
|
||||
val managedByCaption = StringMDMSetting("ManagedByCaption", "Managed By - Caption")
|
||||
|
||||
val managedByOrganizationName =
|
||||
StringMDMSetting("ManagedByOrganizationName", "Managed By - Organization Name")
|
||||
|
||||
val managedByURL = StringMDMSetting("ManagedByURL", "Managed By - Support URL")
|
||||
|
||||
// Handled on the backend
|
||||
val tailnet = StringMDMSetting("Tailnet", "Recommended/Required Tailnet Name")
|
||||
|
||||
val hiddenNetworkDevices =
|
||||
StringArrayListMDMSetting("HiddenNetworkDevices", "Hidden Network Device Categories")
|
||||
|
||||
// Unused on Android
|
||||
val allowIncomingConnections =
|
||||
AlwaysNeverUserDecidesMDMSetting("AllowIncomingConnections", "Allow Incoming Connections")
|
||||
|
||||
// Unused on Android
|
||||
val detectThirdPartyAppConflicts =
|
||||
AlwaysNeverUserDecidesMDMSetting(
|
||||
"DetectThirdPartyAppConflicts", "Detect potentially problematic third-party apps")
|
||||
|
||||
val exitNodeAllowLANAccess =
|
||||
AlwaysNeverUserDecidesMDMSetting(
|
||||
"ExitNodeAllowLANAccess", "Allow LAN Access when using an exit node")
|
||||
|
||||
// Handled on the backend
|
||||
val postureChecking =
|
||||
AlwaysNeverUserDecidesMDMSetting("PostureChecking", "Enable Posture Checking")
|
||||
|
||||
val useTailscaleDNSSettings =
|
||||
AlwaysNeverUserDecidesMDMSetting("UseTailscaleDNSSettings", "Use Tailscale DNS Settings")
|
||||
|
||||
// Unused on Android
|
||||
val useTailscaleSubnets =
|
||||
AlwaysNeverUserDecidesMDMSetting("UseTailscaleSubnets", "Use Tailscale Subnets")
|
||||
|
||||
val exitNodesPicker = ShowHideMDMSetting("ExitNodesPicker", "Exit Nodes Picker")
|
||||
|
||||
val manageTailnetLock = ShowHideMDMSetting("ManageTailnetLock", "“Manage Tailnet lock” menu item")
|
||||
|
||||
// Unused on Android
|
||||
val resetToDefaults = ShowHideMDMSetting("ResetToDefaults", "“Reset to Defaults” menu item")
|
||||
|
||||
val runExitNode = ShowHideMDMSetting("RunExitNode", "Run as Exit Node")
|
||||
|
||||
// Unused on Android
|
||||
val testMenu = ShowHideMDMSetting("TestMenu", "Show Debug Menu")
|
||||
|
||||
// Unused on Android
|
||||
val updateMenu = ShowHideMDMSetting("UpdateMenu", "“Update Available” menu item")
|
||||
|
||||
// (jonathan) TODO: Use this when suggested exit nodes are implemented
|
||||
val allowedSuggestedExitNodes =
|
||||
StringArrayListMDMSetting("AllowedSuggestedExitNodes", "Allowed Suggested Exit Nodes")
|
||||
|
||||
val allSettings by lazy {
|
||||
MDMSettings::class
|
||||
.declaredMemberProperties
|
||||
.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
|
||||
allSettings.forEach { it.setFrom(bundle, app) }
|
||||
}
|
||||
}
|
@ -0,0 +1,93 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.mdm
|
||||
|
||||
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
|
||||
|
||||
abstract class MDMSetting<T>(defaultValue: T, val key: String, val localizedTitle: String) {
|
||||
val flow: StateFlow<T> = MutableStateFlow<T>(defaultValue)
|
||||
|
||||
fun setFrom(bundle: Bundle?, app: App) {
|
||||
val v = getFrom(bundle, app)
|
||||
flow.set(v)
|
||||
}
|
||||
|
||||
abstract fun getFrom(bundle: Bundle?, app: App): T
|
||||
}
|
||||
|
||||
class BooleanMDMSetting(key: String, localizedTitle: String) :
|
||||
MDMSetting<Boolean>(false, key, localizedTitle) {
|
||||
override fun getFrom(bundle: Bundle?, app: App) =
|
||||
bundle?.getBoolean(key) ?: app.getEncryptedPrefs().getBoolean(key, false)
|
||||
}
|
||||
|
||||
class StringMDMSetting(key: String, localizedTitle: String) :
|
||||
MDMSetting<String?>(null, key, localizedTitle) {
|
||||
override fun getFrom(bundle: Bundle?, app: App) =
|
||||
bundle?.getString(key) ?: app.getEncryptedPrefs().getString(key, null)
|
||||
}
|
||||
|
||||
class StringArrayListMDMSetting(key: String, localizedTitle: String) :
|
||||
MDMSetting<List<String>?>(null, key, localizedTitle) {
|
||||
override fun getFrom(bundle: Bundle?, app: App) =
|
||||
bundle?.getStringArrayList(key)
|
||||
?: app.getEncryptedPrefs().getStringSet(key, HashSet<String>())?.toList()
|
||||
}
|
||||
|
||||
class AlwaysNeverUserDecidesMDMSetting(key: String, localizedTitle: String) :
|
||||
MDMSetting<AlwaysNeverUserDecides>(AlwaysNeverUserDecides.UserDecides, key, localizedTitle) {
|
||||
override fun getFrom(bundle: Bundle?, app: App): AlwaysNeverUserDecides {
|
||||
val storedString =
|
||||
bundle?.getString(key)
|
||||
?: App.get().getEncryptedPrefs().getString(key, null)
|
||||
?: "user-decides"
|
||||
return when (storedString) {
|
||||
"always" -> {
|
||||
AlwaysNeverUserDecides.Always
|
||||
}
|
||||
"never" -> {
|
||||
AlwaysNeverUserDecides.Never
|
||||
}
|
||||
else -> {
|
||||
AlwaysNeverUserDecides.UserDecides
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ShowHideMDMSetting(key: String, localizedTitle: String) :
|
||||
MDMSetting<ShowHide>(ShowHide.Show, key, localizedTitle) {
|
||||
override fun getFrom(bundle: Bundle?, app: App): ShowHide {
|
||||
val storedString =
|
||||
bundle?.getString(key) ?: App.get().getEncryptedPrefs().getString(key, null) ?: "show"
|
||||
return when (storedString) {
|
||||
"hide" -> {
|
||||
ShowHide.Hide
|
||||
}
|
||||
else -> {
|
||||
ShowHide.Show
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class AlwaysNeverUserDecides(val value: String) {
|
||||
Always("always"),
|
||||
Never("never"),
|
||||
UserDecides("user-decides");
|
||||
|
||||
val hiddenFromUser: Boolean
|
||||
get() {
|
||||
return this != UserDecides
|
||||
}
|
||||
}
|
||||
|
||||
enum class ShowHide(val value: String) {
|
||||
Show("show"),
|
||||
Hide("hide")
|
||||
}
|
@ -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,350 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.localapi
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
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.util.InputStreamAdapter
|
||||
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
|
||||
|
||||
/**
|
||||
* 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 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() }
|
||||
Log.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,
|
||||
noinline responseHandler: (Result<T>) -> Unit
|
||||
) {
|
||||
Request(
|
||||
scope = scope,
|
||||
method = "POST",
|
||||
path = path,
|
||||
body = body,
|
||||
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) {
|
||||
Log.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)
|
||||
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) {
|
||||
Log.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,235 @@
|
||||
// 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);
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
@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: Boolean? = 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,129 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
class IpnState {
|
||||
@Serializable
|
||||
data class PeerStatusLite(
|
||||
val RxBytes: Long,
|
||||
val TxBytes: Long,
|
||||
val LastHandshake: String,
|
||||
val NodeKey: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PeerStatus(
|
||||
val ID: StableNodeID,
|
||||
val HostName: String,
|
||||
val DNSName: String,
|
||||
val TailscaleIPs: List<Addr>? = null,
|
||||
val Tags: List<String>? = null,
|
||||
val PrimaryRoutes: List<String>? = null,
|
||||
val Addrs: List<String>? = null,
|
||||
val Online: Boolean,
|
||||
val ExitNode: Boolean,
|
||||
val ExitNodeOption: Boolean,
|
||||
val Active: Boolean,
|
||||
val PeerAPIURL: List<String>? = null,
|
||||
val Capabilities: List<String>? = null,
|
||||
val SSH_HostKeys: List<String>? = null,
|
||||
val ShareeNode: Boolean? = null,
|
||||
val Expired: Boolean? = null,
|
||||
val Location: Tailcfg.Location? = null,
|
||||
) {
|
||||
fun computedName(status: Status): String {
|
||||
val name = DNSName
|
||||
val suffix = status.CurrentTailnet?.MagicDNSSuffix
|
||||
|
||||
suffix ?: return name
|
||||
|
||||
if (!(name.endsWith("." + suffix + "."))) {
|
||||
return name
|
||||
}
|
||||
|
||||
return name.dropLast(suffix.count() + 2)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ExitNodeStatus(
|
||||
val ID: StableNodeID,
|
||||
val Online: Boolean,
|
||||
val TailscaleIPs: List<Prefix>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TailnetStatus(
|
||||
val Name: String,
|
||||
val MagicDNSSuffix: String,
|
||||
val MagicDNSEnabled: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Status(
|
||||
val Version: String,
|
||||
val TUN: Boolean,
|
||||
val BackendState: String,
|
||||
val AuthURL: String,
|
||||
val TailscaleIPs: List<Addr>? = null,
|
||||
val Self: PeerStatus? = null,
|
||||
val ExitNodeStatus: ExitNodeStatus? = null,
|
||||
val Health: List<String>? = null,
|
||||
val CurrentTailnet: TailnetStatus? = null,
|
||||
val CertDomains: List<String>? = null,
|
||||
val Peer: Map<String, PeerStatus>? = null,
|
||||
val User: Map<String, Tailcfg.UserProfile>? = null,
|
||||
val ClientVersion: Tailcfg.ClientVersion? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NetworkLockStatus(
|
||||
var Enabled: Boolean? = null,
|
||||
var PublicKey: String? = null,
|
||||
var NodeKey: String? = null,
|
||||
var NodeKeySigned: Boolean? = null,
|
||||
var FilteredPeers: List<TKAFilteredPeer>? = null,
|
||||
var StateID: ULong? = null,
|
||||
var TrustedKeys: List<TKAKey>? = null
|
||||
) {
|
||||
|
||||
fun IsPublicKeyTrusted(): Boolean {
|
||||
return TrustedKeys?.any { it.Key == PublicKey } == true
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class TKAFilteredPeer(
|
||||
var Name: String,
|
||||
var TailscaleIPs: List<Addr>,
|
||||
var NodeKey: String,
|
||||
)
|
||||
|
||||
@Serializable data class TKAKey(var Key: String)
|
||||
|
||||
@Serializable
|
||||
data class PingResult(
|
||||
var IP: Addr,
|
||||
var Err: String,
|
||||
var LatencySeconds: Double,
|
||||
)
|
||||
}
|
||||
|
||||
class IpnLocal {
|
||||
@Serializable
|
||||
data class LoginProfile(
|
||||
var ID: String,
|
||||
val Name: String,
|
||||
val Key: String,
|
||||
val UserProfile: Tailcfg.UserProfile,
|
||||
val NetworkProfile: Tailcfg.NetworkProfile? = null,
|
||||
val LocalUserID: String,
|
||||
) {
|
||||
fun isEmpty(): Boolean {
|
||||
return ID.isEmpty()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
class Netmap {
|
||||
@Serializable
|
||||
data class NetworkMap(
|
||||
var SelfNode: Tailcfg.Node,
|
||||
var NodeKey: KeyNodePublic,
|
||||
var Peers: List<Tailcfg.Node>? = null,
|
||||
var Expiry: Time,
|
||||
var Domain: String,
|
||||
var UserProfiles: Map<String, Tailcfg.UserProfile>,
|
||||
var TKAEnabled: Boolean,
|
||||
var DNS: Tailcfg.DNSConfig? = null
|
||||
) {
|
||||
// Keys are tailcfg.UserIDs thet get stringified
|
||||
// Helpers
|
||||
fun currentUserProfile(): Tailcfg.UserProfile? {
|
||||
return userProfile(User())
|
||||
}
|
||||
|
||||
fun User(): UserID {
|
||||
return SelfNode.User
|
||||
}
|
||||
|
||||
fun userProfile(id: Long): Tailcfg.UserProfile? {
|
||||
return UserProfiles[id.toString()]
|
||||
}
|
||||
|
||||
fun getPeer(id: StableNodeID): Tailcfg.Node? {
|
||||
if (id == SelfNode.StableID) {
|
||||
return SelfNode
|
||||
}
|
||||
return Peers?.find { it.StableID == id }
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is NetworkMap) return false
|
||||
|
||||
return SelfNode == other.SelfNode &&
|
||||
NodeKey == other.NodeKey &&
|
||||
Peers == other.Peers &&
|
||||
Expiry == other.Expiry &&
|
||||
User() == other.User() &&
|
||||
Domain == other.Domain &&
|
||||
UserProfiles == other.UserProfiles &&
|
||||
TKAEnabled == other.TKAEnabled
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,90 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.model
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.PermissionState
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import com.google.accompanist.permissions.shouldShowRationale
|
||||
import com.tailscale.ipn.R
|
||||
|
||||
object Permissions {
|
||||
/** Permissions to prompt for on MainView. */
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
val prompt: List<Pair<Permission, PermissionState>>
|
||||
@Composable
|
||||
get() {
|
||||
val permissionStates = rememberMultiplePermissionsState(permissions = all.map { it.name })
|
||||
return all.zip(permissionStates.permissions).filter { (permission, 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,193 @@
|
||||
// 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.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 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,104 @@
|
||||
// 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.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
|
||||
|
||||
// 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
|
||||
val state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState)
|
||||
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)
|
||||
|
||||
// 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) {
|
||||
Log.d(TAG, "Starting")
|
||||
if (!::app.isInitialized) {
|
||||
App.get()
|
||||
}
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val mask =
|
||||
NotifyWatchOpt.Netmap.value or
|
||||
NotifyWatchOpt.Prefs.value or
|
||||
NotifyWatchOpt.InitialState.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
Log.d(TAG, "Stopping")
|
||||
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)
|
||||
}
|
||||
}
|
@ -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,388 @@
|
||||
// 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))
|
||||
|
||||
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
|
||||
get() = Color(0xFFD97916) // 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
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
/** 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.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() {
|
||||
val defaults = OutlinedTextFieldDefaults.colors()
|
||||
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,30 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tailscale.ipn.UninitializedApp
|
||||
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
|
||||
|
||||
object AndroidTVUtil {
|
||||
fun isAndroidTV(): Boolean {
|
||||
val pm = UninitializedApp.get().packageManager
|
||||
return (pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION) ||
|
||||
pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
|
||||
}
|
||||
}
|
||||
|
||||
// Applies a letterbox effect iff we're running on Android TV to reduce the overall width
|
||||
// of the UI.
|
||||
fun Modifier.universalFit(): Modifier {
|
||||
return when (isAndroidTV()) {
|
||||
true -> this.padding(horizontal = 150.dp, vertical = 10.dp).clip(RoundedCornerShape(10.dp))
|
||||
false -> this
|
||||
}
|
||||
}
|
@ -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,47 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.theme.titledListItem
|
||||
|
||||
@Composable
|
||||
fun ClipboardValueView(value: String, title: String? = null, subtitle: String? = null) {
|
||||
val localClipboardManager = LocalClipboardManager.current
|
||||
ListItem(
|
||||
colors = MaterialTheme.colorScheme.titledListItem,
|
||||
modifier = Modifier.clickable { localClipboardManager.setText(AnnotatedString(value)) },
|
||||
overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } },
|
||||
headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) },
|
||||
supportingContent =
|
||||
subtitle?.let {
|
||||
{
|
||||
Text(
|
||||
it,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Icon(
|
||||
painterResource(R.drawable.clipboard),
|
||||
stringResource(R.string.copy_to_clipboard),
|
||||
modifier = Modifier.width(24.dp).height(24.dp))
|
||||
})
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.tailscale.ipn.R
|
||||
|
||||
// Convenience wrapper for passing formatted strings to Composables
|
||||
class ComposableStringFormatter(
|
||||
@StringRes val stringRes: Int = R.string.template,
|
||||
private vararg val params: Any
|
||||
) {
|
||||
|
||||
// Convenience constructor for passing a non-formatted string directly
|
||||
constructor(string: String) : this(stringRes = R.string.template, string)
|
||||
|
||||
// Returns the fully formatted string
|
||||
@Composable fun getString(): String = stringResource(id = stringRes, *params)
|
||||
}
|
@ -0,0 +1,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,122 @@
|
||||
// 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.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
|
||||
) {
|
||||
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),
|
||||
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,125 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
import com.tailscale.ipn.mdm.MDMSettings
|
||||
import com.tailscale.ipn.ui.model.Netmap
|
||||
import com.tailscale.ipn.ui.model.Tailcfg
|
||||
import com.tailscale.ipn.ui.model.UserID
|
||||
|
||||
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
|
||||
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) }
|
||||
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 android.util.Log
|
||||
import com.tailscale.ipn.R
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Date
|
||||
|
||||
object TimeUtil {
|
||||
val TAG = "TimeUtil"
|
||||
|
||||
fun keyExpiryFromGoTime(goTime: String?): ComposableStringFormatter {
|
||||
|
||||
val time = goTime ?: return ComposableStringFormatter(R.string.empty)
|
||||
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 -> {
|
||||
Log.e(TAG, "Invalid duration string: $goDuration")
|
||||
return null
|
||||
}
|
||||
}
|
||||
} catch (e: NumberFormatException) {
|
||||
Log.e(TAG, "Invalid duration string: $goDuration")
|
||||
return null
|
||||
}
|
||||
valStr = ""
|
||||
}
|
||||
}
|
||||
return Duration.ofSeconds(duration.toLong())
|
||||
}
|
||||
}
|
@ -0,0 +1,98 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tailscale.ipn.BuildConfig
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.Links
|
||||
import com.tailscale.ipn.ui.theme.logoBackground
|
||||
|
||||
@Composable
|
||||
fun AboutView(backToSettings: BackNavigation) {
|
||||
val localClipboardManager = LocalClipboardManager.current
|
||||
|
||||
Scaffold(topBar = { Header(R.string.about_view_header, onBack = backToSettings) }) { innerPadding
|
||||
->
|
||||
Column(
|
||||
verticalArrangement =
|
||||
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())) {
|
||||
TailscaleLogoView(
|
||||
usesOnBackgroundColors = true,
|
||||
modifier =
|
||||
Modifier.width(100.dp)
|
||||
.height(100.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(MaterialTheme.colorScheme.logoBackground)
|
||||
.padding(25.dp))
|
||||
|
||||
Column(
|
||||
verticalArrangement =
|
||||
Arrangement.spacedBy(space = 2.dp, alignment = Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
stringResource(R.string.about_view_title),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = MaterialTheme.typography.titleLarge.fontSize)
|
||||
Text(
|
||||
modifier =
|
||||
Modifier.clickable {
|
||||
localClipboardManager.setText(AnnotatedString(BuildConfig.VERSION_NAME))
|
||||
},
|
||||
text = "${stringResource(R.string.version)} ${BuildConfig.VERSION_NAME}",
|
||||
fontWeight = MaterialTheme.typography.bodyMedium.fontWeight,
|
||||
fontSize = MaterialTheme.typography.bodyMedium.fontSize)
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
OpenURLButton(stringResource(R.string.acknowledgements), Links.LICENSES_URL)
|
||||
OpenURLButton(stringResource(R.string.privacy_policy), Links.PRIVACY_POLICY_URL)
|
||||
OpenURLButton(stringResource(R.string.terms_of_service), Links.TERMS_URL)
|
||||
}
|
||||
|
||||
Text(
|
||||
stringResource(R.string.about_view_footnotes),
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = MaterialTheme.typography.labelMedium.fontSize,
|
||||
textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AboutPreview() {
|
||||
AboutView({})
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.compose.AsyncImage
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.model.IpnLocal
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
@Composable
|
||||
fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50, action: (() -> Unit)? = null) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size.dp).clip(CircleShape)) {
|
||||
var modifier = Modifier.size((size * .8f).dp)
|
||||
action?.let {
|
||||
modifier =
|
||||
modifier.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = rememberRipple(bounded = false),
|
||||
onClick = action)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = stringResource(R.string.settings_title),
|
||||
modifier = modifier)
|
||||
|
||||
profile?.UserProfile?.ProfilePicURL?.let { url ->
|
||||
AsyncImage(model = url, modifier = Modifier.size((size * 1.2f).dp), contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,94 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.Links
|
||||
import com.tailscale.ipn.ui.theme.defaultTextColor
|
||||
import com.tailscale.ipn.ui.theme.link
|
||||
import com.tailscale.ipn.ui.util.ClipboardValueView
|
||||
import com.tailscale.ipn.ui.util.Lists
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.ui.viewModel.BugReportViewModel
|
||||
|
||||
@Composable
|
||||
fun BugReportView(backToSettings: BackNavigation, model: BugReportViewModel = viewModel()) {
|
||||
val handler = LocalUriHandler.current
|
||||
val bugReportID by model.bugReportID.collectAsState()
|
||||
|
||||
Scaffold(topBar = { Header(R.string.bug_report_title, onBack = backToSettings) }) { innerPadding
|
||||
->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.padding(innerPadding)
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(rememberScrollState())) {
|
||||
Lists.MultilineDescription {
|
||||
ClickableText(
|
||||
text = contactText(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
onClick = { handler.openUri(Links.SUPPORT_URL) })
|
||||
}
|
||||
|
||||
ClipboardValueView(bugReportID, title = stringResource(R.string.bug_report_id))
|
||||
|
||||
Lists.InfoItem(stringResource(id = R.string.bug_report_id_desc))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun contactText(): AnnotatedString {
|
||||
val annotatedString = buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
|
||||
append(stringResource(id = R.string.bug_report_instructions_prefix))
|
||||
}
|
||||
|
||||
pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL)
|
||||
withStyle(
|
||||
style =
|
||||
SpanStyle(
|
||||
color = MaterialTheme.colorScheme.link,
|
||||
textDecoration = TextDecoration.Underline)) {
|
||||
append(stringResource(id = R.string.bug_report_instructions_linktext))
|
||||
}
|
||||
pop()
|
||||
|
||||
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
|
||||
append(stringResource(id = R.string.bug_report_instructions_suffix))
|
||||
}
|
||||
}
|
||||
return annotatedString
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun BugReportPreview() {
|
||||
val vm = BugReportViewModel()
|
||||
vm.bugReportID.set("12345678ABCDEF-12345678ABCDEF")
|
||||
BugReportView({}, vm)
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tailscale.ipn.ui.theme.link
|
||||
|
||||
@Composable
|
||||
fun PrimaryActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
contentPadding = PaddingValues(vertical = 12.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
content = content)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OpenURLButton(title: String, url: String) {
|
||||
val handler = LocalUriHandler.current
|
||||
|
||||
TextButton(onClick = { handler.openUri(url) }) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.link,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,153 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.theme.listItem
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.ui.viewModel.LoginWithAuthKeyViewModel
|
||||
import com.tailscale.ipn.ui.viewModel.LoginWithCustomControlURLViewModel
|
||||
|
||||
data class LoginViewStrings(
|
||||
var title: String,
|
||||
var explanation: String,
|
||||
var inputTitle: String,
|
||||
var placeholder: String,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun LoginWithCustomControlURLView(
|
||||
onNavigateHome: BackNavigation,
|
||||
backToSettings: BackNavigation,
|
||||
viewModel: LoginWithCustomControlURLViewModel = LoginWithCustomControlURLViewModel()
|
||||
) {
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Header(
|
||||
R.string.add_account,
|
||||
onBack = backToSettings,
|
||||
)
|
||||
}) { innerPadding ->
|
||||
val error by viewModel.errorDialog.collectAsState()
|
||||
val strings =
|
||||
LoginViewStrings(
|
||||
title = stringResource(id = R.string.custom_control_menu),
|
||||
explanation = stringResource(id = R.string.custom_control_menu_desc),
|
||||
inputTitle = stringResource(id = R.string.custom_control_url_title),
|
||||
placeholder = stringResource(id = R.string.custom_control_placeholder),
|
||||
)
|
||||
|
||||
error?.let { ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) }) }
|
||||
|
||||
LoginView(
|
||||
innerPadding = innerPadding,
|
||||
strings = strings,
|
||||
onSubmitAction = { viewModel.setControlURL(it, onNavigateHome) })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoginWithAuthKeyView(
|
||||
onNavigateHome: BackNavigation,
|
||||
backToSettings: BackNavigation,
|
||||
viewModel: LoginWithAuthKeyViewModel = LoginWithAuthKeyViewModel()
|
||||
) {
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Header(
|
||||
R.string.add_account,
|
||||
onBack = backToSettings,
|
||||
)
|
||||
}) { innerPadding ->
|
||||
val error by viewModel.errorDialog.collectAsState()
|
||||
val strings =
|
||||
LoginViewStrings(
|
||||
title = stringResource(id = R.string.auth_key_title),
|
||||
explanation = stringResource(id = R.string.auth_key_explanation),
|
||||
inputTitle = stringResource(id = R.string.auth_key_input_title),
|
||||
placeholder = stringResource(id = R.string.auth_key_placeholder),
|
||||
)
|
||||
// Show the error overlay if need be
|
||||
error?.let { ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) }) }
|
||||
|
||||
LoginView(
|
||||
innerPadding = innerPadding,
|
||||
strings = strings,
|
||||
onSubmitAction = { viewModel.setAuthKey(it, onNavigateHome) })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoginView(
|
||||
innerPadding: PaddingValues = PaddingValues(16.dp),
|
||||
strings: LoginViewStrings,
|
||||
onSubmitAction: (String) -> Unit,
|
||||
) {
|
||||
|
||||
var textVal by remember { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.padding(innerPadding)
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)) {
|
||||
ListItem(
|
||||
colors = MaterialTheme.colorScheme.listItem,
|
||||
headlineContent = { Text(text = strings.title) },
|
||||
supportingContent = { Text(text = strings.explanation) })
|
||||
|
||||
ListItem(
|
||||
colors = MaterialTheme.colorScheme.listItem,
|
||||
headlineContent = { Text(text = strings.inputTitle) },
|
||||
supportingContent = {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent),
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
value = textVal,
|
||||
onValueChange = { textVal = it },
|
||||
placeholder = {
|
||||
Text(strings.placeholder, style = MaterialTheme.typography.bodySmall)
|
||||
})
|
||||
})
|
||||
|
||||
ListItem(
|
||||
colors = MaterialTheme.colorScheme.listItem,
|
||||
headlineContent = {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Button(
|
||||
onClick = { onSubmitAction(textVal) },
|
||||
content = { Text(stringResource(id = R.string.add_account_short)) })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -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.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,172 @@
|
||||
// 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.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
|
||||
|
||||
@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 allowLANAccess = Notifier.prefs.collectAsState().value?.ExitNodeAllowLANAccess == true
|
||||
val showRunAsExitNode by MDMSettings.runExitNode.flow.collectAsState()
|
||||
val allowLanAccessMDMDisposition by MDMSettings.exitNodeAllowLANAccess.flow.collectAsState()
|
||||
|
||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||
item(key = "header") {
|
||||
ExitNodeItem(
|
||||
model,
|
||||
ExitNodePickerViewModel.ExitNode(
|
||||
label = stringResource(R.string.none),
|
||||
online = true,
|
||||
selected = !anyActive,
|
||||
))
|
||||
|
||||
if (showRunAsExitNode == ShowHide.Show) {
|
||||
Lists.ItemDivider()
|
||||
RunAsExitNodeItem(nav = nav, viewModel = model)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowLanAccessMDMDisposition.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,
|
||||
) {
|
||||
Box {
|
||||
var modifier: Modifier = Modifier
|
||||
if (node.online) {
|
||||
modifier = modifier.clickable { viewModel.setExitNode(node) }
|
||||
}
|
||||
ListItem(
|
||||
modifier = modifier,
|
||||
colors =
|
||||
if (node.online) MaterialTheme.colorScheme.listItem
|
||||
else MaterialTheme.colorScheme.disabledListItem,
|
||||
headlineContent = {
|
||||
Text(node.city.ifEmpty { node.label }, style = MaterialTheme.typography.bodyMedium)
|
||||
},
|
||||
supportingContent = {
|
||||
if (!node.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 RunAsExitNodeItem(nav: ExitNodePickerNav, viewModel: ExitNodePickerViewModel) {
|
||||
val isRunningExitNode = viewModel.isRunningExitNode.collectAsState().value
|
||||
|
||||
Box {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { nav.onNavigateToRunAsExitNode() },
|
||||
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,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,80 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.theme.AppTheme
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.ui.viewModel.LoginQRViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel()) {
|
||||
Surface(color = MaterialTheme.colorScheme.scrim, modifier = Modifier.fillMaxSize()) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
val image by model.qrCode.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = stringResource(R.string.scan_to_connect_to_your_tailnet),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(200.dp)
|
||||
.background(MaterialTheme.colorScheme.onSurface)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center) {
|
||||
image?.let {
|
||||
Image(
|
||||
bitmap = it,
|
||||
contentDescription = "Scan to login",
|
||||
modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
Button(onClick = onDismiss) { Text(text = stringResource(R.string.dismiss)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun LoginQRViewPreview() {
|
||||
val vm = LoginQRViewModel()
|
||||
vm.qrCode.set(vm.generateQRCode("https://tailscale.com", 200, 0))
|
||||
AppTheme { LoginQRView({}, vm) }
|
||||
}
|
@ -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.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, 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(
|
||||
value.toString(),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
fontWeight = FontWeight.SemiBold)
|
||||
})
|
||||
}
|
@ -0,0 +1,589 @@
|
||||
// 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.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowDropDown
|
||||
import androidx.compose.material.icons.outlined.Clear
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.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.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.mdm.MDMSettings
|
||||
import com.tailscale.ipn.mdm.ShowHide
|
||||
import com.tailscale.ipn.ui.model.Ipn
|
||||
import com.tailscale.ipn.ui.model.IpnLocal
|
||||
import com.tailscale.ipn.ui.model.Netmap
|
||||
import com.tailscale.ipn.ui.model.Permissions
|
||||
import com.tailscale.ipn.ui.model.Tailcfg
|
||||
import com.tailscale.ipn.ui.theme.disabled
|
||||
import com.tailscale.ipn.ui.theme.exitNodeToggleButton
|
||||
import com.tailscale.ipn.ui.theme.listItem
|
||||
import com.tailscale.ipn.ui.theme.minTextSize
|
||||
import com.tailscale.ipn.ui.theme.primaryListItem
|
||||
import com.tailscale.ipn.ui.theme.searchBarColors
|
||||
import com.tailscale.ipn.ui.theme.secondaryButton
|
||||
import com.tailscale.ipn.ui.theme.short
|
||||
import com.tailscale.ipn.ui.theme.surfaceContainerListItem
|
||||
import com.tailscale.ipn.ui.theme.warningListItem
|
||||
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.flag
|
||||
import com.tailscale.ipn.ui.util.itemsWithDividers
|
||||
import com.tailscale.ipn.ui.viewModel.MainViewModel
|
||||
|
||||
// Navigation actions for the MainView
|
||||
data class MainViewNavigation(
|
||||
val onNavigateToSettings: () -> Unit,
|
||||
val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
|
||||
val onNavigateToExitNodes: () -> Unit
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun MainView(
|
||||
loginAtUrl: (String) -> Unit,
|
||||
navigation: MainViewNavigation,
|
||||
viewModel: MainViewModel
|
||||
) {
|
||||
LoadingIndicator.Wrap {
|
||||
Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(paddingInsets),
|
||||
verticalArrangement = Arrangement.Center) {
|
||||
// Assume VPN has been prepared. Whether or not it has been prepared cannot be known
|
||||
// until permission has been granted to prepare the VPN.
|
||||
val isPrepared by viewModel.vpnPrepared.collectAsState(initial = true)
|
||||
val isOn by viewModel.vpnToggleState.collectAsState(initial = false)
|
||||
val state by viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
|
||||
val user by viewModel.loggedInUser.collectAsState(initial = null)
|
||||
val stateVal by viewModel.stateRes.collectAsState(initial = R.string.placeholder)
|
||||
val stateStr = stringResource(id = stateVal)
|
||||
val netmap by viewModel.netmap.collectAsState(initial = null)
|
||||
val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState()
|
||||
val disableToggle by MDMSettings.forceEnabled.flow.collectAsState(initial = true)
|
||||
val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false)
|
||||
|
||||
ListItem(
|
||||
colors = MaterialTheme.colorScheme.surfaceContainerListItem,
|
||||
leadingContent = {
|
||||
TintedSwitch(
|
||||
onCheckedChange = {
|
||||
if (!disableToggle) {
|
||||
viewModel.toggleVpn()
|
||||
}
|
||||
},
|
||||
enabled = !disableToggle,
|
||||
checked = isOn)
|
||||
},
|
||||
headlineContent = {
|
||||
user?.NetworkProfile?.DomainName?.let { domain ->
|
||||
AutoResizingText(
|
||||
text = domain,
|
||||
style = MaterialTheme.typography.titleMedium.short,
|
||||
minFontSize = MaterialTheme.typography.minTextSize,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
},
|
||||
supportingContent = {
|
||||
Text(text = stateStr, style = MaterialTheme.typography.bodyMedium.short)
|
||||
},
|
||||
trailingContent = {
|
||||
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
|
||||
when (user) {
|
||||
null -> SettingsButton { navigation.onNavigateToSettings() }
|
||||
else ->
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier =
|
||||
Modifier.size(42.dp).clip(CircleShape).clickable {
|
||||
navigation.onNavigateToSettings()
|
||||
}) {
|
||||
Avatar(profile = user, size = 36) {
|
||||
navigation.onNavigateToSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
when (state) {
|
||||
Ipn.State.Running -> {
|
||||
|
||||
PromptPermissionsIfNecessary()
|
||||
|
||||
if (showKeyExpiry) {
|
||||
ExpiryNotification(netmap = netmap, action = { viewModel.login() })
|
||||
}
|
||||
|
||||
if (showExitNodePicker == 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,
|
||||
user,
|
||||
{ viewModel.toggleVpn() },
|
||||
{ viewModel.login() },
|
||||
loginAtUrl,
|
||||
netmap?.SelfNode,
|
||||
{ viewModel.showVPNPermissionLauncherIfUnauthorized() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
|
||||
val maybePrefs by viewModel.prefs.collectAsState()
|
||||
val netmap by viewModel.netmap.collectAsState()
|
||||
|
||||
// There's nothing to render if we haven't loaded the prefs yet
|
||||
val prefs = maybePrefs ?: return
|
||||
|
||||
// The activeExitNode is the source of truth. The selectedExitNode is only relevant if we
|
||||
// don't have an active node.
|
||||
val chosenExitNodeId = prefs.activeExitNodeID ?: prefs.selectedExitNodeID
|
||||
|
||||
val exitNodePeer = chosenExitNodeId?.let { id -> netmap?.Peers?.find { it.StableID == id } }
|
||||
val location = exitNodePeer?.Hostinfo?.Location
|
||||
val name = exitNodePeer?.ComputedName
|
||||
|
||||
// We're connected to an exit node if we found an active peer for the *active* exit node
|
||||
val activeAndRunning = (exitNodePeer != null) && !prefs.activeExitNodeID.isNullOrEmpty()
|
||||
|
||||
// (jonathan) TODO: We will block the "enable/disable" button for an exit node for which we cannot
|
||||
// find a peer on purpose and render the "No Exit Node" state, however, that should
|
||||
// eventually show up in the UI as an error case so the user knows to pick an available node.
|
||||
|
||||
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) {
|
||||
Box(
|
||||
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 =
|
||||
if (activeAndRunning) MaterialTheme.colorScheme.primaryListItem
|
||||
else ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
overlineContent = {
|
||||
Text(
|
||||
stringResource(R.string.exit_node),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text =
|
||||
location?.let { "${it.CountryCode?.flag()} ${it.Country}: ${it.City}" }
|
||||
?: name
|
||||
?: stringResource(id = R.string.none),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
tint =
|
||||
if (activeAndRunning)
|
||||
MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
if (exitNodePeer != null) {
|
||||
Button(
|
||||
colors =
|
||||
if (prefs.activeExitNodeID.isNullOrEmpty())
|
||||
MaterialTheme.colorScheme.exitNodeToggleButton
|
||||
else MaterialTheme.colorScheme.secondaryButton,
|
||||
onClick = { viewModel.toggleExitNode() }) {
|
||||
Text(
|
||||
if (prefs.activeExitNodeID.isNullOrEmpty())
|
||||
stringResource(id = R.string.enable)
|
||||
else stringResource(id = R.string.disable))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
user: IpnLocal.LoginProfile?,
|
||||
connectAction: () -> Unit,
|
||||
loginAction: () -> Unit,
|
||||
loginAtUrlAction: (String) -> Unit,
|
||||
selfNode: Tailcfg.Node?,
|
||||
showVPNPermissionLauncherIfUnauthorized: () -> Unit
|
||||
) {
|
||||
LaunchedEffect(isPrepared) {
|
||||
if (!isPrepared) {
|
||||
showVPNPermissionLauncherIfUnauthorized()
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
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) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surface)) {
|
||||
OutlinedTextField(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 0.dp)
|
||||
.onFocusChanged { isFocussed = it.isFocused },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
colors = MaterialTheme.colorScheme.searchBarColors,
|
||||
leadingIcon = { Icon(imageVector = Icons.Outlined.Search, contentDescription = "search") },
|
||||
trailingIcon = {
|
||||
if (isFocussed) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
onSearch("")
|
||||
}) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (searchTermStr.isEmpty()) Icons.Outlined.Close
|
||||
else Icons.Outlined.Clear,
|
||||
contentDescription = "clear search",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(id = R.string.search),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1)
|
||||
},
|
||||
value = searchTermStr,
|
||||
onValueChange = { onSearch(it) })
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(color = MaterialTheme.colorScheme.surface)) {
|
||||
if (showNoResults) {
|
||||
item {
|
||||
Spacer(
|
||||
Modifier.height(16.dp)
|
||||
.fillMaxSize()
|
||||
.background(color = MaterialTheme.colorScheme.surface))
|
||||
|
||||
Lists.LargeTitle(
|
||||
stringResource(id = R.string.no_results),
|
||||
bottomPadding = 8.dp,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Light)
|
||||
}
|
||||
}
|
||||
|
||||
var first = true
|
||||
peerList.forEach { peerSet ->
|
||||
if (!first) {
|
||||
item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() }
|
||||
}
|
||||
first = false
|
||||
|
||||
stickyHeader {
|
||||
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,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer ->
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { onNavigateToPeerDetails(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)
|
||||
}
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = peer.Addresses?.first()?.split("/")?.first() ?: "",
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
lineHeight = MaterialTheme.typography.titleMedium.lineHeight))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) {
|
||||
if (netmap == null) return
|
||||
|
||||
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
|
||||
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
|
||||
.fillMaxWidth()) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { action() },
|
||||
colors = MaterialTheme.colorScheme.warningListItem,
|
||||
headlineContent = {
|
||||
Text(
|
||||
netmap.SelfNode.expiryLabel(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
stringResource(id = R.string.keyExpiryExplainer),
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun PromptPermissionsIfNecessary() {
|
||||
Permissions.prompt.forEach { (permission, state) ->
|
||||
ErrorDialog(
|
||||
title = permission.title,
|
||||
message = permission.description,
|
||||
buttonText = R.string._continue) {
|
||||
state.launchPermissionRequest()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun MainViewPreview() {
|
||||
val vm = MainViewModel()
|
||||
MainView(
|
||||
{},
|
||||
MainViewNavigation(
|
||||
onNavigateToSettings = {}, onNavigateToPeerDetails = {}, onNavigateToExitNodes = {}),
|
||||
vm)
|
||||
}
|
@ -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.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
|
||||
|
||||
@Composable
|
||||
fun ManagedByView(backToSettings: BackNavigation, model: IpnViewModel = viewModel()) {
|
||||
Scaffold(topBar = { Header(R.string.managed_by, onBack = backToSettings) }) { innerPadding ->
|
||||
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
|
||||
val managedByCaption = MDMSettings.managedByCaption.flow.collectAsState().value
|
||||
val managedByURL = MDMSettings.managedByURL.flow.collectAsState().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,121 @@
|
||||
// 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.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.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.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.Lists
|
||||
import com.tailscale.ipn.ui.util.itemsWithDividers
|
||||
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
|
||||
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModelFactory
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PeerDetails(
|
||||
backToHome: BackNavigation,
|
||||
nodeId: String,
|
||||
model: PeerDetailsViewModel =
|
||||
viewModel(factory = PeerDetailsViewModelFactory(nodeId, LocalContext.current.filesDir))
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
},
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddressRow(address: String, type: String) {
|
||||
val localClipboardManager = LocalClipboardManager.current
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { localClipboardManager.setText(AnnotatedString(address)) },
|
||||
colors = MaterialTheme.colorScheme.listItem,
|
||||
headlineContent = { Text(text = address) },
|
||||
supportingContent = { Text(text = type) },
|
||||
trailingContent = {
|
||||
// TODO: there is some overlap with other uses of clipboard, DRY
|
||||
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,123 @@
|
||||
// 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.RunExitNodeViewModel
|
||||
import com.tailscale.ipn.ui.viewModel.RunExitNodeViewModelFactory
|
||||
|
||||
@Composable
|
||||
fun RunExitNodeView(
|
||||
nav: ExitNodePickerNav,
|
||||
model: RunExitNodeViewModel = viewModel(factory = RunExitNodeViewModelFactory())
|
||||
) {
|
||||
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,202 @@
|
||||
// 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.Lists
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.ui.viewModel.SettingsNav
|
||||
import com.tailscale.ipn.ui.viewModel.SettingsViewModel
|
||||
|
||||
@Composable
|
||||
fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel()) {
|
||||
val handler = LocalUriHandler.current
|
||||
|
||||
val user by viewModel.loggedInUser.collectAsState()
|
||||
val isAdmin by viewModel.isAdmin.collectAsState()
|
||||
val managedByOrganization by viewModel.managedByOrganization.collectAsState()
|
||||
val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState()
|
||||
val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState()
|
||||
val isVPNPrepared by viewModel.vpnPrepared.collectAsState()
|
||||
val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Header(titleRes = R.string.settings_title, onBack = settingsNav.onNavigateBackHome)
|
||||
}) { innerPadding ->
|
||||
Column(modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) {
|
||||
if (isVPNPrepared){
|
||||
UserView(
|
||||
profile = user,
|
||||
actionState = UserActionState.NAV,
|
||||
onClick = settingsNav.onNavigateToUserSwitcher)
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
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)
|
||||
|
||||
if (showTailnetLock == ShowHide.Show) {
|
||||
Lists.ItemDivider()
|
||||
Setting.Text(
|
||||
R.string.tailnet_lock,
|
||||
subtitle =
|
||||
tailnetLockEnabled?.let {
|
||||
stringResource(if (it) R.string.enabled else R.string.disabled)
|
||||
},
|
||||
onClick = settingsNav.onNavigateToTailnetLock)
|
||||
}
|
||||
|
||||
Lists.ItemDivider()
|
||||
Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions)
|
||||
|
||||
managedByOrganization?.let {
|
||||
Lists.ItemDivider()
|
||||
Setting.Text(
|
||||
title = stringResource(R.string.managed_by_orgName, it),
|
||||
onClick = settingsNav.onNavigateToManagedBy)
|
||||
}
|
||||
|
||||
Lists.SectionDivider()
|
||||
Setting.Text(R.string.bug_report, onClick = settingsNav.onNavigateToBugReport)
|
||||
|
||||
Lists.ItemDivider()
|
||||
Setting.Text(
|
||||
R.string.about_tailscale,
|
||||
subtitle = "${stringResource(id = R.string.version)} ${BuildConfig.VERSION_NAME}",
|
||||
onClick = settingsNav.onNavigateToAbout)
|
||||
|
||||
// TODO: put a heading for the debug section
|
||||
if (BuildConfig.DEBUG) {
|
||||
Lists.SectionDivider()
|
||||
Lists.MutedHeader(text = stringResource(R.string.internal_debug_options))
|
||||
Setting.Text(R.string.mdm_settings, onClick = settingsNav.onNavigateToMDMSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object Setting {
|
||||
@Composable
|
||||
fun Text(
|
||||
titleRes: Int = 0,
|
||||
title: String? = null,
|
||||
subtitle: String? = null,
|
||||
destructive: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
var modifier: Modifier = Modifier
|
||||
if (enabled) {
|
||||
onClick?.let { modifier = modifier.clickable(onClick = it) }
|
||||
}
|
||||
ListItem(
|
||||
modifier = modifier,
|
||||
colors = MaterialTheme.colorScheme.listItem,
|
||||
headlineContent = {
|
||||
Text(
|
||||
title ?: stringResource(titleRes),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified)
|
||||
},
|
||||
supportingContent =
|
||||
subtitle?.let {
|
||||
{
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Switch(
|
||||
titleRes: Int = 0,
|
||||
title: String? = null,
|
||||
isOn: Boolean,
|
||||
enabled: Boolean = true,
|
||||
onToggle: (Boolean) -> Unit = {}
|
||||
) {
|
||||
ListItem(
|
||||
colors = MaterialTheme.colorScheme.listItem,
|
||||
headlineContent = {
|
||||
Text(
|
||||
title ?: stringResource(titleRes),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
TintedSwitch(checked = isOn, onCheckedChange = onToggle, enabled = enabled)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
|
||||
val adminStr = buildAnnotatedString {
|
||||
append(stringResource(id = R.string.settings_admin_prefix))
|
||||
|
||||
pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL)
|
||||
withStyle(
|
||||
style =
|
||||
SpanStyle(
|
||||
color = MaterialTheme.colorScheme.link,
|
||||
textDecoration = TextDecoration.Underline)) {
|
||||
append(stringResource(id = R.string.settings_admin_link))
|
||||
}
|
||||
}
|
||||
|
||||
Lists.InfoItem(adminStr, onClick = onNavigateToAdminConsole)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SettingsPreview() {
|
||||
val vm = SettingsViewModel()
|
||||
vm.corpDNSEnabled.set(true)
|
||||
vm.tailNetLockEnabled.set(true)
|
||||
vm.isAdmin.set(true)
|
||||
vm.managedByOrganization.set("Tails and Scales Inc.")
|
||||
SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
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
|
||||
|
||||
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
|
||||
) {
|
||||
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) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BackArrow(action: () -> Unit) {
|
||||
Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Go back to the previous screen",
|
||||
modifier =
|
||||
Modifier.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = rememberRipple(bounded = false),
|
||||
onClick = { action() }))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CheckedIndicator() {
|
||||
Icon(Icons.Default.CheckCircle, null, tint = ts_color_light_blue)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleActivityIndicator(size: Int = 32) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.width(size.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActivityIndicator(progress: Double, size: Int = 32) {
|
||||
LinearProgressIndicator(
|
||||
progress = { progress.toFloat() },
|
||||
modifier = Modifier.width(size.dp),
|
||||
color = ts_color_light_blue,
|
||||
trackColor = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
}
|
@ -0,0 +1,194 @@
|
||||
// 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.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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.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(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)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
|
||||
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
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
|
||||
Text(stringResource(R.string.my_devices), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
|
||||
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 = 8.dp).fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
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 = 8.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.titleMedium)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@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,130 @@
|
||||
// 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.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.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.Links
|
||||
import com.tailscale.ipn.ui.theme.defaultTextColor
|
||||
import com.tailscale.ipn.ui.theme.link
|
||||
import com.tailscale.ipn.ui.util.ClipboardValueView
|
||||
import com.tailscale.ipn.ui.util.Lists
|
||||
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.ui.viewModel.TailnetLockSetupViewModel
|
||||
import com.tailscale.ipn.ui.viewModel.TailnetLockSetupViewModelFactory
|
||||
|
||||
@Composable
|
||||
fun TailnetLockSetupView(
|
||||
backToSettings: BackNavigation,
|
||||
model: TailnetLockSetupViewModel = viewModel(factory = TailnetLockSetupViewModelFactory())
|
||||
) {
|
||||
val statusItems by model.statusItems.collectAsState()
|
||||
val nodeKey by model.nodeKey.collectAsState()
|
||||
val tailnetLockKey by model.tailnetLockKey.collectAsState()
|
||||
val tailnetLockTlPubKey = tailnetLockKey.replace("nlpub", "tlpub")
|
||||
|
||||
Scaffold(topBar = { Header(R.string.tailnet_lock, onBack = backToSettings) }) { innerPadding ->
|
||||
LoadingIndicator.Wrap {
|
||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||
item(key = "header") { ExplainerView() }
|
||||
|
||||
items(items = statusItems, key = { "status_${it.title}" }) { statusItem ->
|
||||
Lists.ItemDivider()
|
||||
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(
|
||||
painter = painterResource(id = statusItem.icon),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
},
|
||||
headlineContent = { Text(stringResource(statusItem.title)) })
|
||||
}
|
||||
|
||||
item(key = "nodeKey") {
|
||||
Lists.SectionDivider()
|
||||
|
||||
ClipboardValueView(
|
||||
value = nodeKey,
|
||||
title = stringResource(R.string.node_key),
|
||||
subtitle = stringResource(R.string.node_key_explainer))
|
||||
}
|
||||
|
||||
item(key = "tailnetLockKey") {
|
||||
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 {
|
||||
val annotatedString = 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()
|
||||
}
|
||||
return annotatedString
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun TailnetLockSetupViewPreview() {
|
||||
val vm = TailnetLockSetupViewModel()
|
||||
vm.nodeKey.set("8BADF00D-EA7-1337-DEAD-BEEF")
|
||||
vm.tailnetLockKey.set("C0FFEE-CAFE-50DA")
|
||||
TailnetLockSetupView(backToSettings = {}, vm)
|
||||
}
|
@ -0,0 +1,154 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.tailscale.ipn.ui.theme.onBackgroundLogoDotDisabled
|
||||
import com.tailscale.ipn.ui.theme.onBackgroundLogoDotEnabled
|
||||
import com.tailscale.ipn.ui.theme.standaloneLogoDotDisabled
|
||||
import com.tailscale.ipn.ui.theme.standaloneLogoDotEnabled
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlin.concurrent.timer
|
||||
|
||||
// DotsMatrix represents the state of the progress indicator.
|
||||
typealias DotsMatrix = List<List<Boolean>>
|
||||
|
||||
// The initial DotsMatrix that represents the Tailscale logo (T-shaped).
|
||||
val logoDotsMatrix: DotsMatrix =
|
||||
listOf(
|
||||
listOf(false, false, false),
|
||||
listOf(true, true, true),
|
||||
listOf(false, true, false),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TailscaleLogoView(
|
||||
animated: Boolean = false,
|
||||
usesOnBackgroundColors: Boolean = false,
|
||||
modifier: Modifier
|
||||
) {
|
||||
|
||||
val primaryColor: Color =
|
||||
if (usesOnBackgroundColors) {
|
||||
MaterialTheme.colorScheme.onBackgroundLogoDotEnabled
|
||||
} else {
|
||||
MaterialTheme.colorScheme.standaloneLogoDotEnabled
|
||||
}
|
||||
val secondaryColor: Color =
|
||||
if (usesOnBackgroundColors) {
|
||||
MaterialTheme.colorScheme.onBackgroundLogoDotDisabled
|
||||
} else {
|
||||
MaterialTheme.colorScheme.standaloneLogoDotDisabled
|
||||
}
|
||||
|
||||
val currentDotsMatrix: StateFlow<DotsMatrix> = MutableStateFlow(logoDotsMatrix)
|
||||
var currentDotsMatrixIndex = 0
|
||||
fun advanceToNextMatrix() {
|
||||
currentDotsMatrixIndex = (currentDotsMatrixIndex + 1) % gameOfLife.size
|
||||
val newMatrix =
|
||||
if (animated) {
|
||||
gameOfLife[currentDotsMatrixIndex]
|
||||
} else {
|
||||
logoDotsMatrix
|
||||
}
|
||||
currentDotsMatrix.set(newMatrix)
|
||||
}
|
||||
|
||||
if (animated) {
|
||||
timer(period = 300L) { advanceToNextMatrix() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EnabledDot(modifier: Modifier) {
|
||||
Canvas(modifier = modifier, onDraw = { drawCircle(primaryColor) })
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisabledDot(modifier: Modifier) {
|
||||
Canvas(modifier = modifier, onDraw = { drawCircle(secondaryColor) })
|
||||
}
|
||||
|
||||
BoxWithConstraints(modifier) {
|
||||
val currentMatrix = currentDotsMatrix.collectAsState().value
|
||||
Column(verticalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
|
||||
for (y in 0..2) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(this@BoxWithConstraints.maxWidth.div(8))) {
|
||||
for (x in 0..2) {
|
||||
if (currentMatrix[y][x]) {
|
||||
EnabledDot(Modifier.size(this@BoxWithConstraints.maxWidth.div(4)))
|
||||
} else {
|
||||
DisabledDot(Modifier.size(this@BoxWithConstraints.maxWidth.div(4)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val gameOfLife: List<DotsMatrix> =
|
||||
listOf(
|
||||
listOf(
|
||||
listOf(false, true, true),
|
||||
listOf(true, false, true),
|
||||
listOf(false, false, true),
|
||||
),
|
||||
listOf(
|
||||
listOf(false, true, true),
|
||||
listOf(false, false, true),
|
||||
listOf(false, true, false),
|
||||
),
|
||||
listOf(
|
||||
listOf(false, true, true),
|
||||
listOf(false, false, false),
|
||||
listOf(false, false, true),
|
||||
),
|
||||
listOf(
|
||||
listOf(false, false, true),
|
||||
listOf(false, true, false),
|
||||
listOf(false, false, false),
|
||||
),
|
||||
listOf(
|
||||
listOf(false, true, false),
|
||||
listOf(false, false, false),
|
||||
listOf(false, false, false),
|
||||
),
|
||||
listOf(
|
||||
listOf(false, false, false),
|
||||
listOf(false, false, true),
|
||||
listOf(false, false, false),
|
||||
),
|
||||
listOf(
|
||||
listOf(false, false, false),
|
||||
listOf(false, false, false),
|
||||
listOf(false, false, false),
|
||||
),
|
||||
listOf(
|
||||
listOf(false, false, true),
|
||||
listOf(false, false, false),
|
||||
listOf(false, false, false),
|
||||
),
|
||||
listOf(
|
||||
listOf(false, false, false),
|
||||
listOf(false, false, false),
|
||||
listOf(true, false, false),
|
||||
),
|
||||
listOf(listOf(false, false, false), listOf(false, false, false), listOf(true, true, false)),
|
||||
listOf(listOf(false, false, false), listOf(true, false, false), listOf(true, true, false)),
|
||||
listOf(listOf(false, false, false), listOf(true, true, false), listOf(false, true, false)),
|
||||
listOf(listOf(false, false, false), listOf(true, true, false), listOf(false, true, true)),
|
||||
listOf(listOf(false, false, false), listOf(true, true, true), listOf(false, false, true)),
|
||||
listOf(listOf(false, true, false), listOf(true, true, true), listOf(true, false, true)))
|
@ -0,0 +1,12 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
fun TintedSwitch(checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?, enabled: Boolean = true) {
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
|
||||
}
|
@ -0,0 +1,193 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.util.Lists
|
||||
import com.tailscale.ipn.ui.util.itemsWithDividers
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.ui.viewModel.UserSwitcherViewModel
|
||||
|
||||
data class UserSwitcherNav(
|
||||
val backToSettings: BackNavigation,
|
||||
val onNavigateHome: () -> Unit,
|
||||
val onNavigateCustomControl: () -> Unit,
|
||||
val onNavigateToAuthKey: () -> Unit
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = viewModel()) {
|
||||
|
||||
val users by viewModel.loginProfiles.collectAsState()
|
||||
val currentUser by viewModel.loggedInUser.collectAsState()
|
||||
val showHeaderMenu by viewModel.showHeaderMenu.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Header(
|
||||
R.string.accounts,
|
||||
onBack = nav.backToSettings,
|
||||
actions = {
|
||||
Row {
|
||||
FusMenu(
|
||||
viewModel = viewModel,
|
||||
onAuthKeyClick = nav.onNavigateToAuthKey,
|
||||
onCustomClick = nav.onNavigateCustomControl)
|
||||
IconButton(onClick = { viewModel.showHeaderMenu.set(!showHeaderMenu) }) {
|
||||
Icon(Icons.Default.MoreVert, "menu")
|
||||
}
|
||||
}
|
||||
})
|
||||
}) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val showErrorDialog by viewModel.errorDialog.collectAsState()
|
||||
|
||||
// Show the error overlay if need be
|
||||
showErrorDialog?.let {
|
||||
ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) })
|
||||
}
|
||||
|
||||
// When switch is invoked, this stores the ID of the user we're trying to switch to
|
||||
// so we can decorate it with a spinner. The actual logged in user will not change
|
||||
// until
|
||||
// we get our first netmap update back with the new userId for SelfNode.
|
||||
// (jonathan) TODO: This user switch is not immediate. We may need to represent the
|
||||
// "switching users" state globally (if ipnState is insufficient)
|
||||
val nextUserId = remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LazyColumn {
|
||||
itemsWithDividers(users ?: emptyList()) { user ->
|
||||
if (user.ID == currentUser?.ID) {
|
||||
UserView(profile = user, actionState = UserActionState.CURRENT)
|
||||
} else {
|
||||
val state =
|
||||
if (user.ID == nextUserId.value) UserActionState.SWITCHING
|
||||
else UserActionState.NONE
|
||||
UserView(
|
||||
profile = user,
|
||||
actionState = state,
|
||||
onClick = {
|
||||
nextUserId.value = user.ID
|
||||
viewModel.switchProfile(user) {
|
||||
if (it.isFailure) {
|
||||
viewModel.errorDialog.set(ErrorDialogType.LOGOUT_FAILED)
|
||||
nextUserId.value = null
|
||||
} else {
|
||||
nav.onNavigateHome()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Lists.SectionDivider()
|
||||
Setting.Text(R.string.add_account) {
|
||||
viewModel.addProfile {
|
||||
if (it.isFailure) {
|
||||
viewModel.errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Lists.ItemDivider()
|
||||
Setting.Text(R.string.reauthenticate) { viewModel.login() }
|
||||
|
||||
if (currentUser != null) {
|
||||
Lists.ItemDivider()
|
||||
Setting.Text(
|
||||
R.string.log_out,
|
||||
destructive = true,
|
||||
onClick = {
|
||||
viewModel.logout {
|
||||
it.onSuccess { nav.onNavigateHome() }
|
||||
.onFailure {
|
||||
viewModel.errorDialog.set(ErrorDialogType.LOGOUT_FAILED)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FusMenu(
|
||||
onCustomClick: () -> Unit,
|
||||
onAuthKeyClick: () -> Unit,
|
||||
viewModel: UserSwitcherViewModel
|
||||
) {
|
||||
val expanded by viewModel.showHeaderMenu.collectAsState()
|
||||
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { viewModel.showHeaderMenu.set(false) },
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)) {
|
||||
MenuItem(
|
||||
onClick = {
|
||||
onCustomClick()
|
||||
viewModel.showHeaderMenu.set(false)
|
||||
},
|
||||
text = stringResource(id = R.string.custom_control_menu))
|
||||
MenuItem(
|
||||
onClick = {
|
||||
onAuthKeyClick()
|
||||
viewModel.showHeaderMenu.set(false)
|
||||
},
|
||||
text = stringResource(id = R.string.auth_key_menu))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MenuItem(text: String, onClick: () -> Unit) {
|
||||
DropdownMenuItem(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 0.dp),
|
||||
onClick = onClick,
|
||||
text = { Text(text = text) })
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun UserSwitcherViewPreview() {
|
||||
val vm = UserSwitcherViewModel()
|
||||
val nav =
|
||||
UserSwitcherNav(
|
||||
backToSettings = {},
|
||||
onNavigateHome = {},
|
||||
onNavigateCustomControl = {},
|
||||
onNavigateToAuthKey = {})
|
||||
UserSwitcherView(nav, vm)
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemColors
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.model.IpnLocal
|
||||
import com.tailscale.ipn.ui.theme.minTextSize
|
||||
import com.tailscale.ipn.ui.theme.short
|
||||
import com.tailscale.ipn.ui.util.AutoResizingText
|
||||
|
||||
// Used to decorate UserViews.
|
||||
// NONE indicates no decoration
|
||||
// CURRENT indicates the user is the current user and will be "checked"
|
||||
// SWITCHING indicates the user is being switched to and will be "loading"
|
||||
// NAV will show a chevron
|
||||
enum class UserActionState {
|
||||
CURRENT,
|
||||
SWITCHING,
|
||||
NAV,
|
||||
NONE
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserView(
|
||||
profile: IpnLocal.LoginProfile?,
|
||||
onClick: (() -> Unit)? = null,
|
||||
colors: ListItemColors = ListItemDefaults.colors(),
|
||||
actionState: UserActionState = UserActionState.NONE,
|
||||
) {
|
||||
Box {
|
||||
var modifier: Modifier = Modifier
|
||||
onClick?.let { modifier = modifier.clickable { it() } }
|
||||
profile?.let {
|
||||
ListItem(
|
||||
modifier = modifier,
|
||||
colors = colors,
|
||||
leadingContent = { Avatar(profile = profile, size = 36) },
|
||||
headlineContent = {
|
||||
AutoResizingText(
|
||||
text = profile.UserProfile.DisplayName,
|
||||
style = MaterialTheme.typography.titleMedium.short,
|
||||
minFontSize = MaterialTheme.typography.minTextSize,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
supportingContent = {
|
||||
AutoResizingText(
|
||||
text = profile.NetworkProfile?.DomainName ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium.short,
|
||||
minFontSize = MaterialTheme.typography.minTextSize,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
trailingContent = {
|
||||
when (actionState) {
|
||||
UserActionState.CURRENT -> CheckedIndicator()
|
||||
UserActionState.SWITCHING -> SimpleActivityIndicator(size = 26)
|
||||
UserActionState.NAV ->
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight, null, Modifier.offset(x = 6.dp))
|
||||
UserActionState.NONE -> Unit
|
||||
}
|
||||
})
|
||||
}
|
||||
?: run {
|
||||
ListItem(
|
||||
modifier = modifier,
|
||||
colors = colors,
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = stringResource(id = R.string.accounts),
|
||||
style = MaterialTheme.typography.titleMedium)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tailscale.ipn.ui.localapi.Client
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class BugReportViewModel : ViewModel() {
|
||||
val bugReportID: StateFlow<String> = MutableStateFlow("")
|
||||
|
||||
init {
|
||||
Client(viewModelScope).bugReportId { result ->
|
||||
result
|
||||
.onSuccess { bugReportID.set(it.trim()) }
|
||||
.onFailure { bugReportID.set("(Error fetching ID)") }
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,52 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.ui.view.ErrorDialogType
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
const val AUTH_KEY_LENGTH = 16
|
||||
|
||||
open class CustomLoginViewModel : IpnViewModel() {
|
||||
val errorDialog: StateFlow<ErrorDialogType?> = MutableStateFlow(null)
|
||||
}
|
||||
|
||||
class LoginWithAuthKeyViewModel : CustomLoginViewModel() {
|
||||
// Sets the auth key and invokes the login flow
|
||||
fun setAuthKey(authKey: String, onSuccess: () -> Unit) {
|
||||
// The most basic of checks for auth key syntax
|
||||
if (authKey.isEmpty()) {
|
||||
errorDialog.set(ErrorDialogType.INVALID_AUTH_KEY)
|
||||
return
|
||||
}
|
||||
loginWithAuthKey(authKey) {
|
||||
it.onFailure { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) }
|
||||
it.onSuccess { onSuccess() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LoginWithCustomControlURLViewModel : CustomLoginViewModel() {
|
||||
// Sets the custom control URL and invokes the login flow
|
||||
fun setControlURL(urlStr: String, onSuccess: () -> Unit) {
|
||||
// Some basic checks that the entered URL is "reasonable". The underlying
|
||||
// localAPIClient will use the default server if we give it a broken URL,
|
||||
// but we can make sure we can construct a URL from the input string and
|
||||
// ensure it has an http/https scheme
|
||||
when (urlStr.startsWith("http") && urlStr.contains("://") && urlStr.length > 7) {
|
||||
false -> {
|
||||
errorDialog.set(ErrorDialogType.INVALID_CUSTOM_URL)
|
||||
return
|
||||
}
|
||||
true -> {
|
||||
loginWithCustomControlURL(urlStr) {
|
||||
it.onFailure { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) }
|
||||
it.onSuccess { onSuccess() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,93 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import android.util.Log
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.localapi.Client
|
||||
import com.tailscale.ipn.ui.model.Ipn
|
||||
import com.tailscale.ipn.ui.model.Tailcfg
|
||||
import com.tailscale.ipn.ui.notifier.Notifier
|
||||
import com.tailscale.ipn.ui.theme.off
|
||||
import com.tailscale.ipn.ui.theme.success
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class DNSSettingsViewModelFactory() : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return DNSSettingsViewModel() as T
|
||||
}
|
||||
}
|
||||
|
||||
class DNSSettingsViewModel() : IpnViewModel() {
|
||||
val enablementState: StateFlow<DNSEnablementState> =
|
||||
MutableStateFlow(DNSEnablementState.NOT_RUNNING)
|
||||
val dnsConfig: StateFlow<Tailcfg.DNSConfig?> = MutableStateFlow(null)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
Notifier.netmap
|
||||
.combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) }
|
||||
.stateIn(viewModelScope)
|
||||
.collect { (netmap, prefs) ->
|
||||
Log.d("DNSSettingsViewModel", "prefs: CorpDNS=" + prefs?.CorpDNS.toString())
|
||||
prefs?.let {
|
||||
if (it.CorpDNS) {
|
||||
enablementState.set(DNSEnablementState.ENABLED)
|
||||
} else {
|
||||
enablementState.set(DNSEnablementState.DISABLED)
|
||||
}
|
||||
} ?: run { enablementState.set(DNSEnablementState.NOT_RUNNING) }
|
||||
netmap?.let { dnsConfig.set(netmap.DNS) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleCorpDNS(callback: (Result<Ipn.Prefs>) -> Unit) {
|
||||
val prefs =
|
||||
Notifier.prefs.value
|
||||
?: run {
|
||||
callback(Result.failure(Exception("no prefs")))
|
||||
return@toggleCorpDNS
|
||||
}
|
||||
|
||||
val prefsOut = Ipn.MaskedPrefs()
|
||||
prefsOut.CorpDNS = !prefs.CorpDNS
|
||||
Client(viewModelScope).editPrefs(prefsOut, callback)
|
||||
}
|
||||
}
|
||||
|
||||
enum class DNSEnablementState(
|
||||
@StringRes val title: Int,
|
||||
@StringRes val caption: Int,
|
||||
val symbolDrawable: Int,
|
||||
val tint: @Composable () -> Color
|
||||
) {
|
||||
NOT_RUNNING(
|
||||
R.string.not_running,
|
||||
R.string.tailscale_is_not_running_this_device_is_using_the_system_dns_resolver,
|
||||
R.drawable.xmark_circle,
|
||||
{ MaterialTheme.colorScheme.off }),
|
||||
ENABLED(
|
||||
R.string.using_tailscale_dns,
|
||||
R.string.this_device_is_using_tailscale_to_resolve_dns_names,
|
||||
R.drawable.check_circle,
|
||||
{ MaterialTheme.colorScheme.success }),
|
||||
DISABLED(
|
||||
R.string.not_using_tailscale_dns,
|
||||
R.string.this_device_is_using_the_system_dns_resolver,
|
||||
R.drawable.xmark_circle,
|
||||
{ MaterialTheme.colorScheme.error })
|
||||
}
|
@ -0,0 +1,165 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tailscale.ipn.ui.localapi.Client
|
||||
import com.tailscale.ipn.ui.model.Ipn
|
||||
import com.tailscale.ipn.ui.model.StableNodeID
|
||||
import com.tailscale.ipn.ui.notifier.Notifier
|
||||
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.TreeMap
|
||||
|
||||
data class ExitNodePickerNav(
|
||||
val onNavigateBackHome: () -> Unit,
|
||||
val onNavigateBackToExitNodes: () -> Unit,
|
||||
val onNavigateToMullvad: () -> Unit,
|
||||
val onNavigateBackToMullvad: () -> Unit,
|
||||
val onNavigateToMullvadCountry: (String) -> Unit,
|
||||
val onNavigateToRunAsExitNode: () -> Unit,
|
||||
)
|
||||
|
||||
class ExitNodePickerViewModelFactory(private val nav: ExitNodePickerNav) :
|
||||
ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return ExitNodePickerViewModel(nav) as T
|
||||
}
|
||||
}
|
||||
|
||||
class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel() {
|
||||
data class ExitNode(
|
||||
val id: StableNodeID? = null,
|
||||
val label: String,
|
||||
val online: Boolean,
|
||||
val selected: Boolean,
|
||||
val mullvad: Boolean = false,
|
||||
val priority: Int = 0,
|
||||
val countryCode: String = "",
|
||||
val country: String = "",
|
||||
val city: String = ""
|
||||
)
|
||||
|
||||
val tailnetExitNodes: StateFlow<List<ExitNode>> = MutableStateFlow(emptyList())
|
||||
val mullvadExitNodesByCountryCode: StateFlow<Map<String, List<ExitNode>>> =
|
||||
MutableStateFlow(TreeMap())
|
||||
val mullvadBestAvailableByCountry: StateFlow<Map<String, ExitNode>> = MutableStateFlow(TreeMap())
|
||||
val mullvadExitNodeCount: StateFlow<Int> = MutableStateFlow(0)
|
||||
val anyActive: StateFlow<Boolean> = MutableStateFlow(false)
|
||||
val isRunningExitNode: StateFlow<Boolean> = MutableStateFlow(false)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
Notifier.netmap
|
||||
.combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) }
|
||||
.stateIn(viewModelScope)
|
||||
.collect { (netmap, prefs) ->
|
||||
isRunningExitNode.set(prefs?.let { AdvertisedRoutesHelper.exitNodeOnFromPrefs(it) })
|
||||
val exitNodeId = prefs?.activeExitNodeID ?: prefs?.selectedExitNodeID
|
||||
netmap?.Peers?.let { peers ->
|
||||
val allNodes =
|
||||
peers
|
||||
.filter { it.isExitNode }
|
||||
.map {
|
||||
ExitNode(
|
||||
id = it.StableID,
|
||||
label = it.displayName,
|
||||
online = it.Online ?: false,
|
||||
selected = it.StableID == exitNodeId,
|
||||
mullvad = it.Name.endsWith(".mullvad.ts.net."),
|
||||
priority = it.Hostinfo.Location?.Priority ?: 0,
|
||||
countryCode = it.Hostinfo.Location?.CountryCode ?: "",
|
||||
country = it.Hostinfo.Location?.Country ?: "",
|
||||
city = it.Hostinfo.Location?.City ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
val tailnetNodes = allNodes.filter { !it.mullvad }
|
||||
tailnetExitNodes.set(tailnetNodes.sortedWith { a, b -> a.label.compareTo(b.label) })
|
||||
|
||||
val allMullvadExitNodes =
|
||||
allNodes.filter {
|
||||
// Pick all mullvad nodes that are online or the currently selected
|
||||
it.mullvad && (it.selected || it.online)
|
||||
}
|
||||
val mullvadExitNodes =
|
||||
allMullvadExitNodes
|
||||
.groupBy {
|
||||
// Group by countryCode
|
||||
it.countryCode
|
||||
}
|
||||
.mapValues { (_, nodes) ->
|
||||
// Group by city
|
||||
nodes
|
||||
.groupBy { it.city }
|
||||
.mapValues { (_, nodes) ->
|
||||
// Pick one node per city, either the selected one or the best
|
||||
// available
|
||||
nodes
|
||||
.sortedWith { a, b ->
|
||||
if (a.selected && !b.selected) {
|
||||
-1
|
||||
} else if (b.selected && !a.selected) {
|
||||
1
|
||||
} else {
|
||||
b.priority.compareTo(a.priority)
|
||||
}
|
||||
}
|
||||
.first()
|
||||
}
|
||||
.values
|
||||
.sortedBy { it.city.lowercase() }
|
||||
}
|
||||
mullvadExitNodesByCountryCode.set(mullvadExitNodes)
|
||||
mullvadExitNodeCount.set(allMullvadExitNodes.size)
|
||||
|
||||
val bestAvailableByCountry =
|
||||
mullvadExitNodes.mapValues { (_, nodes) ->
|
||||
nodes.minByOrNull { -1 * it.priority }!!
|
||||
}
|
||||
mullvadBestAvailableByCountry.set(bestAvailableByCountry)
|
||||
|
||||
anyActive.set(allNodes.any { it.selected })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setExitNode(node: ExitNode) {
|
||||
LoadingIndicator.start()
|
||||
val prefsOut = Ipn.MaskedPrefs()
|
||||
prefsOut.ExitNodeID = node.id
|
||||
|
||||
Client(viewModelScope).editPrefs(prefsOut) {
|
||||
nav.onNavigateBackHome()
|
||||
LoadingIndicator.stop()
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleAllowLANAccess(callback: (Result<Ipn.Prefs>) -> Unit) {
|
||||
val prefs =
|
||||
Notifier.prefs.value
|
||||
?: run {
|
||||
callback(Result.failure(Exception("no prefs")))
|
||||
return@toggleAllowLANAccess
|
||||
}
|
||||
|
||||
val prefsOut = Ipn.MaskedPrefs()
|
||||
prefsOut.ExitNodeAllowLANAccess = !prefs.ExitNodeAllowLANAccess
|
||||
Client(viewModelScope).editPrefs(prefsOut, callback)
|
||||
}
|
||||
}
|
||||
|
||||
val List<ExitNodePickerViewModel.ExitNode>.selected
|
||||
get() = this.any { it.selected }
|
||||
|
||||
val Map<String, List<ExitNodePickerViewModel.ExitNode>>.selected
|
||||
get() = this.any { it.value.selected }
|
@ -0,0 +1,226 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import android.net.VpnService
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tailscale.ipn.App
|
||||
import com.tailscale.ipn.UninitializedApp
|
||||
import com.tailscale.ipn.mdm.MDMSettings
|
||||
import com.tailscale.ipn.ui.localapi.Client
|
||||
import com.tailscale.ipn.ui.model.Ipn
|
||||
import com.tailscale.ipn.ui.model.IpnLocal
|
||||
import com.tailscale.ipn.ui.model.UserID
|
||||
import com.tailscale.ipn.ui.notifier.Notifier
|
||||
import com.tailscale.ipn.ui.notifier.Notifier.prefs
|
||||
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Base model for most models in this application. Provides common facilities for watching IPN
|
||||
* notifications, managing login/logout, updating preferences, etc.
|
||||
*/
|
||||
open class IpnViewModel : ViewModel() {
|
||||
protected val TAG = this::class.simpleName
|
||||
|
||||
val loggedInUser: StateFlow<IpnLocal.LoginProfile?> = MutableStateFlow(null)
|
||||
val loginProfiles: StateFlow<List<IpnLocal.LoginProfile>?> = MutableStateFlow(null)
|
||||
private val _vpnPrepared = MutableStateFlow(false)
|
||||
val vpnPrepared: StateFlow<Boolean> = _vpnPrepared
|
||||
|
||||
// The userId associated with the current node. ie: The logged in user.
|
||||
private var selfNodeUserId: UserID? = null
|
||||
|
||||
init {
|
||||
// Check if the user has granted permission yet.
|
||||
if (!vpnPrepared.value) {
|
||||
val vpnIntent = VpnService.prepare(App.get())
|
||||
if (vpnIntent != null) {
|
||||
setVpnPrepared(false)
|
||||
} else {
|
||||
setVpnPrepared(true)
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
Notifier.state.collect {
|
||||
// Reload the user profiles on all state transitions to ensure loggedInUser is correct
|
||||
viewModelScope.launch { loadUserProfiles() }
|
||||
}
|
||||
}
|
||||
|
||||
// This will observe the userId of the current node and reload our user profiles if
|
||||
// we discover it has changed (e.g. due to a login or user switch)
|
||||
viewModelScope.launch {
|
||||
Notifier.netmap.collect {
|
||||
it?.SelfNode?.User.let {
|
||||
if (it != selfNodeUserId) {
|
||||
selfNodeUserId = it
|
||||
viewModelScope.launch { loadUserProfiles() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch { loadUserProfiles() }
|
||||
Log.d(TAG, "Created")
|
||||
}
|
||||
|
||||
// VPN Control
|
||||
fun setVpnPrepared(prepared: Boolean) {
|
||||
_vpnPrepared.value = prepared
|
||||
}
|
||||
|
||||
fun startVPN() {
|
||||
UninitializedApp.get().startVPN()
|
||||
}
|
||||
|
||||
fun stopVPN() {
|
||||
UninitializedApp.get().stopVPN()
|
||||
}
|
||||
|
||||
// Login/Logout
|
||||
|
||||
fun login(
|
||||
maskedPrefs: Ipn.MaskedPrefs? = null,
|
||||
authKey: String? = null,
|
||||
completionHandler: (Result<Unit>) -> Unit = {}
|
||||
) {
|
||||
|
||||
val loginAction = {
|
||||
Client(viewModelScope).startLoginInteractive { result ->
|
||||
result
|
||||
.onSuccess { Log.d(TAG, "Login started: $it") }
|
||||
.onFailure { Log.e(TAG, "Error starting login: ${it.message}") }
|
||||
completionHandler(result)
|
||||
}
|
||||
}
|
||||
|
||||
// Need to stop running before logging in to clear routes:
|
||||
// https://linear.app/tailscale/issue/ENG-3441/routesdns-is-not-cleared-when-switching-profiles-or-reauthenticating
|
||||
val stopThenLogin = {
|
||||
Client(viewModelScope).editPrefs(Ipn.MaskedPrefs().apply { WantRunning = false }) { result ->
|
||||
result
|
||||
.onSuccess { loginAction() }
|
||||
.onFailure { Log.e(TAG, "Error setting wantRunning to false: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
val startAction = {
|
||||
Client(viewModelScope).start(Ipn.Options(AuthKey = authKey)) { start ->
|
||||
start.onFailure { completionHandler(Result.failure(it)) }.onSuccess { stopThenLogin() }
|
||||
}
|
||||
}
|
||||
|
||||
// If an MDM control URL is set, we will always use that in lieu of anything the user sets.
|
||||
var prefs = maskedPrefs
|
||||
val mdmControlURL = MDMSettings.loginURL.flow.value
|
||||
|
||||
if (mdmControlURL != null) {
|
||||
prefs = prefs ?: Ipn.MaskedPrefs()
|
||||
prefs.ControlURL = mdmControlURL
|
||||
Log.d(TAG, "Overriding control URL with MDM value: $mdmControlURL")
|
||||
}
|
||||
|
||||
prefs?.let {
|
||||
Client(viewModelScope).editPrefs(it) { result ->
|
||||
result.onFailure { completionHandler(Result.failure(it)) }.onSuccess { startAction() }
|
||||
}
|
||||
} ?: run { startAction() }
|
||||
}
|
||||
|
||||
fun loginWithAuthKey(authKey: String, completionHandler: (Result<Unit>) -> Unit = {}) {
|
||||
val prefs = Ipn.MaskedPrefs()
|
||||
prefs.WantRunning = true
|
||||
login(prefs, authKey = authKey, completionHandler)
|
||||
}
|
||||
|
||||
fun loginWithCustomControlURL(
|
||||
controlURL: String,
|
||||
completionHandler: (Result<Unit>) -> Unit = {}
|
||||
) {
|
||||
val prefs = Ipn.MaskedPrefs()
|
||||
prefs.ControlURL = controlURL
|
||||
login(prefs, completionHandler = completionHandler)
|
||||
}
|
||||
|
||||
fun logout(completionHandler: (Result<String>) -> Unit = {}) {
|
||||
Client(viewModelScope).logout { result ->
|
||||
result
|
||||
.onSuccess { Log.d(TAG, "Logout started: $it") }
|
||||
.onFailure { Log.e(TAG, "Error starting logout: ${it.message}") }
|
||||
completionHandler(result)
|
||||
}
|
||||
}
|
||||
|
||||
// User Profiles
|
||||
|
||||
private fun loadUserProfiles() {
|
||||
Client(viewModelScope).profiles { result ->
|
||||
result.onSuccess(loginProfiles::set).onFailure {
|
||||
Log.e(TAG, "Error loading profiles: ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
Client(viewModelScope).currentProfile { result ->
|
||||
result
|
||||
.onSuccess { loggedInUser.set(if (it.isEmpty()) null else it) }
|
||||
.onFailure { Log.e(TAG, "Error loading current profile: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
fun switchProfile(profile: IpnLocal.LoginProfile, completionHandler: (Result<String>) -> Unit) {
|
||||
val switchProfile = {
|
||||
Client(viewModelScope).switchProfile(profile) {
|
||||
startVPN()
|
||||
completionHandler(it)
|
||||
}
|
||||
}
|
||||
Client(viewModelScope).editPrefs(Ipn.MaskedPrefs().apply { WantRunning = false }) { result ->
|
||||
result
|
||||
.onSuccess { switchProfile() }
|
||||
.onFailure { Log.e(TAG, "Error setting wantRunning to false: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
fun addProfile(completionHandler: (Result<String>) -> Unit) {
|
||||
Client(viewModelScope).addProfile {
|
||||
if (it.isSuccess) {
|
||||
login()
|
||||
}
|
||||
startVPN()
|
||||
completionHandler(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteProfile(profile: IpnLocal.LoginProfile, completionHandler: (Result<String>) -> Unit) {
|
||||
Client(viewModelScope).deleteProfile(profile) {
|
||||
viewModelScope.launch { loadUserProfiles() }
|
||||
completionHandler(it)
|
||||
}
|
||||
}
|
||||
|
||||
// Exit Node Manipulation
|
||||
|
||||
fun toggleExitNode() {
|
||||
val prefs = prefs.value ?: return
|
||||
|
||||
LoadingIndicator.start()
|
||||
if (prefs.activeExitNodeID != null) {
|
||||
// We have an active exit node so we should keep it, but disable it
|
||||
Client(viewModelScope).setUseExitNode(false) { LoadingIndicator.stop() }
|
||||
} else if (prefs.selectedExitNodeID != null) {
|
||||
// We have a prior exit node to enable
|
||||
Client(viewModelScope).setUseExitNode(true) { LoadingIndicator.stop() }
|
||||
} else {
|
||||
// This should not be possible. In this state the button is hidden
|
||||
Log.e(TAG, "No exit node to disable and no prior exit node to enable")
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,62 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.WriterException
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import com.tailscale.ipn.ui.notifier.Notifier
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class LoginQRViewModel : IpnViewModel() {
|
||||
|
||||
val qrCode: StateFlow<ImageBitmap?> = MutableStateFlow(null)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
Notifier.browseToURL.collect { url ->
|
||||
url?.let { qrCode.set(generateQRCode(url, 200, 0)) } ?: run { qrCode.set(null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun generateQRCode(content: String, size: Int, padding: Int): ImageBitmap? {
|
||||
val qrCodeWriter = QRCodeWriter()
|
||||
|
||||
val encodeHints = mapOf<EncodeHintType, Any?>(EncodeHintType.MARGIN to padding)
|
||||
|
||||
val bitmapMatrix =
|
||||
try {
|
||||
qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, size, size, encodeHints)
|
||||
} catch (ex: WriterException) {
|
||||
return null
|
||||
}
|
||||
|
||||
val qrCode =
|
||||
Bitmap.createBitmap(
|
||||
size,
|
||||
size,
|
||||
Bitmap.Config.ARGB_8888,
|
||||
)
|
||||
|
||||
for (x in 0 until size) {
|
||||
for (y in 0 until size) {
|
||||
val shouldColorPixel = bitmapMatrix?.get(x, y) ?: false
|
||||
val pixelColor = if (shouldColorPixel) Color.BLACK else Color.WHITE
|
||||
qrCode.setPixel(x, y, pixelColor)
|
||||
}
|
||||
}
|
||||
|
||||
return qrCode.asImageBitmap()
|
||||
}
|
||||
}
|
@ -0,0 +1,149 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import android.util.Log
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tailscale.ipn.App
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.mdm.MDMSettings
|
||||
import com.tailscale.ipn.ui.model.Ipn
|
||||
import com.tailscale.ipn.ui.model.Ipn.State
|
||||
import com.tailscale.ipn.ui.notifier.Notifier
|
||||
import com.tailscale.ipn.ui.util.PeerCategorizer
|
||||
import com.tailscale.ipn.ui.util.PeerSet
|
||||
import com.tailscale.ipn.ui.util.TimeUtil
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Duration
|
||||
|
||||
class MainViewModel : IpnViewModel() {
|
||||
|
||||
// The user readable state of the system
|
||||
val stateRes: StateFlow<Int> = MutableStateFlow(userStringRes(State.NoState, State.NoState, true))
|
||||
|
||||
// The expected state of the VPN toggle
|
||||
private val _vpnToggleState = MutableStateFlow(false)
|
||||
val vpnToggleState: StateFlow<Boolean> = _vpnToggleState
|
||||
|
||||
// Permission to prepare VPN
|
||||
private var vpnPermissionLauncher: ActivityResultLauncher<Intent>? = null
|
||||
|
||||
// The list of peers
|
||||
val peers: StateFlow<List<PeerSet>> = MutableStateFlow(emptyList<PeerSet>())
|
||||
|
||||
// The current state of the IPN for determining view visibility
|
||||
val ipnState = Notifier.state
|
||||
|
||||
val prefs = Notifier.prefs
|
||||
val netmap = Notifier.netmap
|
||||
|
||||
// The active search term for filtering peers
|
||||
val searchTerm: StateFlow<String> = MutableStateFlow("")
|
||||
|
||||
// True if we should render the key expiry bannder
|
||||
val showExpiry: StateFlow<Boolean> = MutableStateFlow(false)
|
||||
|
||||
private val peerCategorizer = PeerCategorizer()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
var previousState: State? = null
|
||||
|
||||
combine(Notifier.state, vpnPrepared) { state, prepared -> state to prepared }
|
||||
.collect { (currentState, prepared) ->
|
||||
stateRes.set(userStringRes(currentState, previousState, prepared))
|
||||
|
||||
val isOn =
|
||||
when {
|
||||
currentState == State.Running || currentState == State.Starting -> true
|
||||
previousState == State.NoState && currentState == State.Starting -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
_vpnToggleState.value = isOn
|
||||
previousState = currentState
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
Notifier.netmap.collect { it ->
|
||||
it?.let { netmap ->
|
||||
peerCategorizer.regenerateGroupedPeers(netmap)
|
||||
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value))
|
||||
|
||||
if (netmap.SelfNode.keyDoesNotExpire) {
|
||||
showExpiry.set(false)
|
||||
return@let
|
||||
} else {
|
||||
val expiryNotificationWindowMDM = MDMSettings.keyExpirationNotice.flow.value
|
||||
val window =
|
||||
expiryNotificationWindowMDM?.let { TimeUtil.duration(it) } ?: Duration.ofHours(24)
|
||||
val expiresSoon =
|
||||
TimeUtil.isWithinExpiryNotificationWindow(window, it.SelfNode.KeyExpiry)
|
||||
showExpiry.set(expiresSoon)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
searchTerm.collect { term -> peers.set(peerCategorizer.groupedAndFilteredPeers(term)) }
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
Notifier.prefs.collect { prefs -> Log.d(TAG, "Main VM - prefs = ${prefs}") }
|
||||
}
|
||||
}
|
||||
|
||||
fun showVPNPermissionLauncherIfUnauthorized() {
|
||||
val vpnIntent = VpnService.prepare(App.get())
|
||||
if (vpnIntent != null) {
|
||||
vpnPermissionLauncher?.launch(vpnIntent)
|
||||
} else {
|
||||
setVpnPrepared(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleVpn() {
|
||||
val state = Notifier.state.value
|
||||
val isPrepared = vpnPrepared.value
|
||||
|
||||
when {
|
||||
!isPrepared -> showVPNPermissionLauncherIfUnauthorized()
|
||||
state == Ipn.State.Running -> stopVPN()
|
||||
else -> startVPN()
|
||||
}
|
||||
}
|
||||
|
||||
fun searchPeers(searchTerm: String) {
|
||||
this.searchTerm.set(searchTerm)
|
||||
}
|
||||
|
||||
fun setVpnPermissionLauncher(launcher: ActivityResultLauncher<Intent>) {
|
||||
// No intent means we're already authorized
|
||||
vpnPermissionLauncher = launcher
|
||||
}
|
||||
}
|
||||
|
||||
private fun userStringRes(currentState: State?, previousState: State?, vpnPrepared: Boolean): Int {
|
||||
return when {
|
||||
previousState == State.NoState && currentState == State.Starting -> R.string.starting
|
||||
currentState == State.NoState -> R.string.placeholder
|
||||
currentState == State.InUseOtherUser -> R.string.placeholder
|
||||
currentState == State.NeedsLogin ->
|
||||
if (vpnPrepared) R.string.please_login else R.string.connect_to_vpn
|
||||
currentState == State.NeedsMachineAuth -> R.string.needs_machine_auth
|
||||
currentState == State.Stopped -> R.string.stopped
|
||||
currentState == State.Starting -> R.string.starting
|
||||
currentState == State.Running -> R.string.connected
|
||||
else -> R.string.placeholder
|
||||
}
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tailscale.ipn.ui.model.Netmap
|
||||
import com.tailscale.ipn.ui.model.StableNodeID
|
||||
import com.tailscale.ipn.ui.model.Tailcfg
|
||||
import com.tailscale.ipn.ui.notifier.Notifier
|
||||
import com.tailscale.ipn.ui.util.ComposableStringFormatter
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
data class PeerSettingInfo(val titleRes: Int, val value: ComposableStringFormatter)
|
||||
|
||||
class PeerDetailsViewModelFactory(private val nodeId: StableNodeID, private val filesDir: File) :
|
||||
ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return PeerDetailsViewModel(nodeId, filesDir) as T
|
||||
}
|
||||
}
|
||||
|
||||
class PeerDetailsViewModel(val nodeId: StableNodeID, val filesDir: File) : IpnViewModel() {
|
||||
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
|
||||
val node: StateFlow<Tailcfg.Node?> = MutableStateFlow(null)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
Notifier.netmap.collect { nm ->
|
||||
netmap.set(nm)
|
||||
nm?.getPeer(nodeId)?.let { peer -> node.set(peer) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,97 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tailscale.ipn.ui.localapi.Client
|
||||
import com.tailscale.ipn.ui.model.Ipn
|
||||
import com.tailscale.ipn.ui.notifier.Notifier
|
||||
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class RunExitNodeViewModelFactory() : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return RunExitNodeViewModel() as T
|
||||
}
|
||||
}
|
||||
|
||||
class AdvertisedRoutesHelper() {
|
||||
companion object {
|
||||
fun exitNodeOnFromPrefs(prefs: Ipn.Prefs): Boolean {
|
||||
var v4 = false
|
||||
var v6 = false
|
||||
prefs.AdvertiseRoutes?.forEach {
|
||||
if (it == "0.0.0.0/0") {
|
||||
v4 = true
|
||||
}
|
||||
if (it == "::/0") {
|
||||
v6 = true
|
||||
}
|
||||
}
|
||||
return v4 && v6
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RunExitNodeViewModel() : IpnViewModel() {
|
||||
|
||||
val isRunningExitNode: StateFlow<Boolean> = MutableStateFlow(false)
|
||||
var lastPrefs: Ipn.Prefs? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
Notifier.prefs.stateIn(viewModelScope).collect { prefs ->
|
||||
Log.d("RunExitNode", "prefs: AdvertiseRoutes=" + prefs?.AdvertiseRoutes.toString())
|
||||
prefs?.let {
|
||||
lastPrefs = it
|
||||
isRunningExitNode.set(AdvertisedRoutesHelper.exitNodeOnFromPrefs(it))
|
||||
} ?: run { isRunningExitNode.set(false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setRunningExitNode(isOn: Boolean) {
|
||||
LoadingIndicator.start()
|
||||
lastPrefs?.let { currentPrefs ->
|
||||
val newPrefs: Ipn.MaskedPrefs
|
||||
if (isOn) {
|
||||
newPrefs = setZeroRoutes(currentPrefs)
|
||||
} else {
|
||||
newPrefs = removeAllZeroRoutes(currentPrefs)
|
||||
}
|
||||
Client(viewModelScope).editPrefs(newPrefs) { result ->
|
||||
LoadingIndicator.stop()
|
||||
Log.d("RunExitNodeViewModel", "Edited prefs: $result")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setZeroRoutes(prefs: Ipn.Prefs): Ipn.MaskedPrefs {
|
||||
val newRoutes = (removeAllZeroRoutes(prefs).AdvertiseRoutes ?: emptyList()).toMutableList()
|
||||
newRoutes.add("0.0.0.0/0")
|
||||
newRoutes.add("::/0")
|
||||
val newPrefs = Ipn.MaskedPrefs()
|
||||
newPrefs.AdvertiseRoutes = newRoutes
|
||||
return newPrefs
|
||||
}
|
||||
|
||||
private fun removeAllZeroRoutes(prefs: Ipn.Prefs): Ipn.MaskedPrefs {
|
||||
val newRoutes = emptyList<String>().toMutableList()
|
||||
(prefs.AdvertiseRoutes ?: emptyList()).forEach {
|
||||
if (it != "0.0.0.0/0" && it != "::/0") {
|
||||
newRoutes.add(it)
|
||||
}
|
||||
}
|
||||
val newPrefs = Ipn.MaskedPrefs()
|
||||
newPrefs.AdvertiseRoutes = newRoutes
|
||||
return newPrefs
|
||||
}
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tailscale.ipn.mdm.MDMSettings
|
||||
import com.tailscale.ipn.ui.localapi.Client
|
||||
import com.tailscale.ipn.ui.notifier.Notifier
|
||||
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class SettingsNav(
|
||||
val onNavigateToBugReport: () -> Unit,
|
||||
val onNavigateToAbout: () -> Unit,
|
||||
val onNavigateToDNSSettings: () -> Unit,
|
||||
val onNavigateToTailnetLock: () -> Unit,
|
||||
val onNavigateToMDMSettings: () -> Unit,
|
||||
val onNavigateToManagedBy: () -> Unit,
|
||||
val onNavigateToUserSwitcher: () -> Unit,
|
||||
val onNavigateToPermissions: () -> Unit,
|
||||
val onNavigateBackHome: () -> Unit,
|
||||
val onBackToSettings: () -> Unit,
|
||||
)
|
||||
|
||||
class SettingsViewModel : IpnViewModel() {
|
||||
// Display name for the logged in user
|
||||
val isAdmin: StateFlow<Boolean> = MutableStateFlow(false)
|
||||
val managedByOrganization = MDMSettings.managedByOrganizationName.flow
|
||||
// True if tailnet lock is enabled. nil if not yet known.
|
||||
val tailNetLockEnabled: StateFlow<Boolean?> = MutableStateFlow(null)
|
||||
// True if tailscaleDNS is enabled. nil if not yet known.
|
||||
val corpDNSEnabled: StateFlow<Boolean?> = MutableStateFlow(null)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
Notifier.netmap.collect { netmap -> isAdmin.set(netmap?.SelfNode?.isAdmin ?: false) }
|
||||
}
|
||||
|
||||
Client(viewModelScope).tailnetLockStatus { result ->
|
||||
result.onSuccess { status -> tailNetLockEnabled.set(status.Enabled) }
|
||||
|
||||
LoadingIndicator.stop()
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
Notifier.prefs.collect {
|
||||
it?.let {
|
||||
corpDNSEnabled.set(it.CorpDNS)
|
||||
} ?: run {
|
||||
corpDNSEnabled.set(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,200 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.localapi.Client
|
||||
import com.tailscale.ipn.ui.model.Ipn
|
||||
import com.tailscale.ipn.ui.model.StableNodeID
|
||||
import com.tailscale.ipn.ui.model.Tailcfg
|
||||
import com.tailscale.ipn.ui.notifier.Notifier
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import com.tailscale.ipn.ui.view.ActivityIndicator
|
||||
import com.tailscale.ipn.ui.view.CheckedIndicator
|
||||
import com.tailscale.ipn.ui.view.ErrorDialogType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class TaildropViewModelFactory(
|
||||
private val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>>,
|
||||
private val applicationScope: CoroutineScope
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return TaildropViewModel(requestedTransfers, applicationScope) as T
|
||||
}
|
||||
}
|
||||
|
||||
class TaildropViewModel(
|
||||
val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>>,
|
||||
private val applicationScope: CoroutineScope
|
||||
) : IpnViewModel() {
|
||||
|
||||
// Represents the state of a file transfer
|
||||
enum class TransferState {
|
||||
SENDING,
|
||||
SENT,
|
||||
FAILED
|
||||
}
|
||||
|
||||
// The overall VPN state
|
||||
val state = Notifier.state
|
||||
|
||||
// Set of all nodes for which we've requested a file transfer. This is used to prevent us from
|
||||
// request a transfer to the same peer twice.
|
||||
private val selectedPeers: StateFlow<Set<StableNodeID>> = MutableStateFlow(emptySet())
|
||||
// Set of OutgoingFile.IDs that we're currently transferring.
|
||||
private val currentTransferIDs: StateFlow<Set<String>> = MutableStateFlow(emptySet())
|
||||
// Flow of Ipn.OutgoingFiles with updated statuses for every entry in transferWithStatuses.
|
||||
private val transfers: StateFlow<List<Ipn.OutgoingFile>> = MutableStateFlow(emptyList())
|
||||
|
||||
// The total size of all pending files.
|
||||
val totalSize: Long
|
||||
get() = requestedTransfers.value.sumOf { it.DeclaredSize }
|
||||
|
||||
// The list of peers that we can share with. This includes only the nodes belonging to the user
|
||||
// and excludes the current node. Sorted by online devices first, and offline second,
|
||||
// alphabetically.
|
||||
val myPeers: StateFlow<List<Tailcfg.Node>> = MutableStateFlow(emptyList())
|
||||
|
||||
// Non null if there's an error to be rendered.
|
||||
val showDialog: StateFlow<ErrorDialogType?> = MutableStateFlow(null)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
Notifier.state.collect {
|
||||
if (it == Ipn.State.Running) {
|
||||
loadTargets()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
// Map the outgoing files by their PeerId since we need to display them for each peer
|
||||
// We only need to track files which are pending send, everything else is irrelevant.
|
||||
Notifier.outgoingFiles
|
||||
.combine(currentTransferIDs) { outgoingFiles, ongoingIDs ->
|
||||
Pair(outgoingFiles, ongoingIDs)
|
||||
}
|
||||
.collect { (outgoingFiles, ongoingIDs) ->
|
||||
outgoingFiles?.let {
|
||||
transfers.set(outgoingFiles.filter { ongoingIDs.contains(it.ID) })
|
||||
} ?: run { transfers.set(emptyList()) }
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
requestedTransfers.collect {
|
||||
// This means that we're processing a new share intent, clear current state
|
||||
selectedPeers.set(emptySet())
|
||||
currentTransferIDs.set(emptySet())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculates the overall progress for a set of outgoing files
|
||||
private fun progress(transfers: List<Ipn.OutgoingFile>): Double {
|
||||
val total = transfers.sumOf { it.DeclaredSize }.toDouble()
|
||||
val sent = transfers.sumOf { it.Sent }.toDouble()
|
||||
if (total < 0.1) return 0.0
|
||||
return (sent / total)
|
||||
}
|
||||
|
||||
// Calculates the overall state of a set of file transfers.
|
||||
// peerId: The peer ID to check for transfers.
|
||||
// transfers: The list of outgoing file transfers for the peer.
|
||||
private fun transferState(transfers: List<Ipn.OutgoingFile>): TransferState? {
|
||||
// No transfers? Nothing state
|
||||
if (transfers.isEmpty()) return null
|
||||
|
||||
return if (transfers.all { it.Finished }) {
|
||||
// Everything done? SENT if all succeeded, FAILED if any failed.
|
||||
if (transfers.any { !it.Succeeded }) TransferState.FAILED else TransferState.SENT
|
||||
} else {
|
||||
// Not complete, we're still sending
|
||||
TransferState.SENDING
|
||||
}
|
||||
}
|
||||
|
||||
// Loads all of the valid fileTargets from localAPI
|
||||
private fun loadTargets() {
|
||||
Client(viewModelScope).fileTargets { result ->
|
||||
result
|
||||
.onSuccess { it ->
|
||||
val allSharablePeers = it.map { it.Node }
|
||||
val onlinePeers = allSharablePeers.filter { it.Online ?: false }.sortedBy { it.Name }
|
||||
val offlinePeers =
|
||||
allSharablePeers.filter { !(it.Online ?: false) }.sortedBy { it.Name }
|
||||
myPeers.set(onlinePeers + offlinePeers)
|
||||
}
|
||||
.onFailure { Log.e(TAG, "Error loading targets: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
// Creates the trailing status view for the peer list item depending on the state of
|
||||
// any requested transfers.
|
||||
@Composable
|
||||
fun TrailingContentForPeer(peerId: String) {
|
||||
// Check our outgoing files for the peer and determine the state of the transfer.
|
||||
val transfers = this.transfers.collectAsState().value.filter { it.PeerID == peerId }
|
||||
val status: TransferState = transferState(transfers) ?: return
|
||||
|
||||
// Still no status? Nothing to render for this peer
|
||||
|
||||
Column(modifier = Modifier.fillMaxHeight()) {
|
||||
when (status) {
|
||||
TransferState.SENDING -> {
|
||||
val progress = progress(transfers)
|
||||
Text(
|
||||
stringResource(id = R.string.taildrop_sending),
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
ActivityIndicator(progress, 60)
|
||||
}
|
||||
TransferState.SENT -> CheckedIndicator()
|
||||
TransferState.FAILED -> Text(stringResource(id = R.string.taildrop_share_failed_short))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Commences the file transfer to the specified node iff
|
||||
fun share(context: Context, node: Tailcfg.Node) {
|
||||
if (node.Online != true) {
|
||||
showDialog.set(ErrorDialogType.SHARE_DEVICE_NOT_CONNECTED)
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedPeers.value.contains(node.StableID)) {
|
||||
// We've already selected this peer, ignore
|
||||
return
|
||||
}
|
||||
selectedPeers.set(selectedPeers.value + node.StableID)
|
||||
|
||||
val preparedTransfers = requestedTransfers.value.map { it.prepare(node.StableID) }
|
||||
currentTransferIDs.set(currentTransferIDs.value + preparedTransfers.map { it.ID })
|
||||
|
||||
Client(applicationScope).putTaildropFiles(context, node.StableID, preparedTransfers) {
|
||||
// This is an early API failure and will not get communicated back up to us via
|
||||
// outgoing files - things never made it that far.
|
||||
if (it.isFailure) {
|
||||
selectedPeers.set(selectedPeers.value - node.StableID)
|
||||
showDialog.set(ErrorDialogType.SHARE_FAILED)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tailscale.ipn.R
|
||||
import com.tailscale.ipn.ui.localapi.Client
|
||||
import com.tailscale.ipn.ui.model.IpnState
|
||||
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||
import com.tailscale.ipn.ui.util.set
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class TailnetLockSetupViewModelFactory() : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return TailnetLockSetupViewModel() as T
|
||||
}
|
||||
}
|
||||
|
||||
data class StatusItem(@StringRes val title: Int, @DrawableRes val icon: Int)
|
||||
|
||||
class TailnetLockSetupViewModel() : IpnViewModel() {
|
||||
|
||||
val statusItems: StateFlow<List<StatusItem>> = MutableStateFlow(emptyList())
|
||||
val nodeKey: StateFlow<String> = MutableStateFlow("unknown")
|
||||
val tailnetLockKey: StateFlow<String> = MutableStateFlow("unknown")
|
||||
|
||||
init {
|
||||
LoadingIndicator.start()
|
||||
Client(viewModelScope).tailnetLockStatus { result ->
|
||||
statusItems.set(generateStatusItems(result.getOrNull()))
|
||||
nodeKey.set(result.getOrNull()?.NodeKey ?: "unknown")
|
||||
tailnetLockKey.set(result.getOrNull()?.PublicKey ?: "unknown")
|
||||
LoadingIndicator.stop()
|
||||
}
|
||||
}
|
||||
|
||||
fun generateStatusItems(networkLockStatus: IpnState.NetworkLockStatus?): List<StatusItem> {
|
||||
networkLockStatus?.let { status ->
|
||||
val items = emptyList<StatusItem>().toMutableList()
|
||||
if (status.Enabled == true) {
|
||||
items.add(StatusItem(title = R.string.tailnet_lock_enabled, icon = R.drawable.check_circle))
|
||||
} else {
|
||||
items.add(
|
||||
StatusItem(title = R.string.tailnet_lock_disabled, icon = R.drawable.xmark_circle))
|
||||
}
|
||||
|
||||
if (status.NodeKeySigned == true) {
|
||||
items.add(
|
||||
StatusItem(title = R.string.this_node_has_been_signed, icon = R.drawable.check_circle))
|
||||
} else {
|
||||
items.add(
|
||||
StatusItem(
|
||||
title = R.string.this_node_has_not_been_signed, icon = R.drawable.xmark_circle))
|
||||
}
|
||||
|
||||
if (status.IsPublicKeyTrusted()) {
|
||||
items.add(StatusItem(title = R.string.this_node_is_trusted, icon = R.drawable.check_circle))
|
||||
} else {
|
||||
items.add(
|
||||
StatusItem(title = R.string.this_node_is_not_trusted, icon = R.drawable.xmark_circle))
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
?: run {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import com.tailscale.ipn.ui.view.ErrorDialogType
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class UserSwitcherViewModel : IpnViewModel() {
|
||||
|
||||
// Set to a non-null value to show the appropriate error dialog
|
||||
val errorDialog: StateFlow<ErrorDialogType?> = MutableStateFlow(null)
|
||||
|
||||
// True if we should render the kebab menu
|
||||
val showHeaderMenu: StateFlow<Boolean> = MutableStateFlow(false)
|
||||
}
|
Before Width: | Height: | Size: 641 B |
Before Width: | Height: | Size: 406 B |
Before Width: | Height: | Size: 879 B |
Before Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 1.8 KiB |