Compare commits
207 Commits
1.60.1-t2c
...
main
Author | SHA1 | Date |
---|---|---|
kari-ts | 4fa86dbf03 | 7 hours ago |
Jonathan Nobels | 77c2d924ee | 10 hours ago |
Jonathan Nobels | b37492a547 | 11 hours ago |
kari-ts | 999c6f2357 | 11 hours ago |
Andrea Gottardo | 006b1e6852 | 12 hours ago |
kari-ts | 32e29c4efd | 12 hours ago |
kari-ts | 9aa3a840de | 1 day ago |
kari-ts | 0ff47f7ab5 | 1 day ago |
kari-ts | 12ad295706 | 1 day ago |
kari-ts | d842ccde22 | 1 day ago |
Andrea Gottardo | cbcc773b98 | 2 days ago |
Andrea Gottardo | cbc0035dfe | 3 days ago |
kari-ts | c47ead9412 | 3 days ago |
Percy Wegmann | 46cdbb7b9b | 4 days ago |
kari-ts | 5476288100 | 4 days ago |
kari-ts | a3b356a81c | 4 days ago |
Percy Wegmann | 411d7b2597 | 4 days ago |
Percy Wegmann | 59a88ffbab | 4 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 | 1 week ago |
kari-ts | 2d7d6e1357 | 1 week 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 |
@ -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 198
|
||||
versionName "1.59.53-t0f042b981-g1017015de26"
|
||||
}
|
||||
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,85 +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"/>
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
|
||||
<!-- Disable input emulation on ChromeOS -->
|
||||
<uses-feature android:name="android.hardware.type.pc" android:required="false"/>
|
||||
<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,418 +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 static final String PEER_TAG = "peer";
|
||||
|
||||
static final String STATUS_CHANNEL_ID = "tailscale-status";
|
||||
static final int STATUS_NOTIFICATION_ID = 1;
|
||||
|
||||
static final String NOTIFY_CHANNEL_ID = "tailscale-notify";
|
||||
static final int NOTIFY_NOTIFICATION_ID = 2;
|
||||
|
||||
private static final String FILE_CHANNEL_ID = "tailscale-files";
|
||||
private static final int FILE_NOTIFICATION_ID = 3;
|
||||
|
||||
private static final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
private ConnectivityManager connectivityManager;
|
||||
public DnsConfig dns = new DnsConfig();
|
||||
public DnsConfig getDnsConfigObj() { return this.dns; }
|
||||
|
||||
@Override public void onCreate() {
|
||||
super.onCreate();
|
||||
// Load and initialize the Go library.
|
||||
Gio.init(this);
|
||||
|
||||
this.connectivityManager = (ConnectivityManager) this.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
setAndRegisterNetworkCallbacks();
|
||||
|
||||
createNotificationChannel(NOTIFY_CHANNEL_ID, "Notifications", NotificationManagerCompat.IMPORTANCE_DEFAULT);
|
||||
createNotificationChannel(STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW);
|
||||
createNotificationChannel(FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT);
|
||||
|
||||
}
|
||||
|
||||
// requestNetwork attempts to find the best network that matches the passed NetworkRequest. It is possible that
|
||||
// this might return an unusuable network, eg a captive portal.
|
||||
private void setAndRegisterNetworkCallbacks() {
|
||||
connectivityManager.requestNetwork(dns.getDNSConfigNetworkRequest(), new ConnectivityManager.NetworkCallback(){
|
||||
@Override
|
||||
public void onAvailable(Network network){
|
||||
super.onAvailable(network);
|
||||
StringBuilder sb = new StringBuilder("");
|
||||
LinkProperties linkProperties = connectivityManager.getLinkProperties(network);
|
||||
List<InetAddress> dnsList = linkProperties.getDnsServers();
|
||||
for (InetAddress ip : dnsList) {
|
||||
sb.append(ip.getHostAddress()).append(" ");
|
||||
}
|
||||
String searchDomains = linkProperties.getDomains();
|
||||
if (searchDomains != null) {
|
||||
sb.append("\n");
|
||||
sb.append(searchDomains);
|
||||
}
|
||||
|
||||
dns.updateDNSFromNetwork(sb.toString());
|
||||
onDnsConfigChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLost(Network network) {
|
||||
super.onLost(network);
|
||||
onDnsConfigChanged();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void startVPN() {
|
||||
Intent intent = new Intent(this, IPNService.class);
|
||||
intent.setAction(IPNService.ACTION_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 onDnsConfigChanged();
|
||||
static native void onShareIntent(int nfiles, int[] types, String[] mimes, String[] items, String[] names, long[] sizes);
|
||||
static native void onWriteStorageGranted();
|
||||
|
||||
// Returns details of the interfaces in the system, encoded as a single string for ease
|
||||
// of JNI transfer over to the Go environment.
|
||||
//
|
||||
// Example:
|
||||
// rmnet_data0 10 2000 true false false false false | fe80::4059:dc16:7ed3:9c6e%rmnet_data0/64
|
||||
// dummy0 3 1500 true false false false false | fe80::1450:5cff:fe13:f891%dummy0/64
|
||||
// wlan0 30 1500 true true false false true | fe80::2f60:2c82:4163:8389%wlan0/64 10.1.10.131/24
|
||||
// r_rmnet_data0 21 1500 true false false false false | fe80::9318:6093:d1ad:ba7f%r_rmnet_data0/64
|
||||
// rmnet_data2 12 1500 true false false false false | fe80::3c8c:44dc:46a9:9907%rmnet_data2/64
|
||||
// r_rmnet_data1 22 1500 true false false false false | fe80::b6cd:5cb0:8ae6:fe92%r_rmnet_data1/64
|
||||
// rmnet_data1 11 1500 true false false false false | fe80::51f2:ee00:edce:d68b%rmnet_data1/64
|
||||
// lo 1 65536 true false true false false | ::1/128 127.0.0.1/8
|
||||
// v4-rmnet_data2 68 1472 true true false true true | 192.0.0.4/32
|
||||
//
|
||||
// Where the fields are:
|
||||
// name ifindex mtu isUp hasBroadcast isLoopback isPointToPoint hasMulticast | ip1/N ip2/N ip3/N;
|
||||
String getInterfacesAsString() {
|
||||
List<NetworkInterface> interfaces;
|
||||
try {
|
||||
interfaces = Collections.list(NetworkInterface.getNetworkInterfaces());
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder("");
|
||||
for (NetworkInterface nif : interfaces) {
|
||||
try {
|
||||
// Android doesn't have a supportsBroadcast() but the Go net.Interface wants
|
||||
// one, so we say the interface has broadcast if it has multicast.
|
||||
sb.append(String.format(java.util.Locale.ROOT, "%s %d %d %b %b %b %b %b |", nif.getName(),
|
||||
nif.getIndex(), nif.getMTU(), nif.isUp(), nif.supportsMulticast(),
|
||||
nif.isLoopback(), nif.isPointToPoint(), nif.supportsMulticast()));
|
||||
|
||||
for (InterfaceAddress ia : nif.getInterfaceAddresses()) {
|
||||
// InterfaceAddress == hostname + "/" + IP
|
||||
String[] parts = ia.toString().split("/", 0);
|
||||
if (parts.length > 1) {
|
||||
sb.append(String.format(java.util.Locale.ROOT, "%s/%d ", parts[1], ia.getNetworkPrefixLength()));
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// TODO(dgentry) should log the exception not silently suppress it.
|
||||
continue;
|
||||
}
|
||||
sb.append("\n");
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
boolean isTV() {
|
||||
UiModeManager mm = (UiModeManager)getSystemService(UI_MODE_SERVICE);
|
||||
return mm.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION;
|
||||
}
|
||||
}
|
@ -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,88 +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.app.PendingIntent;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.service.quicksettings.Tile;
|
||||
import android.service.quicksettings.TileService;
|
||||
|
||||
public class QuickToggleService extends TileService {
|
||||
// lock protects the static fields below it.
|
||||
private static 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();
|
||||
|
||||
@Override public void onStartListening() {
|
||||
synchronized (lock) {
|
||||
currentTile = getQsTile();
|
||||
}
|
||||
updateTile();
|
||||
}
|
||||
// isRunning tracks whether the VPN is running.
|
||||
private static boolean isRunning;
|
||||
|
||||
@Override public void onStopListening() {
|
||||
synchronized (lock) {
|
||||
currentTile = null;
|
||||
}
|
||||
}
|
||||
// currentTile tracks getQsTile while service is listening.
|
||||
private static Tile currentTile;
|
||||
|
||||
@Override public void onClick() {
|
||||
boolean r;
|
||||
synchronized (lock) {
|
||||
r = ready;
|
||||
}
|
||||
if (r) {
|
||||
onTileClick();
|
||||
} else {
|
||||
// Start main activity.
|
||||
Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName());
|
||||
startActivityAndCollapse(i);
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
private static void updateTile() {
|
||||
Tile t;
|
||||
boolean act;
|
||||
synchronized (lock) {
|
||||
t = currentTile;
|
||||
act = active && ready;
|
||||
}
|
||||
if (t == null) {
|
||||
return;
|
||||
}
|
||||
t.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
|
||||
t.updateTile();
|
||||
}
|
||||
static void setVPNRunning(boolean running) {
|
||||
synchronized (lock) {
|
||||
isRunning = running;
|
||||
}
|
||||
updateTile();
|
||||
}
|
||||
|
||||
static void setReady(Context ctx, boolean rdy) {
|
||||
synchronized (lock) {
|
||||
ready = rdy;
|
||||
}
|
||||
updateTile();
|
||||
}
|
||||
@Override
|
||||
public void onStartListening() {
|
||||
synchronized (lock) {
|
||||
currentTile = getQsTile();
|
||||
}
|
||||
updateTile();
|
||||
}
|
||||
|
||||
static void setStatus(Context ctx, boolean act) {
|
||||
synchronized (lock) {
|
||||
active = act;
|
||||
}
|
||||
updateTile();
|
||||
}
|
||||
@Override
|
||||
public void onStopListening() {
|
||||
synchronized (lock) {
|
||||
currentTile = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void onTileClick() {
|
||||
boolean act;
|
||||
synchronized (lock) {
|
||||
act = active && ready;
|
||||
}
|
||||
Intent i = new Intent(act ? IPNReceiver.INTENT_DISCONNECT_VPN : IPNReceiver.INTENT_CONNECT_VPN);
|
||||
i.setPackage(getPackageName());
|
||||
i.setClass(getApplicationContext(), com.tailscale.ipn.IPNReceiver.class);
|
||||
sendBroadcast(i);
|
||||
}
|
||||
@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 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 |
@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M17.6,9.48l1.84,-3.18c0.16,-0.31 0.04,-0.69 -0.26,-0.85c-0.29,-0.15 -0.65,-0.06 -0.83,0.22l-1.88,3.24c-2.86,-1.21 -6.08,-1.21 -8.94,0L5.65,5.67c-0.19,-0.29 -0.58,-0.38 -0.87,-0.2C4.5,5.65 4.41,6.01 4.56,6.3L6.4,9.48C3.3,11.25 1.28,14.44 1,18h22C22.72,14.44 20.7,11.25 17.6,9.48zM7,15.25c-0.69,0 -1.25,-0.56 -1.25,-1.25c0,-0.69 0.56,-1.25 1.25,-1.25S8.25,13.31 8.25,14C8.25,14.69 7.69,15.25 7,15.25zM17,15.25c-0.69,0 -1.25,-0.56 -1.25,-1.25c0,-0.69 0.56,-1.25 1.25,-1.25s1.25,0.56 1.25,1.25C18.25,14.69 17.69,15.25 17,15.25z"/>
|
||||
|
||||
</vector>
|
@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M424,552L338,466Q327,455 310,455Q293,455 282,466Q271,477 271,494Q271,511 282,522L396,636Q408,648 424,648Q440,648 452,636L678,410Q689,399 689,382Q689,365 678,354Q667,343 650,343Q633,343 622,354L424,552ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
|
||||
|
||||
</vector>
|