Compare commits
No commits in common. 'main' and '1.35.108-t692eac23a-g30e46fb8545' have entirely different histories.
main
...
1.35.108-t
@ -1,37 +0,0 @@
|
|||||||
name: Android CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- "*"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check out code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: "go.mod"
|
|
||||||
- name: Switch to Java 17 # Note: 17 is pre-installed on ubuntu-latest
|
|
||||||
uses: actions/setup-java@v3
|
|
||||||
with:
|
|
||||||
distribution: "temurin"
|
|
||||||
java-version: "17"
|
|
||||||
|
|
||||||
# Clean should essentially be a no-op, but make sure that it works.
|
|
||||||
- name: Clean
|
|
||||||
run: make clean
|
|
||||||
|
|
||||||
- name: Build APKs
|
|
||||||
run: make tailscale-debug.apk
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: make test
|
|
@ -0,0 +1,23 @@
|
|||||||
|
name: Build Debug APK
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
if: "!contains(github.event.head_commit.message, '[ci skip]')"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- name: Check out code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Build APK
|
||||||
|
run: make tailscale-debug.apk
|
@ -0,0 +1,67 @@
|
|||||||
|
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@tailscale.com>
|
||||||
|
Committer: License Updater <noreply@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
|
@ -1,36 +0,0 @@
|
|||||||
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)
|
|
@ -1,19 +0,0 @@
|
|||||||
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,47 +1,50 @@
|
|||||||
# This is a Dockerfile for creating a build environment for
|
# This is a Dockerfile for creating a build environment for
|
||||||
# tailscale-android.
|
# tailscale-android.
|
||||||
|
|
||||||
FROM --platform=linux/amd64 eclipse-temurin:20-jdk
|
FROM openjdk:8-jdk
|
||||||
|
|
||||||
# To enable running android tools such as aapt
|
# To enable running android tools such as aapt
|
||||||
RUN apt-get update && apt-get -y upgrade
|
RUN apt-get update && apt-get -y upgrade
|
||||||
RUN apt-get install -y libz1 libstdc++6 unzip
|
RUN apt-get install -y lib32z1 lib32stdc++6
|
||||||
# For Go:
|
# For Go:
|
||||||
RUN apt-get -y --no-install-recommends install curl gcc
|
RUN apt-get -y --no-install-recommends install curl gcc
|
||||||
RUN apt-get -y --no-install-recommends install ca-certificates libc6-dev git
|
RUN apt-get -y --no-install-recommends install ca-certificates libc6-dev git
|
||||||
|
|
||||||
RUN apt-get -y install make
|
RUN apt-get -y install make
|
||||||
|
|
||||||
RUN mkdir -p build
|
RUN mkdir -p BUILD
|
||||||
ENV HOME /build
|
ENV HOME /build
|
||||||
|
|
||||||
# Make android sdk location, the later make step will populate it.
|
# Get android sdk, ndk, and rest of the stuff needed to build the android app.
|
||||||
|
WORKDIR $HOME
|
||||||
RUN mkdir android-sdk
|
RUN mkdir android-sdk
|
||||||
ENV ANDROID_HOME $HOME/android-sdk
|
ENV ANDROID_HOME $HOME/android-sdk
|
||||||
ENV ANDROID_SDK_ROOT $ANDROID_HOME
|
WORKDIR $ANDROID_HOME
|
||||||
|
RUN curl -O https://dl.google.com/android/repository/sdk-tools-linux-3859397.zip
|
||||||
|
RUN echo '444e22ce8ca0f67353bda4b85175ed3731cae3ffa695ca18119cbacef1c1bea0 sdk-tools-linux-3859397.zip' | sha256sum -c
|
||||||
|
RUN unzip sdk-tools-linux-3859397.zip
|
||||||
|
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager --update
|
||||||
|
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'platforms;android-31'
|
||||||
|
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'extras;android;m2repository'
|
||||||
|
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'ndk;23.1.7779620'
|
||||||
|
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'platform-tools'
|
||||||
|
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'build-tools;28.0.3'
|
||||||
|
|
||||||
ENV PATH $PATH:$HOME/bin:$ANDROID_HOME/platform-tools
|
ENV PATH $PATH:$HOME/bin:$ANDROID_HOME/platform-tools
|
||||||
|
ENV ANDROID_SDK_ROOT /build/android-sdk
|
||||||
|
|
||||||
# We need some version of Go new enough to support the "embed" package
|
# We need some version of Go new enough to support the "embed" package
|
||||||
# to run "go run tailscale.com/cmd/printdep" to figure out which Tailscale Go
|
# to run "go run tailscale.com/cmd/printdep" to figure out which Tailscale Go
|
||||||
# version we need later, but otherwise this toolchain isn't used:
|
# version we need later, but otherwise this toolchain isn't used:
|
||||||
RUN curl -L https://go.dev/dl/go1.23.0.linux-amd64.tar.gz | tar -C /usr/local -zxv
|
RUN curl -L https://go.dev/dl/go1.17.5.linux-amd64.tar.gz | tar -C /usr/local -zxv
|
||||||
RUN ln -s /usr/local/go/bin/go /usr/bin
|
RUN ln -s /usr/local/go/bin/go /usr/bin
|
||||||
|
|
||||||
RUN mkdir -p $HOME/tailscale-android
|
RUN mkdir -p $HOME/tailscale-android
|
||||||
RUN git config --global --add safe.directory $HOME/tailscale-android
|
|
||||||
WORKDIR $HOME/tailscale-android
|
WORKDIR $HOME/tailscale-android
|
||||||
|
|
||||||
COPY Makefile Makefile
|
|
||||||
|
|
||||||
# Get android sdk, ndk, and rest of the stuff needed to build the android app.
|
|
||||||
RUN make androidsdk
|
|
||||||
|
|
||||||
# Preload Gradle
|
# Preload Gradle
|
||||||
COPY android/gradlew android/gradlew
|
COPY android/gradlew android/gradlew
|
||||||
COPY android/gradle android/gradle
|
COPY android/gradle android/gradle
|
||||||
RUN ./android/gradlew
|
RUN ./android/gradlew
|
||||||
|
|
||||||
# Run a shell
|
|
||||||
CMD /bin/bash
|
CMD /bin/bash
|
||||||
|
|
||||||
|
|
@ -1,195 +1,57 @@
|
|||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = "1.9.22"
|
|
||||||
ext.compose_version = "1.5.10"
|
|
||||||
ext.accompanist_version = "0.34.0"
|
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
jcenter()
|
||||||
maven {
|
|
||||||
url = uri("https://plugins.gradle.org/m2/")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:8.6.1'
|
classpath 'com.android.tools.build:gradle:4.2.0'
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
|
||||||
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
|
|
||||||
classpath("com.ncorti.ktfmt.gradle:plugin:0.17.0")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
jcenter()
|
||||||
flatDir {
|
flatDir {
|
||||||
dirs 'libs'
|
dirs 'libs'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
apply plugin: 'kotlin-android'
|
|
||||||
apply plugin: 'com.android.application'
|
apply plugin: 'com.android.application'
|
||||||
apply plugin: 'org.jetbrains.kotlin.plugin.serialization'
|
|
||||||
apply plugin: 'com.ncorti.ktfmt.gradle'
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
ndkVersion "23.1.7779620"
|
ndkVersion "23.1.7779620"
|
||||||
compileSdkVersion 34
|
compileSdkVersion 30
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdkVersion 26
|
minSdkVersion 22
|
||||||
targetSdkVersion 34
|
targetSdkVersion 31
|
||||||
versionCode 242
|
versionCode 144
|
||||||
versionName getVersionProperty("VERSION_LONG")
|
versionName "1.35.80-t237f030cd-gfd874ed58e9"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
|
||||||
|
|
||||||
// This setting, which defaults to 'true', will cause Tailscale to fall
|
|
||||||
// back to the Google DNS servers if it cannot determine what the
|
|
||||||
// operating system's DNS configuration is.
|
|
||||||
//
|
|
||||||
// Set it to false either here or in your local.properties file to
|
|
||||||
// disable this behaviour.
|
|
||||||
buildConfigField "boolean", "USE_GOOGLE_DNS_FALLBACK", getLocalProperty("tailscale.useGoogleDnsFallback", "true")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_17
|
sourceCompatibility 1.8
|
||||||
targetCompatibility JavaVersion.VERSION_17
|
targetCompatibility 1.8
|
||||||
}
|
|
||||||
|
|
||||||
lintOptions {
|
|
||||||
warningsAsErrors true
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
|
||||||
buildFeatures {
|
|
||||||
buildConfig true
|
|
||||||
compose true
|
|
||||||
}
|
|
||||||
composeOptions {
|
|
||||||
kotlinCompilerExtensionVersion = "$compose_version"
|
|
||||||
}
|
}
|
||||||
flavorDimensions "version"
|
flavorDimensions "version"
|
||||||
namespace 'com.tailscale.ipn'
|
productFlavors {
|
||||||
|
fdroid {
|
||||||
buildTypes {
|
// The fdroid flavor contains only free dependencies and is suitable
|
||||||
applicationTest {
|
// for the F-Droid app store.
|
||||||
initWith debug
|
|
||||||
manifestPlaceholders.leanbackRequired = false
|
|
||||||
buildConfigField "String", "GITHUB_USERNAME", "\"" + getLocalProperty("githubUsername", "")+"\""
|
|
||||||
buildConfigField "String", "GITHUB_PASSWORD", "\"" + getLocalProperty("githubPassword", "")+"\""
|
|
||||||
buildConfigField "String", "GITHUB_2FA_SECRET", "\"" + getLocalProperty("github2FASecret", "")+"\""
|
|
||||||
}
|
|
||||||
debug {
|
|
||||||
manifestPlaceholders.leanbackRequired = false
|
|
||||||
}
|
|
||||||
release {
|
|
||||||
manifestPlaceholders.leanbackRequired = false
|
|
||||||
|
|
||||||
minifyEnabled true
|
|
||||||
|
|
||||||
shrinkResources true
|
|
||||||
|
|
||||||
proguardFiles getDefaultProguardFile(
|
|
||||||
'proguard-android-optimize.txt'),
|
|
||||||
'proguard-rules.pro'
|
|
||||||
}
|
}
|
||||||
release_tv {
|
play {
|
||||||
initWith release
|
// The play flavor contains all features and is for the Play Store.
|
||||||
manifestPlaceholders.leanbackRequired = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
testBuildType "applicationTest"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Android dependencies.
|
implementation "androidx.core:core:1.2.0"
|
||||||
implementation "androidx.core:core:1.13.1"
|
implementation "androidx.browser:browser:1.2.0"
|
||||||
implementation 'androidx.core:core-ktx:1.13.1'
|
implementation "androidx.security:security-crypto:1.1.0-alpha03"
|
||||||
implementation "androidx.browser:browser:1.8.0"
|
implementation ':ipn@aar'
|
||||||
implementation "androidx.security:security-crypto:1.1.0-alpha06"
|
testCompile "junit:junit:4.12"
|
||||||
implementation "androidx.work:work-runtime:2.9.1"
|
|
||||||
|
// Non-free dependencies.
|
||||||
// Kotlin dependencies.
|
playImplementation 'com.google.android.gms:play-services-auth:18.0.0'
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1"
|
|
||||||
implementation 'junit:junit:4.13.2'
|
|
||||||
runtimeOnly "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
|
|
||||||
|
|
||||||
// Compose dependencies.
|
|
||||||
def composeBom = platform('androidx.compose:compose-bom:2024.09.03')
|
|
||||||
implementation composeBom
|
|
||||||
implementation 'androidx.compose.material3:material3:1.3.0'
|
|
||||||
implementation 'androidx.compose.material:material-icons-core:1.7.3'
|
|
||||||
implementation "androidx.compose.ui:ui:1.7.3"
|
|
||||||
implementation "androidx.compose.ui:ui-tooling:1.7.3"
|
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6'
|
|
||||||
implementation 'androidx.activity:activity-compose:1.9.2'
|
|
||||||
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
|
|
||||||
implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version"
|
|
||||||
implementation "androidx.core:core-splashscreen:1.1.0-rc01"
|
|
||||||
implementation "androidx.compose.animation:animation:1.7.4"
|
|
||||||
|
|
||||||
// Navigation dependencies.
|
|
||||||
def nav_version = "2.8.2"
|
|
||||||
implementation "androidx.navigation:navigation-compose:$nav_version"
|
|
||||||
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
|
|
||||||
|
|
||||||
// Supporting libraries.
|
|
||||||
implementation("io.coil-kt:coil-compose:2.6.0")
|
|
||||||
implementation("com.google.zxing:core:3.5.1")
|
|
||||||
implementation("com.patrykandpatrick.vico:compose:1.15.0")
|
|
||||||
implementation("com.patrykandpatrick.vico:compose-m3:1.15.0")
|
|
||||||
|
|
||||||
// Tailscale dependencies.
|
|
||||||
implementation ':libtailscale@aar'
|
|
||||||
|
|
||||||
// Integration Tests
|
|
||||||
androidTestImplementation composeBom
|
|
||||||
androidTestImplementation 'androidx.test:runner:1.6.2'
|
|
||||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1'
|
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
|
|
||||||
implementation 'androidx.test.uiautomator:uiautomator:2.3.0'
|
|
||||||
|
|
||||||
|
|
||||||
// Authentication only for tests
|
|
||||||
androidTestImplementation 'dev.turingcomplete:kotlin-onetimepassword:2.4.0'
|
|
||||||
androidTestImplementation 'commons-codec:commons-codec:1.16.1'
|
|
||||||
|
|
||||||
// Unit Tests
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
|
||||||
testImplementation 'org.mockito:mockito-core:5.12.0'
|
|
||||||
testImplementation 'org.mockito:mockito-inline:5.2.0'
|
|
||||||
testImplementation 'org.mockito.kotlin:mockito-kotlin:5.4.0'
|
|
||||||
|
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
|
||||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
|
||||||
}
|
|
||||||
|
|
||||||
def getLocalProperty(key, defaultValue) {
|
|
||||||
try {
|
|
||||||
Properties properties = new Properties()
|
|
||||||
properties.load(project.file('local.properties').newDataInputStream())
|
|
||||||
return properties.getProperty(key) ?: defaultValue
|
|
||||||
} catch(Throwable ignored) {
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def getVersionProperty(key) {
|
|
||||||
// tailscale.version is created / updated by the makefile, it is in a loosely
|
|
||||||
// Makfile/envfile format, which is also loosely a properties file format.
|
|
||||||
// make tailscale.version
|
|
||||||
def versionProps = new Properties()
|
|
||||||
versionProps.load(project.file('../tailscale.version').newDataInputStream())
|
|
||||||
return versionProps.getProperty(key).replaceAll('^\"|\"$', '')
|
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionSha256Sum=544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d
|
distributionSha256Sum=3239b5ed86c3838a37d983ac100573f64c1f3fd8e1eb6c89fa5f9529b5ec091d
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-bin.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
@ -1,25 +0,0 @@
|
|||||||
# Keep all classes with native methods
|
|
||||||
-keepclasseswithmembernames class * {
|
|
||||||
native <methods>;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Keep the classes with syspolicy MDM keys, some of which
|
|
||||||
# get used only by the Go backend.
|
|
||||||
-keep class com.tailscale.ipn.mdm.** { *; }
|
|
||||||
|
|
||||||
# Keep specific classes from Tink library
|
|
||||||
-keep class com.google.crypto.tink.** { *; }
|
|
||||||
|
|
||||||
# Ignore warnings about missing Error Prone annotations
|
|
||||||
-dontwarn com.google.errorprone.annotations.**
|
|
||||||
|
|
||||||
# Keep Error Prone annotations if referenced
|
|
||||||
-keep class com.google.errorprone.annotations.** { *; }
|
|
||||||
|
|
||||||
# Keep Google HTTP Client classes
|
|
||||||
-keep class com.google.api.client.http.** { *; }
|
|
||||||
-dontwarn com.google.api.client.http.**
|
|
||||||
|
|
||||||
# Keep Joda-Time classes
|
|
||||||
-keep class org.joda.time.** { *; }
|
|
||||||
-dontwarn org.joda.time.**
|
|
@ -1,163 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn
|
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import android.util.Log
|
|
||||||
import android.widget.Button
|
|
||||||
import android.widget.EditText
|
|
||||||
import androidx.test.ext.junit.rules.activityScenarioRule
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.filters.LargeTest
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
|
||||||
import androidx.test.uiautomator.By
|
|
||||||
import androidx.test.uiautomator.UiDevice
|
|
||||||
import androidx.test.uiautomator.UiSelector
|
|
||||||
import dev.turingcomplete.kotlinonetimepassword.HmacAlgorithm
|
|
||||||
import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordConfig
|
|
||||||
import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordGenerator
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
import java.net.URL
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import kotlin.time.Duration.Companion.minutes
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
import org.apache.commons.codec.binary.Base32
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
@LargeTest
|
|
||||||
class MainActivityTest {
|
|
||||||
companion object {
|
|
||||||
const val TAG = "MainActivityTest"
|
|
||||||
}
|
|
||||||
|
|
||||||
@get:Rule val activityRule = activityScenarioRule<MainActivity>()
|
|
||||||
|
|
||||||
@Before fun setUp() {}
|
|
||||||
|
|
||||||
@After fun tearDown() {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This test starts with a clean install, logs the user in to a tailnet using credentials provided
|
|
||||||
* through a build config, and then makes sure we can hit https://hello.ts.net.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun loginAndVisitHello() {
|
|
||||||
val githubUsername = BuildConfig.GITHUB_USERNAME
|
|
||||||
val githubPassword = BuildConfig.GITHUB_PASSWORD
|
|
||||||
val github2FASecret = Base32().decode(BuildConfig.GITHUB_2FA_SECRET)
|
|
||||||
val config =
|
|
||||||
TimeBasedOneTimePasswordConfig(
|
|
||||||
codeDigits = 6,
|
|
||||||
hmacAlgorithm = HmacAlgorithm.SHA1,
|
|
||||||
timeStep = 30,
|
|
||||||
timeStepUnit = TimeUnit.SECONDS)
|
|
||||||
val githubTOTP = TimeBasedOneTimePasswordGenerator(github2FASecret, config)
|
|
||||||
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
|
|
||||||
|
|
||||||
Log.d(TAG, "Click through Get Started screen")
|
|
||||||
device.find(By.text("Get Started"))
|
|
||||||
device.find(By.text("Get Started")).click()
|
|
||||||
|
|
||||||
Log.d(TAG, "Wait for VPN permission prompt and accept")
|
|
||||||
device.find(By.text("Connection request"))
|
|
||||||
device.find(By.text("OK")).click()
|
|
||||||
|
|
||||||
asNecessary(
|
|
||||||
2.minutes,
|
|
||||||
{
|
|
||||||
Log.d(TAG, "Log in")
|
|
||||||
device.find(By.text("Log in")).click()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Log.d(TAG, "Accept Chrome terms and conditions (if necessary)")
|
|
||||||
device.find(By.text("Welcome to Chrome"))
|
|
||||||
val dismissIndex =
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) 1 else 0
|
|
||||||
device.find(UiSelector().instance(dismissIndex).className(Button::class.java)).click()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Log.d(TAG, "Don't turn on sync")
|
|
||||||
device.find(By.text("Turn on sync?"))
|
|
||||||
device.find(By.text("No thanks")).click()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Log.d(TAG, "Log in with GitHub")
|
|
||||||
device.find(By.text("Sign in with GitHub")).click()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Log.d(TAG, "Make sure GitHub page has loaded")
|
|
||||||
device.find(By.text("Username or email address"))
|
|
||||||
device.find(By.text("Sign in"))
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Log.d(TAG, "Enter credentials")
|
|
||||||
device
|
|
||||||
.find(UiSelector().instance(0).className(EditText::class.java))
|
|
||||||
.setText(githubUsername)
|
|
||||||
device
|
|
||||||
.find(UiSelector().instance(1).className(EditText::class.java))
|
|
||||||
.setText(githubPassword)
|
|
||||||
device.find(By.text("Sign in")).click()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Log.d(TAG, "Enter 2FA")
|
|
||||||
device.find(By.text("Two-factor authentication"))
|
|
||||||
device
|
|
||||||
.find(UiSelector().instance(0).className(EditText::class.java))
|
|
||||||
.setText(githubTOTP.generate())
|
|
||||||
device.find(UiSelector().instance(0).className(Button::class.java)).click()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Log.d(TAG, "Authorizing Tailscale")
|
|
||||||
device.find(By.text("Authorize tailscale")).click()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Log.d(TAG, "Accept Tailscale app")
|
|
||||||
device.find(By.text("Learn more about OAuth"))
|
|
||||||
// Sleep a little to give button time to activate
|
|
||||||
|
|
||||||
Thread.sleep(5.seconds.inWholeMilliseconds)
|
|
||||||
device.find(UiSelector().instance(1).className(Button::class.java)).click()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Log.d(TAG, "Connect device")
|
|
||||||
device.find(By.text("Connect device"))
|
|
||||||
device.find(UiSelector().instance(0).className(Button::class.java)).click()
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
Log.d(TAG, "Accept Permission (Either Storage or Notifications)")
|
|
||||||
device.find(By.text("Continue")).click()
|
|
||||||
device.find(By.text("Allow")).click()
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
// we're not always prompted for permissions, that's okay
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(TAG, "Wait for VPN to connect")
|
|
||||||
device.find(By.text("Connected"))
|
|
||||||
|
|
||||||
val helloResponse = helloTSNet
|
|
||||||
Assert.assertTrue(
|
|
||||||
"Response from hello.ts.net should show success",
|
|
||||||
helloResponse.contains("You're connected over Tailscale!"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val helloTSNet: String
|
|
||||||
get() {
|
|
||||||
return URL("https://hello.ts.net").run {
|
|
||||||
openConnection().run {
|
|
||||||
this as HttpURLConnection
|
|
||||||
connectTimeout = 30000
|
|
||||||
readTimeout = 5000
|
|
||||||
inputStream.bufferedReader().readText()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,92 +0,0 @@
|
|||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
|
Binary file not shown.
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
@ -0,0 +1,404 @@
|
|||||||
|
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package com.tailscale.ipn;
|
||||||
|
|
||||||
|
import android.app.Application;
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.app.DownloadManager;
|
||||||
|
import android.app.Fragment;
|
||||||
|
import android.app.FragmentTransaction;
|
||||||
|
import android.app.NotificationChannel;
|
||||||
|
import android.app.PendingIntent;
|
||||||
|
import android.app.UiModeManager;
|
||||||
|
import android.content.BroadcastReceiver;
|
||||||
|
import android.content.ContentResolver;
|
||||||
|
import android.content.ContentValues;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.IntentFilter;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.content.pm.PackageInfo;
|
||||||
|
import android.content.pm.Signature;
|
||||||
|
import android.content.res.Configuration;
|
||||||
|
import android.provider.MediaStore;
|
||||||
|
import android.provider.Settings;
|
||||||
|
import android.net.ConnectivityManager;
|
||||||
|
import android.net.LinkProperties;
|
||||||
|
import android.net.Network;
|
||||||
|
import android.net.NetworkInfo;
|
||||||
|
import android.net.NetworkRequest;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.net.VpnService;
|
||||||
|
import android.view.View;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Environment;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.webkit.MimeTypeMap;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
|
||||||
|
import java.lang.StringBuilder;
|
||||||
|
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.InterfaceAddress;
|
||||||
|
import java.net.NetworkInterface;
|
||||||
|
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
import androidx.core.app.NotificationCompat;
|
||||||
|
import androidx.core.app.NotificationManagerCompat;
|
||||||
|
|
||||||
|
import androidx.core.content.ContextCompat;
|
||||||
|
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences;
|
||||||
|
import androidx.security.crypto.MasterKey;
|
||||||
|
|
||||||
|
import androidx.browser.customtabs.CustomTabsIntent;
|
||||||
|
|
||||||
|
import org.gioui.Gio;
|
||||||
|
|
||||||
|
public class App extends Application {
|
||||||
|
private final static String PEER_TAG = "peer";
|
||||||
|
|
||||||
|
static final String STATUS_CHANNEL_ID = "tailscale-status";
|
||||||
|
static final int STATUS_NOTIFICATION_ID = 1;
|
||||||
|
|
||||||
|
static final String NOTIFY_CHANNEL_ID = "tailscale-notify";
|
||||||
|
static final int NOTIFY_NOTIFICATION_ID = 2;
|
||||||
|
|
||||||
|
private static final String FILE_CHANNEL_ID = "tailscale-files";
|
||||||
|
private static final int FILE_NOTIFICATION_ID = 3;
|
||||||
|
|
||||||
|
private final static Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||||
|
|
||||||
|
public DnsConfig dns = new DnsConfig(this);
|
||||||
|
public DnsConfig getDnsConfigObj() { return this.dns; }
|
||||||
|
|
||||||
|
@Override public void onCreate() {
|
||||||
|
super.onCreate();
|
||||||
|
// Load and initialize the Go library.
|
||||||
|
Gio.init(this);
|
||||||
|
registerNetworkCallback();
|
||||||
|
|
||||||
|
createNotificationChannel(NOTIFY_CHANNEL_ID, "Notifications", NotificationManagerCompat.IMPORTANCE_DEFAULT);
|
||||||
|
createNotificationChannel(STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW);
|
||||||
|
createNotificationChannel(FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private void registerNetworkCallback() {
|
||||||
|
ConnectivityManager cMgr = (ConnectivityManager) this.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||||
|
cMgr.registerNetworkCallback(new NetworkRequest.Builder().build(), new ConnectivityManager.NetworkCallback() {
|
||||||
|
private void reportConnectivityChange() {
|
||||||
|
NetworkInfo active = cMgr.getActiveNetworkInfo();
|
||||||
|
// https://developer.android.com/training/monitoring-device-state/connectivity-status-type
|
||||||
|
boolean isConnected = active != null && active.isConnectedOrConnecting();
|
||||||
|
onConnectivityChanged(isConnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLost(Network network) {
|
||||||
|
super.onLost(network);
|
||||||
|
this.reportConnectivityChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLinkPropertiesChanged(Network network, LinkProperties linkProperties) {
|
||||||
|
super.onLinkPropertiesChanged(network, linkProperties);
|
||||||
|
this.reportConnectivityChange();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void startVPN() {
|
||||||
|
Intent intent = new Intent(this, IPNService.class);
|
||||||
|
intent.setAction(IPNService.ACTION_CONNECT);
|
||||||
|
startService(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stopVPN() {
|
||||||
|
Intent intent = new Intent(this, IPNService.class);
|
||||||
|
intent.setAction(IPNService.ACTION_DISCONNECT);
|
||||||
|
startService(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptToPref a byte array of data using the Jetpack Security
|
||||||
|
// library and writes it to a global encrypted preference store.
|
||||||
|
public void encryptToPref(String prefKey, String plaintext) throws IOException, GeneralSecurityException {
|
||||||
|
getEncryptedPrefs().edit().putString(prefKey, plaintext).commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptFromPref decrypts a encrypted preference using the Jetpack Security
|
||||||
|
// library and returns the plaintext.
|
||||||
|
public String decryptFromPref(String prefKey) throws IOException, GeneralSecurityException {
|
||||||
|
return getEncryptedPrefs().getString(prefKey, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SharedPreferences getEncryptedPrefs() throws IOException, GeneralSecurityException {
|
||||||
|
MasterKey key = new MasterKey.Builder(this)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return EncryptedSharedPreferences.create(
|
||||||
|
this,
|
||||||
|
"secret_shared_prefs",
|
||||||
|
key,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setTileReady(boolean ready) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QuickToggleService.setReady(this, ready);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 nameFromSystemBluetooth = Settings.System.getString(getContentResolver(), "bluetooth_name");
|
||||||
|
String nameFromSecureBluetooth = Settings.Secure.getString(getContentResolver(), "bluetooth_name");
|
||||||
|
String nameFromSystemDevice = Settings.Secure.getString(getContentResolver(), "device_name");
|
||||||
|
|
||||||
|
if (!isEmpty(nameFromSystemBluetooth)) return nameFromSystemBluetooth;
|
||||||
|
if (!isEmpty(nameFromSecureBluetooth)) return nameFromSecureBluetooth;
|
||||||
|
if (!isEmpty(nameFromSystemDevice)) return nameFromSystemDevice;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isEmpty(String str) {
|
||||||
|
return str == null || str.length() == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createNotificationChannel(String id, String name, int importance) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NotificationChannel channel = new NotificationChannel(id, name, importance);
|
||||||
|
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
|
||||||
|
nm.createNotificationChannel(channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
static native void onVPNPrepared();
|
||||||
|
private static native void onConnectivityChanged(boolean connected);
|
||||||
|
static native void onShareIntent(int nfiles, int[] types, String[] mimes, String[] items, String[] names, long[] sizes);
|
||||||
|
static native void onWriteStorageGranted();
|
||||||
|
|
||||||
|
// Returns details of the interfaces in the system, encoded as a single string for ease
|
||||||
|
// of JNI transfer over to the Go environment.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// rmnet_data0 10 2000 true false false false false | fe80::4059:dc16:7ed3:9c6e%rmnet_data0/64
|
||||||
|
// dummy0 3 1500 true false false false false | fe80::1450:5cff:fe13:f891%dummy0/64
|
||||||
|
// wlan0 30 1500 true true false false true | fe80::2f60:2c82:4163:8389%wlan0/64 10.1.10.131/24
|
||||||
|
// r_rmnet_data0 21 1500 true false false false false | fe80::9318:6093:d1ad:ba7f%r_rmnet_data0/64
|
||||||
|
// rmnet_data2 12 1500 true false false false false | fe80::3c8c:44dc:46a9:9907%rmnet_data2/64
|
||||||
|
// r_rmnet_data1 22 1500 true false false false false | fe80::b6cd:5cb0:8ae6:fe92%r_rmnet_data1/64
|
||||||
|
// rmnet_data1 11 1500 true false false false false | fe80::51f2:ee00:edce:d68b%rmnet_data1/64
|
||||||
|
// lo 1 65536 true false true false false | ::1/128 127.0.0.1/8
|
||||||
|
// v4-rmnet_data2 68 1472 true true false true true | 192.0.0.4/32
|
||||||
|
//
|
||||||
|
// Where the fields are:
|
||||||
|
// name ifindex mtu isUp hasBroadcast isLoopback isPointToPoint hasMulticast | ip1/N ip2/N ip3/N;
|
||||||
|
String getInterfacesAsString() {
|
||||||
|
List<NetworkInterface> interfaces;
|
||||||
|
try {
|
||||||
|
interfaces = Collections.list(NetworkInterface.getNetworkInterfaces());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder("");
|
||||||
|
for (NetworkInterface nif : interfaces) {
|
||||||
|
try {
|
||||||
|
// Android doesn't have a supportsBroadcast() but the Go net.Interface wants
|
||||||
|
// one, so we say the interface has broadcast if it has multicast.
|
||||||
|
sb.append(String.format(java.util.Locale.ROOT, "%s %d %d %b %b %b %b %b |", nif.getName(),
|
||||||
|
nif.getIndex(), nif.getMTU(), nif.isUp(), nif.supportsMulticast(),
|
||||||
|
nif.isLoopback(), nif.isPointToPoint(), nif.supportsMulticast()));
|
||||||
|
|
||||||
|
for (InterfaceAddress ia : nif.getInterfaceAddresses()) {
|
||||||
|
// InterfaceAddress == hostname + "/" + IP
|
||||||
|
String[] parts = ia.toString().split("/", 0);
|
||||||
|
if (parts.length > 1) {
|
||||||
|
sb.append(String.format(java.util.Locale.ROOT, "%s/%d ", parts[1], ia.getNetworkPrefixLength()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// TODO(dgentry) should log the exception not silently suppress it.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sb.append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isTV() {
|
||||||
|
UiModeManager mm = (UiModeManager)getSystemService(UI_MODE_SERVICE);
|
||||||
|
return mm.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION;
|
||||||
|
}
|
||||||
|
}
|
@ -1,587 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
package com.tailscale.ipn
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.app.Application
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Environment
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.app.ActivityCompat
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import androidx.lifecycle.ViewModelProvider
|
|
||||||
import androidx.lifecycle.ViewModelStore
|
|
||||||
import androidx.lifecycle.ViewModelStoreOwner
|
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
|
||||||
import androidx.security.crypto.MasterKey
|
|
||||||
import com.tailscale.ipn.mdm.MDMSettings
|
|
||||||
import com.tailscale.ipn.ui.localapi.Client
|
|
||||||
import com.tailscale.ipn.ui.localapi.Request
|
|
||||||
import com.tailscale.ipn.ui.model.Ipn
|
|
||||||
import com.tailscale.ipn.ui.notifier.HealthNotifier
|
|
||||||
import com.tailscale.ipn.ui.notifier.Notifier
|
|
||||||
import com.tailscale.ipn.ui.viewModel.VpnViewModel
|
|
||||||
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
|
|
||||||
import com.tailscale.ipn.util.TSLog
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import libtailscale.Libtailscale
|
|
||||||
import java.io.File
|
|
||||||
import java.io.IOException
|
|
||||||
import java.net.NetworkInterface
|
|
||||||
import java.security.GeneralSecurityException
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
|
|
||||||
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val FILE_CHANNEL_ID = "tailscale-files"
|
|
||||||
private const val TAG = "App"
|
|
||||||
private lateinit var appInstance: App
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the app (if necessary) and returns the singleton app instance. Always use this
|
|
||||||
* function to obtain an App reference to make sure the app initializes.
|
|
||||||
*/
|
|
||||||
@JvmStatic
|
|
||||||
fun get(): App {
|
|
||||||
appInstance.initOnce()
|
|
||||||
return appInstance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val dns = DnsConfig()
|
|
||||||
private lateinit var connectivityManager: ConnectivityManager
|
|
||||||
private lateinit var app: libtailscale.Application
|
|
||||||
|
|
||||||
override val viewModelStore: ViewModelStore
|
|
||||||
get() = appViewModelStore
|
|
||||||
|
|
||||||
private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() }
|
|
||||||
|
|
||||||
var healthNotifier: HealthNotifier? = null
|
|
||||||
|
|
||||||
override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString
|
|
||||||
|
|
||||||
override fun getInstallSource(): String = AppSourceChecker.getInstallSource(this)
|
|
||||||
|
|
||||||
override fun shouldUseGoogleDNSFallback(): Boolean = BuildConfig.USE_GOOGLE_DNS_FALLBACK
|
|
||||||
|
|
||||||
override fun log(s: String, s1: String) {
|
|
||||||
Log.d(s, s1)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
createNotificationChannel(
|
|
||||||
STATUS_CHANNEL_ID,
|
|
||||||
getString(R.string.vpn_status),
|
|
||||||
getString(R.string.optional_notifications_which_display_the_status_of_the_vpn_tunnel),
|
|
||||||
NotificationManagerCompat.IMPORTANCE_MIN)
|
|
||||||
createNotificationChannel(
|
|
||||||
FILE_CHANNEL_ID,
|
|
||||||
getString(R.string.taildrop_file_transfers),
|
|
||||||
getString(R.string.notifications_delivered_when_a_file_is_received_using_taildrop),
|
|
||||||
NotificationManagerCompat.IMPORTANCE_DEFAULT)
|
|
||||||
createNotificationChannel(
|
|
||||||
HealthNotifier.HEALTH_CHANNEL_ID,
|
|
||||||
getString(R.string.health_channel_name),
|
|
||||||
getString(R.string.health_channel_description),
|
|
||||||
NotificationManagerCompat.IMPORTANCE_HIGH)
|
|
||||||
appInstance = this
|
|
||||||
setUnprotectedInstance(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTerminate() {
|
|
||||||
super.onTerminate()
|
|
||||||
Notifier.stop()
|
|
||||||
notificationManager.cancelAll()
|
|
||||||
applicationScope.cancel()
|
|
||||||
viewModelStore.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isInitialized = false
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun initOnce() {
|
|
||||||
if (isInitialized) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isInitialized = true
|
|
||||||
|
|
||||||
val dataDir = this.filesDir.absolutePath
|
|
||||||
|
|
||||||
// Set this to enable direct mode for taildrop whereby downloads will be saved directly
|
|
||||||
// to the given folder. We will preferentially use <shared>/Downloads and fallback to
|
|
||||||
// an app local directory "Taildrop" if we cannot create that. This mode does not support
|
|
||||||
// user notifications for incoming files.
|
|
||||||
val directFileDir = this.prepareDownloadsFolder()
|
|
||||||
app = Libtailscale.start(dataDir, directFileDir.absolutePath, this)
|
|
||||||
Request.setApp(app)
|
|
||||||
Notifier.setApp(app)
|
|
||||||
Notifier.start(applicationScope)
|
|
||||||
healthNotifier = HealthNotifier(Notifier.health, applicationScope)
|
|
||||||
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
||||||
NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
|
|
||||||
initViewModels()
|
|
||||||
applicationScope.launch {
|
|
||||||
Notifier.state.collect { state ->
|
|
||||||
combine(Notifier.state, MDMSettings.forceEnabled.flow) { state, forceEnabled ->
|
|
||||||
Pair(state, forceEnabled)
|
|
||||||
}
|
|
||||||
.collect { (state, hideDisconnectAction) ->
|
|
||||||
val ableToStartVPN = state > Ipn.State.NeedsMachineAuth
|
|
||||||
// If VPN is stopped, show a disconnected notification. If it is running as a
|
|
||||||
// foreground
|
|
||||||
// service, IPNService will show a connected notification.
|
|
||||||
if (state == Ipn.State.Stopped) {
|
|
||||||
notifyStatus(vpnRunning = false, hideDisconnectAction = hideDisconnectAction.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running
|
|
||||||
updateConnStatus(ableToStartVPN)
|
|
||||||
QuickToggleService.setVPNRunning(vpnRunning)
|
|
||||||
|
|
||||||
// Update notification status when VPN is running
|
|
||||||
if (vpnRunning) {
|
|
||||||
notifyStatus(vpnRunning = true, hideDisconnectAction = hideDisconnectAction.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
applicationScope.launch {
|
|
||||||
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initViewModels() {
|
|
||||||
vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setWantRunning(wantRunning: Boolean, onSuccess: (() -> Unit)? = null) {
|
|
||||||
val callback: (Result<Ipn.Prefs>) -> Unit = { result ->
|
|
||||||
result.fold(
|
|
||||||
onSuccess = { onSuccess?.invoke() },
|
|
||||||
onFailure = { error ->
|
|
||||||
TSLog.d("TAG", "Set want running: failed to update preferences: ${error.message}")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Client(applicationScope)
|
|
||||||
.editPrefs(Ipn.MaskedPrefs().apply { WantRunning = wantRunning }, callback)
|
|
||||||
}
|
|
||||||
|
|
||||||
// encryptToPref a byte array of data using the Jetpack Security
|
|
||||||
// library and writes it to a global encrypted preference store.
|
|
||||||
@Throws(IOException::class, GeneralSecurityException::class)
|
|
||||||
override fun encryptToPref(prefKey: String?, plaintext: String?) {
|
|
||||||
getEncryptedPrefs().edit().putString(prefKey, plaintext).commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
// decryptFromPref decrypts a encrypted preference using the Jetpack Security
|
|
||||||
// library and returns the plaintext.
|
|
||||||
@Throws(IOException::class, GeneralSecurityException::class)
|
|
||||||
override fun decryptFromPref(prefKey: String?): String? {
|
|
||||||
return getEncryptedPrefs().getString(prefKey, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class, GeneralSecurityException::class)
|
|
||||||
fun getEncryptedPrefs(): SharedPreferences {
|
|
||||||
val key = MasterKey.Builder(this).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
|
|
||||||
|
|
||||||
return EncryptedSharedPreferences.create(
|
|
||||||
this,
|
|
||||||
"secret_shared_prefs",
|
|
||||||
key,
|
|
||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* setAbleToStartVPN remembers whether or not we're able to start the VPN
|
|
||||||
* by storing this in a shared preference. This allows us to check this
|
|
||||||
* value without needing a fully initialized instance of the application.
|
|
||||||
*/
|
|
||||||
private fun updateConnStatus(ableToStartVPN: Boolean) {
|
|
||||||
setAbleToStartVPN(ableToStartVPN)
|
|
||||||
QuickToggleService.updateTile()
|
|
||||||
TSLog.d("App", "Set Tile Ready: $ableToStartVPN")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getModelName(): String {
|
|
||||||
val manu = Build.MANUFACTURER
|
|
||||||
var model = Build.MODEL
|
|
||||||
// Strip manufacturer from model.
|
|
||||||
val idx = model.lowercase(Locale.getDefault()).indexOf(manu.lowercase(Locale.getDefault()))
|
|
||||||
if (idx != -1) {
|
|
||||||
model = model.substring(idx + manu.length).trim()
|
|
||||||
}
|
|
||||||
return "$manu $model"
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getOSVersion(): String = Build.VERSION.RELEASE
|
|
||||||
|
|
||||||
override fun isChromeOS(): Boolean {
|
|
||||||
return packageManager.hasSystemFeature("android.hardware.type.pc")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getInterfacesAsString(): String {
|
|
||||||
val interfaces: ArrayList<NetworkInterface> =
|
|
||||||
java.util.Collections.list(NetworkInterface.getNetworkInterfaces())
|
|
||||||
|
|
||||||
val sb = StringBuilder()
|
|
||||||
for (nif in interfaces) {
|
|
||||||
try {
|
|
||||||
sb.append(
|
|
||||||
String.format(
|
|
||||||
Locale.ROOT,
|
|
||||||
"%s %d %d %b %b %b %b %b |",
|
|
||||||
nif.name,
|
|
||||||
nif.index,
|
|
||||||
nif.mtu,
|
|
||||||
nif.isUp,
|
|
||||||
nif.supportsMulticast(),
|
|
||||||
nif.isLoopback,
|
|
||||||
nif.isPointToPoint,
|
|
||||||
nif.supportsMulticast()))
|
|
||||||
|
|
||||||
for (ia in nif.interfaceAddresses) {
|
|
||||||
val parts = ia.toString().split("/", limit = 0)
|
|
||||||
if (parts.size > 1) {
|
|
||||||
sb.append(String.format(Locale.ROOT, "%s/%d ", parts[1], ia.networkPrefixLength))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sb.append("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun prepareDownloadsFolder(): File {
|
|
||||||
var downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!downloads.exists()) {
|
|
||||||
downloads.mkdirs()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
TSLog.e(TAG, "Failed to create downloads folder: $e")
|
|
||||||
downloads = File(this.filesDir, "Taildrop")
|
|
||||||
try {
|
|
||||||
if (!downloads.exists()) {
|
|
||||||
downloads.mkdirs()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
TSLog.e(TAG, "Failed to create Taildrop folder: $e")
|
|
||||||
downloads = File("")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return downloads
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(
|
|
||||||
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
|
|
||||||
override fun getSyspolicyBooleanValue(key: String): Boolean {
|
|
||||||
return getSyspolicyStringValue(key) == "true"
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(
|
|
||||||
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
|
|
||||||
override fun getSyspolicyStringValue(key: String): String {
|
|
||||||
val setting = MDMSettings.allSettingsByKey[key]?.flow?.value
|
|
||||||
if (setting?.isSet != true) {
|
|
||||||
throw MDMSettings.NoSuchKeyException()
|
|
||||||
}
|
|
||||||
return setting.value?.toString() ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(
|
|
||||||
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
|
|
||||||
override fun getSyspolicyStringArrayJSONValue(key: String): String {
|
|
||||||
val setting = MDMSettings.allSettingsByKey[key]?.flow?.value
|
|
||||||
if (setting?.isSet != true) {
|
|
||||||
throw MDMSettings.NoSuchKeyException()
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val list = setting.value as? List<*>
|
|
||||||
return Json.encodeToString(list)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
TSLog.d("MDM", "$key value cannot be serialized to JSON. Throwing NoSuchKeyException.")
|
|
||||||
throw MDMSettings.NoSuchKeyException()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun notifyPolicyChanged() {
|
|
||||||
app.notifyPolicyChanged()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UninitializedApp contains all of the methods of App that can be used without having to initialize
|
|
||||||
* the Go backend. This is useful when you want to access functions on the App without creating side
|
|
||||||
* effects from starting the Go backend (such as launching the VPN).
|
|
||||||
*/
|
|
||||||
open class UninitializedApp : Application() {
|
|
||||||
companion object {
|
|
||||||
const val TAG = "UninitializedApp"
|
|
||||||
|
|
||||||
const val STATUS_NOTIFICATION_ID = 1
|
|
||||||
const val STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID = 2
|
|
||||||
const val STATUS_CHANNEL_ID = "tailscale-status"
|
|
||||||
|
|
||||||
// Key for shared preference that tracks whether or not we're able to start
|
|
||||||
// the VPN (i.e. we're logged in and machine is authorized).
|
|
||||||
private const val ABLE_TO_START_VPN_KEY = "ableToStartVPN"
|
|
||||||
|
|
||||||
private const val DISALLOWED_APPS_KEY = "disallowedApps"
|
|
||||||
|
|
||||||
// File for shared preferences that are not encrypted.
|
|
||||||
private const val UNENCRYPTED_PREFERENCES = "unencrypted"
|
|
||||||
|
|
||||||
private lateinit var appInstance: UninitializedApp
|
|
||||||
lateinit var notificationManager: NotificationManagerCompat
|
|
||||||
|
|
||||||
lateinit var vpnViewModel: VpnViewModel
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
fun get(): UninitializedApp {
|
|
||||||
return appInstance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun setUnprotectedInstance(instance: UninitializedApp) {
|
|
||||||
appInstance = instance
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun setAbleToStartVPN(rdy: Boolean) {
|
|
||||||
getUnencryptedPrefs().edit().putBoolean(ABLE_TO_START_VPN_KEY, rdy).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** This function can be called without initializing the App. */
|
|
||||||
fun isAbleToStartVPN(): Boolean {
|
|
||||||
return getUnencryptedPrefs().getBoolean(ABLE_TO_START_VPN_KEY, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getUnencryptedPrefs(): SharedPreferences {
|
|
||||||
return getSharedPreferences(UNENCRYPTED_PREFERENCES, MODE_PRIVATE)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startVPN() {
|
|
||||||
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_START_VPN }
|
|
||||||
// FLAG_UPDATE_CURRENT ensures that if the intent is already pending, the existing intent will
|
|
||||||
// be updated rather than creating multiple redundant instances.
|
|
||||||
val pendingIntent =
|
|
||||||
PendingIntent.getService(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
intent,
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or
|
|
||||||
PendingIntent.FLAG_IMMUTABLE // FLAG_IMMUTABLE for Android 12+
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
pendingIntent.send()
|
|
||||||
} catch (foregroundServiceStartException: IllegalStateException) {
|
|
||||||
TSLog.e(
|
|
||||||
TAG,
|
|
||||||
"startVPN hit ForegroundServiceStartNotAllowedException: $foregroundServiceStartException")
|
|
||||||
} catch (securityException: SecurityException) {
|
|
||||||
TSLog.e(TAG, "startVPN hit SecurityException: $securityException")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
TSLog.e(TAG, "startVPN hit exception: $e")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stopVPN() {
|
|
||||||
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_STOP_VPN }
|
|
||||||
try {
|
|
||||||
startService(intent)
|
|
||||||
} catch (illegalStateException: IllegalStateException) {
|
|
||||||
TSLog.e(TAG, "stopVPN hit IllegalStateException in startService(): $illegalStateException")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
TSLog.e(TAG, "stopVPN hit exception in startService(): $e")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun restartVPN() {
|
|
||||||
// Register a receiver to listen for the completion of stopVPN
|
|
||||||
TSLog.d("KARI", "hi")
|
|
||||||
val stopReceiver =
|
|
||||||
object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
// Ensure stop intent is complete
|
|
||||||
if (intent?.action == IPNService.ACTION_STOP_VPN) {
|
|
||||||
// Unregister receiver after receiving the broadcast
|
|
||||||
context?.unregisterReceiver(this)
|
|
||||||
// Now start the VPN
|
|
||||||
startVPN()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register the receiver before stopping VPN
|
|
||||||
val intentFilter = IntentFilter(IPNService.ACTION_STOP_VPN)
|
|
||||||
this.registerReceiver(stopReceiver, intentFilter)
|
|
||||||
|
|
||||||
stopVPN()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createNotificationChannel(id: String, name: String, description: String, importance: Int) {
|
|
||||||
val channel = NotificationChannel(id, name, importance)
|
|
||||||
channel.description = description
|
|
||||||
notificationManager = NotificationManagerCompat.from(this)
|
|
||||||
notificationManager.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun notifyStatus(vpnRunning: Boolean, hideDisconnectAction: Boolean) {
|
|
||||||
notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun notifyStatus(notification: Notification) {
|
|
||||||
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
|
|
||||||
PackageManager.PERMISSION_GRANTED) {
|
|
||||||
// TODO: Consider calling
|
|
||||||
// ActivityCompat#requestPermissions
|
|
||||||
// here to request the missing permissions, and then overriding
|
|
||||||
// public void onRequestPermissionsResult(int requestCode, String[] permissions,
|
|
||||||
// int[] grantResults)
|
|
||||||
// to handle the case where the user grants the permission. See the documentation
|
|
||||||
// for ActivityCompat#requestPermissions for more details.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
notificationManager.notify(STATUS_NOTIFICATION_ID, notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun buildStatusNotification(vpnRunning: Boolean, hideDisconnectAction: Boolean): Notification {
|
|
||||||
val message = getString(if (vpnRunning) R.string.connected else R.string.not_connected)
|
|
||||||
val icon = if (vpnRunning) R.drawable.ic_notification else R.drawable.ic_notification_disabled
|
|
||||||
val action =
|
|
||||||
if (vpnRunning) IPNReceiver.INTENT_DISCONNECT_VPN else IPNReceiver.INTENT_CONNECT_VPN
|
|
||||||
val actionLabel = getString(if (vpnRunning) R.string.disconnect else R.string.connect)
|
|
||||||
val buttonIntent = Intent(this, IPNReceiver::class.java).apply { this.action = action }
|
|
||||||
val pendingButtonIntent: PendingIntent =
|
|
||||||
PendingIntent.getBroadcast(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
buttonIntent,
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
|
||||||
|
|
||||||
val intent =
|
|
||||||
Intent(this, MainActivity::class.java).apply {
|
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
|
||||||
}
|
|
||||||
val pendingIntent: PendingIntent =
|
|
||||||
PendingIntent.getActivity(
|
|
||||||
this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
|
||||||
|
|
||||||
val builder =
|
|
||||||
NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
|
|
||||||
.setSmallIcon(icon)
|
|
||||||
.setContentTitle(getString(R.string.app_name))
|
|
||||||
.setContentText(message)
|
|
||||||
.setAutoCancel(!vpnRunning)
|
|
||||||
.setOnlyAlertOnce(!vpnRunning)
|
|
||||||
.setOngoing(vpnRunning)
|
|
||||||
.setSilent(true)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
|
||||||
.setContentIntent(pendingIntent)
|
|
||||||
if (!vpnRunning || !hideDisconnectAction) {
|
|
||||||
builder.addAction(
|
|
||||||
NotificationCompat.Action.Builder(0, actionLabel, pendingButtonIntent).build())
|
|
||||||
}
|
|
||||||
return builder.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addUserDisallowedPackageName(packageName: String) {
|
|
||||||
if (packageName.isEmpty()) {
|
|
||||||
TSLog.e(TAG, "addUserDisallowedPackageName called with empty packageName")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
getUnencryptedPrefs()
|
|
||||||
.edit()
|
|
||||||
.putStringSet(
|
|
||||||
DISALLOWED_APPS_KEY, disallowedPackageNames().toMutableSet().union(setOf(packageName)))
|
|
||||||
.apply()
|
|
||||||
|
|
||||||
this.restartVPN()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeUserDisallowedPackageName(packageName: String) {
|
|
||||||
if (packageName.isEmpty()) {
|
|
||||||
TSLog.e(TAG, "removeUserDisallowedPackageName called with empty packageName")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
getUnencryptedPrefs()
|
|
||||||
.edit()
|
|
||||||
.putStringSet(
|
|
||||||
DISALLOWED_APPS_KEY,
|
|
||||||
disallowedPackageNames().toMutableSet().subtract(setOf(packageName)))
|
|
||||||
.apply()
|
|
||||||
|
|
||||||
this.restartVPN()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun disallowedPackageNames(): List<String> {
|
|
||||||
val mdmDisallowed =
|
|
||||||
MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
|
|
||||||
if (mdmDisallowed.isNotEmpty()) {
|
|
||||||
TSLog.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed")
|
|
||||||
return builtInDisallowedPackageNames + mdmDisallowed
|
|
||||||
}
|
|
||||||
val userDisallowed =
|
|
||||||
getUnencryptedPrefs().getStringSet(DISALLOWED_APPS_KEY, emptySet())?.toList() ?: emptyList()
|
|
||||||
return builtInDisallowedPackageNames + userDisallowed
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAppScopedViewModel(): VpnViewModel {
|
|
||||||
return vpnViewModel
|
|
||||||
}
|
|
||||||
|
|
||||||
val builtInDisallowedPackageNames: List<String> =
|
|
||||||
listOf(
|
|
||||||
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
|
|
||||||
"com.google.android.apps.messaging",
|
|
||||||
// Android Auto https://github.com/tailscale/tailscale/issues/3828
|
|
||||||
"com.google.android.projection.gearhead",
|
|
||||||
// GoPro https://github.com/tailscale/tailscale/issues/2554
|
|
||||||
"com.gopro.smarty",
|
|
||||||
// Sonos https://github.com/tailscale/tailscale/issues/2548
|
|
||||||
"com.sonos.acr",
|
|
||||||
"com.sonos.acr2",
|
|
||||||
// Google Chromecast https://github.com/tailscale/tailscale/issues/3636
|
|
||||||
"com.google.android.apps.chromecast.app",
|
|
||||||
// Voicemail https://github.com/tailscale/tailscale/issues/13199
|
|
||||||
"com.samsung.attvvm",
|
|
||||||
"com.att.mobile.android.vvm",
|
|
||||||
"com.tmobile.vvm.application",
|
|
||||||
"com.metropcs.service.vvm",
|
|
||||||
"com.mizmowireless.vvm",
|
|
||||||
"com.vna.service.vvm",
|
|
||||||
"com.dish.vvm",
|
|
||||||
"com.comcast.modesto.vvm.client",
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,35 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
package com.tailscale.ipn
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Build
|
|
||||||
import android.util.Log
|
|
||||||
|
|
||||||
object AppSourceChecker {
|
|
||||||
|
|
||||||
const val TAG = "AppSourceChecker"
|
|
||||||
|
|
||||||
fun getInstallSource(context: Context): String {
|
|
||||||
val packageManager = context.packageManager
|
|
||||||
val packageName = context.packageName
|
|
||||||
Log.d(TAG, "Package name: $packageName")
|
|
||||||
|
|
||||||
val installerPackageName =
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
||||||
packageManager.getInstallSourceInfo(packageName).installingPackageName
|
|
||||||
} else {
|
|
||||||
@Suppress("deprecation") packageManager.getInstallerPackageName(packageName)
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(TAG, "Installer package name: $installerPackageName")
|
|
||||||
|
|
||||||
return when (installerPackageName) {
|
|
||||||
"com.android.vending" -> "googleplay"
|
|
||||||
"org.fdroid.fdroid" -> "fdroid"
|
|
||||||
"com.amazon.venezia" -> "amazon"
|
|
||||||
null -> "unknown"
|
|
||||||
else -> "unknown($installerPackageName)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,133 @@
|
|||||||
|
// 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,45 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn;
|
|
||||||
|
|
||||||
import android.content.BroadcastReceiver;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.Intent;
|
|
||||||
import androidx.work.Data;
|
|
||||||
|
|
||||||
import androidx.work.OneTimeWorkRequest;
|
|
||||||
import androidx.work.WorkManager;
|
|
||||||
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* IPNReceiver allows external applications to start the VPN.
|
|
||||||
*/
|
|
||||||
public class IPNReceiver extends BroadcastReceiver {
|
|
||||||
|
|
||||||
public static final String INTENT_CONNECT_VPN = "com.tailscale.ipn.CONNECT_VPN";
|
|
||||||
public static final String INTENT_DISCONNECT_VPN = "com.tailscale.ipn.DISCONNECT_VPN";
|
|
||||||
|
|
||||||
private static final String INTENT_USE_EXIT_NODE = "com.tailscale.ipn.USE_EXIT_NODE";
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onReceive(Context context, Intent intent) {
|
|
||||||
WorkManager workManager = WorkManager.getInstance(context);
|
|
||||||
|
|
||||||
// On the relevant action, start the relevant worker, which can stay active for longer than this receiver can.
|
|
||||||
if (Objects.equals(intent.getAction(), INTENT_CONNECT_VPN)) {
|
|
||||||
workManager.enqueue(new OneTimeWorkRequest.Builder(StartVPNWorker.class).build());
|
|
||||||
} else if (Objects.equals(intent.getAction(), INTENT_DISCONNECT_VPN)) {
|
|
||||||
workManager.enqueue(new OneTimeWorkRequest.Builder(StopVPNWorker.class).build());
|
|
||||||
}
|
|
||||||
else if (Objects.equals(intent.getAction(), INTENT_USE_EXIT_NODE)) {
|
|
||||||
String exitNode = intent.getStringExtra("exitNode");
|
|
||||||
boolean allowLanAccess = intent.getBooleanExtra("allowLanAccess", false);
|
|
||||||
Data.Builder workData = new Data.Builder();
|
|
||||||
workData.putString(UseExitNodeWorker.EXIT_NODE_NAME, exitNode);
|
|
||||||
workData.putBoolean(UseExitNodeWorker.ALLOW_LAN_ACCESS, allowLanAccess);
|
|
||||||
workManager.enqueue(new OneTimeWorkRequest.Builder(UseExitNodeWorker.class).setInputData(workData.build()).build());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,115 @@
|
|||||||
|
// 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())) {
|
||||||
|
close();
|
||||||
|
return START_NOT_STICKY;
|
||||||
|
}
|
||||||
|
connect();
|
||||||
|
return START_STICKY;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void close() {
|
||||||
|
stopForeground(true);
|
||||||
|
disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void onDestroy() {
|
||||||
|
close();
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void onRevoke() {
|
||||||
|
close();
|
||||||
|
super.onRevoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
private PendingIntent configIntent() {
|
||||||
|
return PendingIntent.getActivity(this, 0, new Intent(this, 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.acr2");
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
@ -1,181 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
package com.tailscale.ipn
|
|
||||||
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.net.VpnService
|
|
||||||
import android.os.Build
|
|
||||||
import android.system.OsConstants
|
|
||||||
import com.tailscale.ipn.mdm.MDMSettings
|
|
||||||
import com.tailscale.ipn.ui.model.Ipn
|
|
||||||
import com.tailscale.ipn.ui.notifier.Notifier
|
|
||||||
import com.tailscale.ipn.util.TSLog
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import libtailscale.Libtailscale
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
open class IPNService : VpnService(), libtailscale.IPNService {
|
|
||||||
private val TAG = "IPNService"
|
|
||||||
private val randomID: String = UUID.randomUUID().toString()
|
|
||||||
private lateinit var app: App
|
|
||||||
val scope = CoroutineScope(Dispatchers.IO)
|
|
||||||
|
|
||||||
override fun id(): String {
|
|
||||||
return randomID
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun updateVpnStatus(status: Boolean) {
|
|
||||||
app.getAppScopedViewModel().setVpnActive(status)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
// grab app to make sure it initializes
|
|
||||||
app = App.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
|
|
||||||
when (intent?.action) {
|
|
||||||
ACTION_STOP_VPN -> {
|
|
||||||
app.setWantRunning(false)
|
|
||||||
close()
|
|
||||||
START_NOT_STICKY
|
|
||||||
}
|
|
||||||
ACTION_START_VPN -> {
|
|
||||||
scope.launch {
|
|
||||||
// Collect the first value of hideDisconnectAction asynchronously.
|
|
||||||
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
|
|
||||||
showForegroundNotification(hideDisconnectAction.value)
|
|
||||||
}
|
|
||||||
app.setWantRunning(true)
|
|
||||||
Libtailscale.requestVPN(this)
|
|
||||||
START_STICKY
|
|
||||||
}
|
|
||||||
"android.net.VpnService" -> {
|
|
||||||
// This means we were started by Android due to Always On VPN.
|
|
||||||
// We show a non-foreground notification because we weren't
|
|
||||||
// started as a foreground service.
|
|
||||||
scope.launch {
|
|
||||||
// Collect the first value of hideDisconnectAction asynchronously.
|
|
||||||
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
|
|
||||||
app.notifyStatus(true, hideDisconnectAction.value)
|
|
||||||
}
|
|
||||||
app.setWantRunning(true)
|
|
||||||
Libtailscale.requestVPN(this)
|
|
||||||
START_STICKY
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
// This means that we were restarted after the service was killed
|
|
||||||
// (potentially due to OOM).
|
|
||||||
if (UninitializedApp.get().isAbleToStartVPN()) {
|
|
||||||
scope.launch {
|
|
||||||
// Collect the first value of hideDisconnectAction asynchronously.
|
|
||||||
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
|
|
||||||
showForegroundNotification(hideDisconnectAction.value)
|
|
||||||
}
|
|
||||||
App.get()
|
|
||||||
Libtailscale.requestVPN(this)
|
|
||||||
START_STICKY
|
|
||||||
} else {
|
|
||||||
START_NOT_STICKY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
|
||||||
app.setWantRunning(false) {}
|
|
||||||
Notifier.setState(Ipn.State.Stopping)
|
|
||||||
disconnectVPN()
|
|
||||||
Libtailscale.serviceDisconnect(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun disconnectVPN() {
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
close()
|
|
||||||
updateVpnStatus(false)
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRevoke() {
|
|
||||||
close()
|
|
||||||
updateVpnStatus(false)
|
|
||||||
super.onRevoke()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setVpnPrepared(isPrepared: Boolean) {
|
|
||||||
app.getAppScopedViewModel().setVpnPrepared(isPrepared)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showForegroundNotification(hideDisconnectAction: Boolean) {
|
|
||||||
try {
|
|
||||||
startForeground(
|
|
||||||
UninitializedApp.STATUS_NOTIFICATION_ID,
|
|
||||||
UninitializedApp.get().buildStatusNotification(true, hideDisconnectAction))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
TSLog.e(TAG, "Failed to start foreground service: $e")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun configIntent(): PendingIntent {
|
|
||||||
return PendingIntent.getActivity(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
Intent(this, MainActivity::class.java),
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun disallowApp(b: Builder, name: String) {
|
|
||||||
try {
|
|
||||||
b.addDisallowedApplication(name)
|
|
||||||
} catch (e: PackageManager.NameNotFoundException) {
|
|
||||||
TSLog.d(TAG, "Failed to add disallowed application: $e")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun newBuilder(): VPNServiceBuilder {
|
|
||||||
val b: Builder =
|
|
||||||
Builder()
|
|
||||||
.setConfigureIntent(configIntent())
|
|
||||||
.allowFamily(OsConstants.AF_INET)
|
|
||||||
.allowFamily(OsConstants.AF_INET6)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
b.setMetered(false) // Inherit the metered status from the underlying networks.
|
|
||||||
}
|
|
||||||
b.setUnderlyingNetworks(null) // Use all available networks.
|
|
||||||
|
|
||||||
val includedPackages: List<String> =
|
|
||||||
MDMSettings.includedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
|
|
||||||
if (includedPackages.isNotEmpty()) {
|
|
||||||
// If an admin defined a list of packages that are exclusively allowed to be used via
|
|
||||||
// Tailscale,
|
|
||||||
// then only allow those apps.
|
|
||||||
for (packageName in includedPackages) {
|
|
||||||
TSLog.d(TAG, "Including app: $packageName")
|
|
||||||
b.addAllowedApplication(packageName)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Otherwise, prevent certain apps from getting their traffic + DNS routed via Tailscale:
|
|
||||||
// - any app that the user manually disallowed in the GUI
|
|
||||||
// - any app that we disallowed via hard-coding
|
|
||||||
for (disallowedPackageName in UninitializedApp.get().disallowedPackageNames()) {
|
|
||||||
TSLog.d(TAG, "Disallowing app: $disallowedPackageName")
|
|
||||||
disallowApp(b, disallowedPackageName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return VPNServiceBuilder(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val ACTION_START_VPN = "com.tailscale.ipn.START_VPN"
|
|
||||||
const val ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN"
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,426 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.Activity
|
|
||||||
import android.app.AlertDialog
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.RestrictionsManager
|
|
||||||
import android.content.pm.ActivityInfo
|
|
||||||
import android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE
|
|
||||||
import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK
|
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.net.NetworkCapabilities
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.provider.Settings
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
|
||||||
import androidx.activity.result.contract.ActivityResultContract
|
|
||||||
import androidx.browser.customtabs.CustomTabsIntent
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
|
||||||
import androidx.lifecycle.ViewModelProvider
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import androidx.navigation.NavHostController
|
|
||||||
import androidx.navigation.NavType
|
|
||||||
import androidx.navigation.compose.NavHost
|
|
||||||
import androidx.navigation.compose.composable
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
|
||||||
import androidx.navigation.navArgument
|
|
||||||
import com.tailscale.ipn.mdm.MDMSettings
|
|
||||||
import com.tailscale.ipn.ui.model.Ipn
|
|
||||||
import com.tailscale.ipn.ui.notifier.Notifier
|
|
||||||
import com.tailscale.ipn.ui.theme.AppTheme
|
|
||||||
import com.tailscale.ipn.ui.util.AndroidTVUtil
|
|
||||||
import com.tailscale.ipn.ui.util.set
|
|
||||||
import com.tailscale.ipn.ui.util.universalFit
|
|
||||||
import com.tailscale.ipn.ui.view.AboutView
|
|
||||||
import com.tailscale.ipn.ui.view.BugReportView
|
|
||||||
import com.tailscale.ipn.ui.view.DNSSettingsView
|
|
||||||
import com.tailscale.ipn.ui.view.ExitNodePicker
|
|
||||||
import com.tailscale.ipn.ui.view.HealthView
|
|
||||||
import com.tailscale.ipn.ui.view.IntroView
|
|
||||||
import com.tailscale.ipn.ui.view.LoginQRView
|
|
||||||
import com.tailscale.ipn.ui.view.LoginWithAuthKeyView
|
|
||||||
import com.tailscale.ipn.ui.view.LoginWithCustomControlURLView
|
|
||||||
import com.tailscale.ipn.ui.view.MDMSettingsDebugView
|
|
||||||
import com.tailscale.ipn.ui.view.MainView
|
|
||||||
import com.tailscale.ipn.ui.view.MainViewNavigation
|
|
||||||
import com.tailscale.ipn.ui.view.ManagedByView
|
|
||||||
import com.tailscale.ipn.ui.view.MullvadExitNodePicker
|
|
||||||
import com.tailscale.ipn.ui.view.MullvadExitNodePickerList
|
|
||||||
import com.tailscale.ipn.ui.view.MullvadInfoView
|
|
||||||
import com.tailscale.ipn.ui.view.PeerDetails
|
|
||||||
import com.tailscale.ipn.ui.view.PermissionsView
|
|
||||||
import com.tailscale.ipn.ui.view.RunExitNodeView
|
|
||||||
import com.tailscale.ipn.ui.view.SettingsView
|
|
||||||
import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView
|
|
||||||
import com.tailscale.ipn.ui.view.TailnetLockSetupView
|
|
||||||
import com.tailscale.ipn.ui.view.UserSwitcherNav
|
|
||||||
import com.tailscale.ipn.ui.view.UserSwitcherView
|
|
||||||
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
|
|
||||||
import com.tailscale.ipn.ui.viewModel.MainViewModel
|
|
||||||
import com.tailscale.ipn.ui.viewModel.MainViewModelFactory
|
|
||||||
import com.tailscale.ipn.ui.viewModel.PingViewModel
|
|
||||||
import com.tailscale.ipn.ui.viewModel.SettingsNav
|
|
||||||
import com.tailscale.ipn.ui.viewModel.VpnViewModel
|
|
||||||
import com.tailscale.ipn.util.TSLog
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
|
||||||
private lateinit var navController: NavHostController
|
|
||||||
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
|
|
||||||
private val viewModel: MainViewModel by lazy {
|
|
||||||
val app = App.get()
|
|
||||||
vpnViewModel = app.getAppScopedViewModel()
|
|
||||||
ViewModelProvider(this, MainViewModelFactory(vpnViewModel)).get(MainViewModel::class.java)
|
|
||||||
}
|
|
||||||
private lateinit var vpnViewModel: VpnViewModel
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "Main Activity"
|
|
||||||
private const val START_AT_ROOT = "startAtRoot"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Context.isLandscapeCapable(): Boolean {
|
|
||||||
return (resources.configuration.screenLayout and SCREENLAYOUT_SIZE_MASK) >=
|
|
||||||
SCREENLAYOUT_SIZE_LARGE
|
|
||||||
}
|
|
||||||
|
|
||||||
// The loginQRCode is used to track whether or not we should be rendering a QR code
|
|
||||||
// to the user. This is used only on TV platforms with no browser in lieu of
|
|
||||||
// simply opening the URL. This should be consumed once it has been handled.
|
|
||||||
private val loginQRCode: StateFlow<String?> = MutableStateFlow(null)
|
|
||||||
|
|
||||||
@SuppressLint("SourceLockedOrientationActivity")
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
|
|
||||||
// grab app to make sure it initializes
|
|
||||||
App.get()
|
|
||||||
vpnViewModel = ViewModelProvider(App.get()).get(VpnViewModel::class.java)
|
|
||||||
|
|
||||||
// (jonathan) TODO: Force the app to be portrait on small screens until we have
|
|
||||||
// proper landscape layout support
|
|
||||||
if (!isLandscapeCapable()) {
|
|
||||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
|
||||||
}
|
|
||||||
|
|
||||||
installSplashScreen()
|
|
||||||
|
|
||||||
vpnPermissionLauncher =
|
|
||||||
registerForActivityResult(VpnPermissionContract()) { granted ->
|
|
||||||
if (granted) {
|
|
||||||
TSLog.d("VpnPermission", "VPN permission granted")
|
|
||||||
vpnViewModel.setVpnPrepared(true)
|
|
||||||
App.get().startVPN()
|
|
||||||
} else {
|
|
||||||
if (isAnotherVpnActive(this)) {
|
|
||||||
TSLog.d("VpnPermission", "Another VPN is likely active")
|
|
||||||
showOtherVPNConflictDialog()
|
|
||||||
} else {
|
|
||||||
TSLog.d("VpnPermission", "Permission was denied by the user")
|
|
||||||
vpnViewModel.setVpnPrepared(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher)
|
|
||||||
|
|
||||||
setContent {
|
|
||||||
AppTheme {
|
|
||||||
navController = rememberNavController()
|
|
||||||
Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox
|
|
||||||
Surface(modifier = Modifier.universalFit()) { // Letterbox for AndroidTV
|
|
||||||
NavHost(
|
|
||||||
navController = navController,
|
|
||||||
startDestination = "main",
|
|
||||||
enterTransition = {
|
|
||||||
slideInHorizontally(animationSpec = tween(150), initialOffsetX = { it })
|
|
||||||
},
|
|
||||||
exitTransition = {
|
|
||||||
slideOutHorizontally(animationSpec = tween(150), targetOffsetX = { -it })
|
|
||||||
},
|
|
||||||
popEnterTransition = {
|
|
||||||
slideInHorizontally(animationSpec = tween(150), initialOffsetX = { -it })
|
|
||||||
},
|
|
||||||
popExitTransition = {
|
|
||||||
slideOutHorizontally(animationSpec = tween(150), targetOffsetX = { it })
|
|
||||||
}) {
|
|
||||||
fun backTo(route: String): () -> Unit = {
|
|
||||||
navController.popBackStack(route = route, inclusive = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
val mainViewNav =
|
|
||||||
MainViewNavigation(
|
|
||||||
onNavigateToSettings = { navController.navigate("settings") },
|
|
||||||
onNavigateToPeerDetails = {
|
|
||||||
navController.navigate("peerDetails/${it.StableID}")
|
|
||||||
},
|
|
||||||
onNavigateToExitNodes = { navController.navigate("exitNodes") },
|
|
||||||
onNavigateToHealth = { navController.navigate("health") })
|
|
||||||
|
|
||||||
val settingsNav =
|
|
||||||
SettingsNav(
|
|
||||||
onNavigateToBugReport = { navController.navigate("bugReport") },
|
|
||||||
onNavigateToAbout = { navController.navigate("about") },
|
|
||||||
onNavigateToDNSSettings = { navController.navigate("dnsSettings") },
|
|
||||||
onNavigateToSplitTunneling = { navController.navigate("splitTunneling") },
|
|
||||||
onNavigateToTailnetLock = { navController.navigate("tailnetLock") },
|
|
||||||
onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
|
|
||||||
onNavigateToManagedBy = { navController.navigate("managedBy") },
|
|
||||||
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
|
|
||||||
onNavigateToPermissions = { navController.navigate("permissions") },
|
|
||||||
onBackToSettings = backTo("settings"),
|
|
||||||
onNavigateBackHome = backTo("main"))
|
|
||||||
|
|
||||||
val exitNodePickerNav =
|
|
||||||
ExitNodePickerNav(
|
|
||||||
onNavigateBackHome = {
|
|
||||||
navController.popBackStack(route = "main", inclusive = false)
|
|
||||||
},
|
|
||||||
onNavigateBackToExitNodes = backTo("exitNodes"),
|
|
||||||
onNavigateToMullvad = { navController.navigate("mullvad") },
|
|
||||||
onNavigateToMullvadInfo = { navController.navigate("mullvad_info") },
|
|
||||||
onNavigateBackToMullvad = backTo("mullvad"),
|
|
||||||
onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") },
|
|
||||||
onNavigateToRunAsExitNode = { navController.navigate("runExitNode") })
|
|
||||||
|
|
||||||
val userSwitcherNav =
|
|
||||||
UserSwitcherNav(
|
|
||||||
backToSettings = backTo("settings"),
|
|
||||||
onNavigateHome = backTo("main"),
|
|
||||||
onNavigateCustomControl = {
|
|
||||||
navController.navigate("loginWithCustomControl")
|
|
||||||
},
|
|
||||||
onNavigateToAuthKey = { navController.navigate("loginWithAuthKey") })
|
|
||||||
|
|
||||||
composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) {
|
|
||||||
MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel)
|
|
||||||
}
|
|
||||||
composable("settings") { SettingsView(settingsNav) }
|
|
||||||
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
|
|
||||||
composable("health") { HealthView(backTo("main")) }
|
|
||||||
composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) }
|
|
||||||
composable("mullvad_info") { MullvadInfoView(exitNodePickerNav) }
|
|
||||||
composable(
|
|
||||||
"mullvad/{countryCode}",
|
|
||||||
arguments =
|
|
||||||
listOf(navArgument("countryCode") { type = NavType.StringType })) {
|
|
||||||
MullvadExitNodePicker(
|
|
||||||
it.arguments!!.getString("countryCode")!!, exitNodePickerNav)
|
|
||||||
}
|
|
||||||
composable("runExitNode") { RunExitNodeView(exitNodePickerNav) }
|
|
||||||
composable(
|
|
||||||
"peerDetails/{nodeId}",
|
|
||||||
arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) {
|
|
||||||
PeerDetails(
|
|
||||||
backTo("main"),
|
|
||||||
it.arguments?.getString("nodeId") ?: "",
|
|
||||||
PingViewModel())
|
|
||||||
}
|
|
||||||
composable("bugReport") { BugReportView(backTo("settings")) }
|
|
||||||
composable("dnsSettings") { DNSSettingsView(backTo("settings")) }
|
|
||||||
composable("splitTunneling") { SplitTunnelAppPickerView(backTo("settings")) }
|
|
||||||
composable("tailnetLock") { TailnetLockSetupView(backTo("settings")) }
|
|
||||||
composable("about") { AboutView(backTo("settings")) }
|
|
||||||
composable("mdmSettings") { MDMSettingsDebugView(backTo("settings")) }
|
|
||||||
composable("managedBy") { ManagedByView(backTo("settings")) }
|
|
||||||
composable("userSwitcher") { UserSwitcherView(userSwitcherNav) }
|
|
||||||
composable("permissions") {
|
|
||||||
PermissionsView(backTo("settings"), ::openApplicationSettings)
|
|
||||||
}
|
|
||||||
composable("intro", exitTransition = { fadeOut(animationSpec = tween(150)) }) {
|
|
||||||
IntroView(backTo("main"))
|
|
||||||
}
|
|
||||||
composable("loginWithAuthKey") {
|
|
||||||
LoginWithAuthKeyView(onNavigateHome = backTo("main"), backTo("userSwitcher"))
|
|
||||||
}
|
|
||||||
composable("loginWithCustomControl") {
|
|
||||||
LoginWithCustomControlURLView(
|
|
||||||
onNavigateHome = backTo("main"), backTo("userSwitcher"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show the intro screen one time
|
|
||||||
if (!introScreenViewed()) {
|
|
||||||
navController.navigate("intro")
|
|
||||||
setIntroScreenViewed(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login actions are app wide. If we are told about a browse-to-url, we should render it
|
|
||||||
// over whatever screen we happen to be on.
|
|
||||||
loginQRCode.collectAsState().value?.let {
|
|
||||||
LoginQRView(onDismiss = { loginQRCode.set(null) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
// Watch the model's browseToURL and launch the browser when it changes or
|
|
||||||
// pop up a QR code to scan
|
|
||||||
lifecycleScope.launch {
|
|
||||||
Notifier.browseToURL.collect { url ->
|
|
||||||
url?.let {
|
|
||||||
when (useQRCodeLogin()) {
|
|
||||||
false -> Dispatchers.Main.run { login(it) }
|
|
||||||
true -> loginQRCode.set(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Once we see a loginFinished event, clear the QR code which will dismiss the QR dialog.
|
|
||||||
lifecycleScope.launch { Notifier.loginFinished.collect { _ -> loginQRCode.set(null) } }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showOtherVPNConflictDialog() {
|
|
||||||
AlertDialog.Builder(this)
|
|
||||||
.setTitle(R.string.vpn_permission_denied)
|
|
||||||
.setMessage(R.string.multiple_vpn_explainer)
|
|
||||||
.setPositiveButton(R.string.go_to_settings) { _, _ ->
|
|
||||||
// Intent to open the VPN settings
|
|
||||||
val intent = Intent(Settings.ACTION_VPN_SETTINGS)
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
.setNegativeButton(R.string.cancel, null)
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isAnotherVpnActive(context: Context): Boolean {
|
|
||||||
val connectivityManager =
|
|
||||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
||||||
|
|
||||||
val activeNetwork = connectivityManager.activeNetwork
|
|
||||||
if (activeNetwork != null) {
|
|
||||||
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
|
|
||||||
if (networkCapabilities != null &&
|
|
||||||
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns true if we should render a QR code instead of launching a browser
|
|
||||||
// for login requests
|
|
||||||
private fun useQRCodeLogin(): Boolean {
|
|
||||||
return AndroidTVUtil.isAndroidTV()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
|
||||||
super.onNewIntent(intent)
|
|
||||||
if (intent.getBooleanExtra(START_AT_ROOT, false)) {
|
|
||||||
if (this::navController.isInitialized) {
|
|
||||||
navController.popBackStack(route = "main", inclusive = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun login(urlString: String) {
|
|
||||||
// Launch coroutine to listen for state changes. When the user completes login, relaunch
|
|
||||||
// MainActivity to bring the app back to focus.
|
|
||||||
App.get().applicationScope.launch {
|
|
||||||
try {
|
|
||||||
Notifier.state.collect { state ->
|
|
||||||
if (state > Ipn.State.NeedsMachineAuth) {
|
|
||||||
val intent =
|
|
||||||
Intent(applicationContext, MainActivity::class.java).apply {
|
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
|
||||||
action = Intent.ACTION_MAIN
|
|
||||||
addCategory(Intent.CATEGORY_LAUNCHER)
|
|
||||||
putExtra(START_AT_ROOT, true)
|
|
||||||
}
|
|
||||||
startActivity(intent)
|
|
||||||
|
|
||||||
// Cancel coroutine once we've logged in
|
|
||||||
this@launch.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
TSLog.e(TAG, "Login: failed to start MainActivity: $e")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val url = urlString.toUri()
|
|
||||||
try {
|
|
||||||
val customTabsIntent = CustomTabsIntent.Builder().build()
|
|
||||||
customTabsIntent.launchUrl(this, url)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Fallback to a regular browser if CustomTabsIntent fails
|
|
||||||
try {
|
|
||||||
val fallbackIntent = Intent(Intent.ACTION_VIEW, url)
|
|
||||||
startActivity(fallbackIntent)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
TSLog.e(TAG, "Login: failed to open browser: $e")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
val restrictionsManager =
|
|
||||||
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStart() {
|
|
||||||
super.onStart()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStop() {
|
|
||||||
super.onStop()
|
|
||||||
val restrictionsManager =
|
|
||||||
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) { MDMSettings.update(App.get(), restrictionsManager) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openApplicationSettings() {
|
|
||||||
val intent =
|
|
||||||
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
|
||||||
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
|
||||||
}
|
|
||||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun introScreenViewed(): Boolean {
|
|
||||||
return getSharedPreferences("introScreen", Context.MODE_PRIVATE).getBoolean("seen", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setIntroScreenViewed(seen: Boolean) {
|
|
||||||
getSharedPreferences("introScreen", Context.MODE_PRIVATE)
|
|
||||||
.edit()
|
|
||||||
.putBoolean("seen", seen)
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class VpnPermissionContract : ActivityResultContract<Intent, Boolean>() {
|
|
||||||
override fun createIntent(context: Context, input: Intent): Intent {
|
|
||||||
return input
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
|
|
||||||
return resultCode == Activity.RESULT_OK
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,168 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
package com.tailscale.ipn
|
|
||||||
|
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.net.LinkProperties
|
|
||||||
import android.net.Network
|
|
||||||
import android.net.NetworkCapabilities
|
|
||||||
import android.net.NetworkRequest
|
|
||||||
import android.util.Log
|
|
||||||
import com.tailscale.ipn.util.TSLog
|
|
||||||
import libtailscale.Libtailscale
|
|
||||||
import java.util.concurrent.locks.ReentrantLock
|
|
||||||
import kotlin.concurrent.withLock
|
|
||||||
|
|
||||||
object NetworkChangeCallback {
|
|
||||||
|
|
||||||
private const val TAG = "NetworkChangeCallback"
|
|
||||||
|
|
||||||
private data class NetworkInfo(var caps: NetworkCapabilities, var linkProps: LinkProperties)
|
|
||||||
|
|
||||||
private val lock = ReentrantLock()
|
|
||||||
|
|
||||||
private val activeNetworks = mutableMapOf<Network, NetworkInfo>() // keyed by Network
|
|
||||||
|
|
||||||
// monitorDnsChanges sets up a network callback to monitor changes to the
|
|
||||||
// system's network state and update the DNS configuration when interfaces
|
|
||||||
// become available or properties of those interfaces change.
|
|
||||||
fun monitorDnsChanges(connectivityManager: ConnectivityManager, dns: DnsConfig) {
|
|
||||||
val networkConnectivityRequest =
|
|
||||||
NetworkRequest.Builder()
|
|
||||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
|
||||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
// Use registerNetworkCallback to listen for updates from all networks, and
|
|
||||||
// then update DNS configs for the best network when LinkProperties are changed.
|
|
||||||
// Per
|
|
||||||
// https://developer.android.com/reference/android/net/ConnectivityManager.NetworkCallback#onAvailable(android.net.Network), this happens after all other updates.
|
|
||||||
//
|
|
||||||
// Note that we can't use registerDefaultNetworkCallback because the
|
|
||||||
// default network used by Tailscale will always show up with capability
|
|
||||||
// NOT_VPN=false, and we must filter out NOT_VPN networks to avoid routing
|
|
||||||
// loops.
|
|
||||||
connectivityManager.registerNetworkCallback(
|
|
||||||
networkConnectivityRequest,
|
|
||||||
object : ConnectivityManager.NetworkCallback() {
|
|
||||||
override fun onAvailable(network: Network) {
|
|
||||||
super.onAvailable(network)
|
|
||||||
|
|
||||||
TSLog.d(TAG, "onAvailable: network ${network}")
|
|
||||||
lock.withLock {
|
|
||||||
activeNetworks[network] = NetworkInfo(NetworkCapabilities(), LinkProperties())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
|
|
||||||
super.onCapabilitiesChanged(network, capabilities)
|
|
||||||
lock.withLock { activeNetworks[network]?.caps = capabilities }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
|
|
||||||
super.onLinkPropertiesChanged(network, linkProperties)
|
|
||||||
lock.withLock {
|
|
||||||
activeNetworks[network]?.linkProps = linkProperties
|
|
||||||
maybeUpdateDNSConfig("onLinkPropertiesChanged", dns)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLost(network: Network) {
|
|
||||||
super.onLost(network)
|
|
||||||
|
|
||||||
TSLog.d(TAG, "onLost: network ${network}")
|
|
||||||
lock.withLock {
|
|
||||||
activeNetworks.remove(network)
|
|
||||||
maybeUpdateDNSConfig("onLost", dns)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// pickNonMetered returns the first non-metered network in the list of
|
|
||||||
// networks, or the first network if none are non-metered.
|
|
||||||
private fun pickNonMetered(networks: Map<Network, NetworkInfo>): Network? {
|
|
||||||
for ((network, info) in networks) {
|
|
||||||
if (info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)) {
|
|
||||||
return network
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return networks.keys.firstOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
// pickDefaultNetwork returns a non-VPN network to use as the 'default'
|
|
||||||
// network; one that is used as a gateway to the internet and from which we
|
|
||||||
// obtain our DNS servers.
|
|
||||||
private fun pickDefaultNetwork(): Network? {
|
|
||||||
// Filter the list of all networks to those that have the INTERNET
|
|
||||||
// capability, are not VPNs, and have a non-zero number of DNS servers
|
|
||||||
// available.
|
|
||||||
val networks =
|
|
||||||
activeNetworks.filter { (_, info) ->
|
|
||||||
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
|
||||||
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) &&
|
|
||||||
info.linkProps.dnsServers.isNotEmpty() == true
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have one; just return it; otherwise, prefer networks that are also
|
|
||||||
// not metered (i.e. cell modems).
|
|
||||||
val nonMeteredNetwork = pickNonMetered(networks)
|
|
||||||
if (nonMeteredNetwork != null) {
|
|
||||||
return nonMeteredNetwork
|
|
||||||
}
|
|
||||||
|
|
||||||
// Okay, less good; just return the first network that has the INTERNET and
|
|
||||||
// NOT_VPN capabilities; even though this interface doesn't have any DNS
|
|
||||||
// servers set, we'll use our DNS fallback servers to make queries. It's
|
|
||||||
// strictly better to return an interface + use the DNS fallback servers
|
|
||||||
// than to return nothing and not be able to route traffic.
|
|
||||||
for ((network, info) in activeNetworks) {
|
|
||||||
if (info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
|
||||||
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) {
|
|
||||||
Log.w(
|
|
||||||
TAG,
|
|
||||||
"no networks available that also have DNS servers set; falling back to first network ${network}")
|
|
||||||
return network
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, return nothing; we don't want to return a VPN network since
|
|
||||||
// it could result in a routing loop, and a non-INTERNET network isn't
|
|
||||||
// helpful.
|
|
||||||
Log.w(TAG, "no networks available to pick a default network")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// maybeUpdateDNSConfig will maybe update our DNS configuration based on the
|
|
||||||
// current set of active Networks.
|
|
||||||
private fun maybeUpdateDNSConfig(why: String, dns: DnsConfig) {
|
|
||||||
val defaultNetwork = pickDefaultNetwork()
|
|
||||||
if (defaultNetwork == null) {
|
|
||||||
TSLog.d(TAG, "${why}: no default network available; not updating DNS config")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val info = activeNetworks[defaultNetwork]
|
|
||||||
if (info == null) {
|
|
||||||
Log.w(
|
|
||||||
TAG,
|
|
||||||
"${why}: [unexpected] no info available for default network; not updating DNS config")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val sb = StringBuilder()
|
|
||||||
for (ip in info.linkProps.dnsServers) {
|
|
||||||
sb.append(ip.hostAddress).append(" ")
|
|
||||||
}
|
|
||||||
val searchDomains: String? = info.linkProps.domains
|
|
||||||
if (searchDomains != null) {
|
|
||||||
sb.append("\n")
|
|
||||||
sb.append(searchDomains)
|
|
||||||
}
|
|
||||||
if (dns.updateDNSFromNetwork(sb.toString())) {
|
|
||||||
TSLog.d(
|
|
||||||
TAG,
|
|
||||||
"${why}: updated DNS config for network ${defaultNetwork} (${info.linkProps.interfaceName})")
|
|
||||||
Libtailscale.onDNSConfigChanged(info.linkProps.interfaceName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,17 @@
|
|||||||
|
// 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,99 +1,83 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
package com.tailscale.ipn;
|
package com.tailscale.ipn;
|
||||||
|
|
||||||
import android.app.PendingIntent;
|
import android.content.Context;
|
||||||
|
import android.content.ComponentName;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.os.Build;
|
|
||||||
import android.service.quicksettings.Tile;
|
import android.service.quicksettings.Tile;
|
||||||
import android.service.quicksettings.TileService;
|
import android.service.quicksettings.TileService;
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
public class QuickToggleService extends TileService {
|
public class QuickToggleService extends TileService {
|
||||||
// lock protects the static fields below it.
|
// lock protects the static fields below it.
|
||||||
private static final Object lock = new Object();
|
private static Object lock = new Object();
|
||||||
|
// Active tracks whether the VPN is active.
|
||||||
// isRunning tracks whether the VPN is running.
|
private static boolean active;
|
||||||
private static boolean isRunning;
|
// Ready tracks whether the tailscale backend is
|
||||||
|
// ready to switch on/off.
|
||||||
|
private static boolean ready;
|
||||||
// currentTile tracks getQsTile while service is listening.
|
// currentTile tracks getQsTile while service is listening.
|
||||||
private static Tile currentTile;
|
private static Tile currentTile;
|
||||||
|
|
||||||
public static void updateTile() {
|
@Override public void onStartListening() {
|
||||||
var app = UninitializedApp.get();
|
|
||||||
Tile t;
|
|
||||||
boolean act;
|
|
||||||
synchronized (lock) {
|
|
||||||
t = currentTile;
|
|
||||||
act = isRunning && app.isAbleToStartVPN();
|
|
||||||
}
|
|
||||||
if (t == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
t.setLabel("Tailscale");
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
t.setSubtitle(act ? app.getString(R.string.connected) : app.getString(R.string.not_connected));
|
|
||||||
}
|
|
||||||
t.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
|
|
||||||
t.updateTile();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void setVPNRunning(boolean running) {
|
|
||||||
synchronized (lock) {
|
|
||||||
isRunning = running;
|
|
||||||
}
|
|
||||||
updateTile();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onStartListening() {
|
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
currentTile = getQsTile();
|
currentTile = getQsTile();
|
||||||
}
|
}
|
||||||
updateTile();
|
updateTile();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override public void onStopListening() {
|
||||||
public void onStopListening() {
|
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
currentTile = null;
|
currentTile = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("deprecation")
|
@Override public void onClick() {
|
||||||
@Override
|
|
||||||
public void onClick() {
|
|
||||||
boolean r;
|
boolean r;
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
r = UninitializedApp.get().isAbleToStartVPN();
|
r = ready;
|
||||||
}
|
}
|
||||||
if (r) {
|
if (r) {
|
||||||
// Get the application to make sure it initializes
|
|
||||||
App.get();
|
|
||||||
onTileClick();
|
onTileClick();
|
||||||
} else {
|
} else {
|
||||||
// Start main activity.
|
// Start main activity.
|
||||||
Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName());
|
Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName());
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
// Request code for opening activity.
|
|
||||||
startActivityAndCollapse(PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE));
|
|
||||||
} else {
|
|
||||||
// Deprecated, but still required for older versions.
|
|
||||||
startActivityAndCollapse(i);
|
startActivityAndCollapse(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void onTileClick() {
|
static void setReady(Context ctx, boolean rdy) {
|
||||||
UninitializedApp app = UninitializedApp.get();
|
|
||||||
boolean needsToStop;
|
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
needsToStop = app.isAbleToStartVPN() && isRunning;
|
ready = rdy;
|
||||||
}
|
}
|
||||||
if (needsToStop) {
|
updateTile();
|
||||||
app.stopVPN();
|
|
||||||
} else {
|
|
||||||
app.startVPN();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void setStatus(Context ctx, boolean act) {
|
||||||
|
synchronized (lock) {
|
||||||
|
active = act;
|
||||||
}
|
}
|
||||||
|
updateTile();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static native void onTileClick();
|
||||||
}
|
}
|
||||||
|
@ -1,129 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.provider.OpenableColumns
|
|
||||||
import android.webkit.MimeTypeMap
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import com.tailscale.ipn.ui.model.Ipn
|
|
||||||
import com.tailscale.ipn.ui.theme.AppTheme
|
|
||||||
import com.tailscale.ipn.ui.util.set
|
|
||||||
import com.tailscale.ipn.ui.util.universalFit
|
|
||||||
import com.tailscale.ipn.ui.view.TaildropView
|
|
||||||
import com.tailscale.ipn.util.TSLog
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlin.random.Random
|
|
||||||
|
|
||||||
// ShareActivity is the entry point for Taildrop share intents
|
|
||||||
class ShareActivity : ComponentActivity() {
|
|
||||||
private val TAG = ShareActivity::class.simpleName
|
|
||||||
|
|
||||||
private val requestedTransfers: StateFlow<List<Ipn.OutgoingFile>> = MutableStateFlow(emptyList())
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContent {
|
|
||||||
AppTheme {
|
|
||||||
Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox
|
|
||||||
Surface(modifier = Modifier.universalFit()) {
|
|
||||||
TaildropView(requestedTransfers, (application as App).applicationScope)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStart() {
|
|
||||||
super.onStart()
|
|
||||||
// Ensure our app instance is initialized
|
|
||||||
App.get()
|
|
||||||
lifecycleScope.launch { withContext(Dispatchers.IO) { loadFiles() } }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
|
||||||
super.onNewIntent(intent)
|
|
||||||
setIntent(intent)
|
|
||||||
loadFiles()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loads the files from the intent.
|
|
||||||
fun loadFiles() {
|
|
||||||
if (intent == null) {
|
|
||||||
TSLog.e(TAG, "Share failure - No intent found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val act = intent.action
|
|
||||||
|
|
||||||
val uris: List<Uri?>? =
|
|
||||||
when (act) {
|
|
||||||
Intent.ACTION_SEND -> {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java))
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Intent.ACTION_SEND_MULTIPLE -> {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
intent.getParcelableArrayListExtra<Uri?>(Intent.EXTRA_STREAM, Uri::class.java)
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION") intent.getParcelableArrayListExtra<Uri?>(Intent.EXTRA_STREAM)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
TSLog.e(TAG, "No extras found in intent - nothing to share")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val pendingFiles: List<Ipn.OutgoingFile> =
|
|
||||||
uris?.filterNotNull()?.mapNotNull {
|
|
||||||
contentResolver?.query(it, null, null, null, null)?.let { c ->
|
|
||||||
val nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
|
||||||
val sizeCol = c.getColumnIndex(OpenableColumns.SIZE)
|
|
||||||
c.moveToFirst()
|
|
||||||
val name: String =
|
|
||||||
c.getString(nameCol)
|
|
||||||
?: run {
|
|
||||||
// For some reason, some content resolvers don't return a name.
|
|
||||||
// Try to build a name from a random integer plus file extension
|
|
||||||
// (if type can be determined), else just a random integer.
|
|
||||||
val rand = Random.nextLong()
|
|
||||||
contentResolver.getType(it)?.let { mimeType ->
|
|
||||||
MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let {
|
|
||||||
extension ->
|
|
||||||
"$rand.$extension"
|
|
||||||
} ?: "$rand"
|
|
||||||
} ?: "$rand"
|
|
||||||
}
|
|
||||||
val size = c.getLong(sizeCol)
|
|
||||||
c.close()
|
|
||||||
val file = Ipn.OutgoingFile(Name = name, DeclaredSize = size)
|
|
||||||
file.uri = it
|
|
||||||
file
|
|
||||||
}
|
|
||||||
} ?: emptyList()
|
|
||||||
|
|
||||||
if (pendingFiles.isEmpty()) {
|
|
||||||
TSLog.e(TAG, "Share failure - no files extracted from intent")
|
|
||||||
}
|
|
||||||
|
|
||||||
requestedTransfers.set(pendingFiles)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,65 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn;
|
|
||||||
|
|
||||||
import android.app.Notification;
|
|
||||||
import android.app.NotificationManager;
|
|
||||||
import android.app.PendingIntent;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.net.VpnService;
|
|
||||||
import android.os.Build;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.work.Worker;
|
|
||||||
import androidx.work.WorkerParameters;
|
|
||||||
|
|
||||||
import com.tailscale.ipn.util.TSLog;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A worker that exists to support IPNReceiver.
|
|
||||||
*/
|
|
||||||
public final class StartVPNWorker extends Worker {
|
|
||||||
|
|
||||||
public StartVPNWorker(Context appContext, WorkerParameters workerParams) {
|
|
||||||
super(appContext, workerParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull
|
|
||||||
@Override
|
|
||||||
public Result doWork() {
|
|
||||||
UninitializedApp app = UninitializedApp.get();
|
|
||||||
boolean ableToStartVPN = app.isAbleToStartVPN();
|
|
||||||
if (ableToStartVPN) {
|
|
||||||
if (VpnService.prepare(app) == null) {
|
|
||||||
// We're ready and have permissions, start the VPN
|
|
||||||
app.startVPN();
|
|
||||||
return Result.success();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// We aren't ready to start the VPN or don't have permission, open the Tailscale app.
|
|
||||||
TSLog.e("StartVPNWorker", "Tailscale isn't ready to start the VPN, notify the user.");
|
|
||||||
|
|
||||||
// Send notification
|
|
||||||
NotificationManager notificationManager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE);
|
|
||||||
String channelId = "start_vpn_channel";
|
|
||||||
|
|
||||||
// Use createNotificationChannel method from App.java
|
|
||||||
app.createNotificationChannel(channelId, getApplicationContext().getString(R.string.vpn_start), getApplicationContext().getString(R.string.notifications_delivered_when_user_interaction_is_required_to_establish_the_vpn_tunnel), NotificationManager.IMPORTANCE_HIGH);
|
|
||||||
|
|
||||||
// Use prepareIntent if available.
|
|
||||||
Intent intent = app.getPackageManager().getLaunchIntentForPackage(app.getPackageName());
|
|
||||||
assert intent != null;
|
|
||||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
|
||||||
int pendingIntentFlags = PendingIntent.FLAG_ONE_SHOT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_IMMUTABLE : 0);
|
|
||||||
PendingIntent pendingIntent = PendingIntent.getActivity(app, 0, intent, pendingIntentFlags);
|
|
||||||
|
|
||||||
Notification notification = new Notification.Builder(app, channelId).setContentTitle(app.getString(R.string.title_connection_failed)).setContentText(app.getString(R.string.body_open_tailscale)).setSmallIcon(R.drawable.ic_notification).setContentIntent(pendingIntent).setAutoCancel(true).build();
|
|
||||||
|
|
||||||
notificationManager.notify(1, notification);
|
|
||||||
|
|
||||||
return Result.failure();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,29 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.work.Worker;
|
|
||||||
import androidx.work.WorkerParameters;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A worker that exists to support IPNReceiver.
|
|
||||||
*/
|
|
||||||
public final class StopVPNWorker extends Worker {
|
|
||||||
|
|
||||||
public StopVPNWorker(
|
|
||||||
Context appContext,
|
|
||||||
WorkerParameters workerParams) {
|
|
||||||
super(appContext, workerParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull
|
|
||||||
@Override
|
|
||||||
public Result doWork() {
|
|
||||||
UninitializedApp.get().stopVPN();
|
|
||||||
return Result.success();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,112 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
package com.tailscale.ipn
|
|
||||||
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.work.CoroutineWorker
|
|
||||||
import androidx.work.Data
|
|
||||||
import androidx.work.WorkerParameters
|
|
||||||
import com.tailscale.ipn.UninitializedApp.Companion.STATUS_CHANNEL_ID
|
|
||||||
import com.tailscale.ipn.UninitializedApp.Companion.STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID
|
|
||||||
import com.tailscale.ipn.ui.localapi.Client
|
|
||||||
import com.tailscale.ipn.ui.model.Ipn
|
|
||||||
import com.tailscale.ipn.ui.notifier.Notifier
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
|
|
||||||
class UseExitNodeWorker(
|
|
||||||
appContext: Context,
|
|
||||||
workerParams: WorkerParameters
|
|
||||||
) : CoroutineWorker(appContext, workerParams) {
|
|
||||||
override suspend fun doWork(): Result {
|
|
||||||
val app = UninitializedApp.get()
|
|
||||||
suspend fun runAndGetResult(): String? {
|
|
||||||
val exitNodeName = inputData.getString(EXIT_NODE_NAME)
|
|
||||||
|
|
||||||
val exitNodeId = if (exitNodeName.isNullOrEmpty()) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
if (!app.isAbleToStartVPN()) {
|
|
||||||
return app.getString(R.string.vpn_is_not_ready_to_start)
|
|
||||||
}
|
|
||||||
|
|
||||||
val peers =
|
|
||||||
(Notifier.netmap.value
|
|
||||||
?: run { return@runAndGetResult app.getString(R.string.tailscale_is_not_setup) })
|
|
||||||
.Peers ?: run { return@runAndGetResult app.getString(R.string.no_peers_found) }
|
|
||||||
|
|
||||||
val filteredPeers = peers.filter {
|
|
||||||
it.displayName == exitNodeName
|
|
||||||
}.toList()
|
|
||||||
|
|
||||||
if (filteredPeers.isEmpty()) {
|
|
||||||
return app.getString(R.string.no_peers_with_name_found, exitNodeName)
|
|
||||||
} else if (filteredPeers.size > 1) {
|
|
||||||
return app.getString(R.string.multiple_peers_with_name_found, exitNodeName)
|
|
||||||
} else if (!filteredPeers[0].isExitNode) {
|
|
||||||
return app.getString(
|
|
||||||
R.string.peer_with_name_is_not_an_exit_node,
|
|
||||||
exitNodeName
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
filteredPeers[0].StableID
|
|
||||||
}
|
|
||||||
|
|
||||||
val allowLanAccess = inputData.getBoolean(ALLOW_LAN_ACCESS, false)
|
|
||||||
val prefsOut = Ipn.MaskedPrefs()
|
|
||||||
prefsOut.ExitNodeID = exitNodeId
|
|
||||||
prefsOut.ExitNodeAllowLANAccess = allowLanAccess
|
|
||||||
|
|
||||||
val scope = CoroutineScope(Dispatchers.Default + Job())
|
|
||||||
var result: String? = null
|
|
||||||
Client(scope).editPrefs(prefsOut) {
|
|
||||||
result = if (it.isFailure) {
|
|
||||||
it.exceptionOrNull()?.message
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scope.coroutineContext[Job]?.join()
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
val result = runAndGetResult()
|
|
||||||
|
|
||||||
return if (result != null) {
|
|
||||||
val intent =
|
|
||||||
Intent(app, MainActivity::class.java).apply {
|
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
|
||||||
}
|
|
||||||
val pendingIntent: PendingIntent =
|
|
||||||
PendingIntent.getActivity(
|
|
||||||
app, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(app, STATUS_CHANNEL_ID)
|
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
|
||||||
.setContentTitle(app.getString(R.string.use_exit_node_intent_failed))
|
|
||||||
.setContentText(result)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
|
||||||
.setContentIntent(pendingIntent)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
app.notifyStatus(notification)
|
|
||||||
|
|
||||||
Result.failure(Data.Builder().putString(ERROR_KEY, result).build())
|
|
||||||
} else {
|
|
||||||
Result.success()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val EXIT_NODE_NAME = "EXIT_NODE_NAME"
|
|
||||||
const val ALLOW_LAN_ACCESS = "ALLOW_LAN_ACCESS"
|
|
||||||
const val ERROR_KEY = "error"
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,48 +0,0 @@
|
|||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,115 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.mdm
|
|
||||||
|
|
||||||
import android.content.RestrictionsManager
|
|
||||||
import com.tailscale.ipn.App
|
|
||||||
import kotlin.reflect.KVisibility
|
|
||||||
import kotlin.reflect.full.declaredMemberProperties
|
|
||||||
import kotlin.reflect.full.isSubclassOf
|
|
||||||
import kotlin.reflect.jvm.jvmErasure
|
|
||||||
|
|
||||||
object MDMSettings {
|
|
||||||
// The String message used in this NoSuchKeyException must match the value of
|
|
||||||
// syspolicy.ErrNoSuchKey defined in Go. We compare against its exact text
|
|
||||||
// to determine whether the requested policy setting is not configured and
|
|
||||||
// an actual syspolicy.ErrNoSuchKey should be returned from syspolicyHandler
|
|
||||||
// to the backend.
|
|
||||||
class NoSuchKeyException : Exception("no such key")
|
|
||||||
|
|
||||||
val forceEnabled = BooleanMDMSetting("ForceEnabled", "Force Enabled Connection Toggle")
|
|
||||||
|
|
||||||
// Handled on the backed
|
|
||||||
val exitNodeID = StringMDMSetting("ExitNodeID", "Forced Exit Node: Stable ID")
|
|
||||||
|
|
||||||
// (jonathan) TODO: Unused but required. There is some funky go string duration parsing required
|
|
||||||
// here.
|
|
||||||
val keyExpirationNotice = StringMDMSetting("KeyExpirationNotice", "Key Expiration Notice Period")
|
|
||||||
|
|
||||||
val loginURL = StringMDMSetting("LoginURL", "Custom control server URL")
|
|
||||||
|
|
||||||
val managedByCaption = StringMDMSetting("ManagedByCaption", "Managed By - Caption")
|
|
||||||
|
|
||||||
val managedByOrganizationName =
|
|
||||||
StringMDMSetting("ManagedByOrganizationName", "Managed By - Organization Name")
|
|
||||||
|
|
||||||
val managedByURL = StringMDMSetting("ManagedByURL", "Managed By - Support URL")
|
|
||||||
|
|
||||||
// Handled on the backend
|
|
||||||
val tailnet = StringMDMSetting("Tailnet", "Recommended/Required Tailnet Name")
|
|
||||||
|
|
||||||
val hiddenNetworkDevices =
|
|
||||||
StringArrayListMDMSetting("HiddenNetworkDevices", "Hidden Network Device Categories")
|
|
||||||
|
|
||||||
// Unused on Android
|
|
||||||
val allowIncomingConnections =
|
|
||||||
AlwaysNeverUserDecidesMDMSetting("AllowIncomingConnections", "Allow Incoming Connections")
|
|
||||||
|
|
||||||
// Unused on Android
|
|
||||||
val detectThirdPartyAppConflicts =
|
|
||||||
AlwaysNeverUserDecidesMDMSetting(
|
|
||||||
"DetectThirdPartyAppConflicts", "Detect potentially problematic third-party apps")
|
|
||||||
|
|
||||||
val exitNodeAllowLANAccess =
|
|
||||||
AlwaysNeverUserDecidesMDMSetting(
|
|
||||||
"ExitNodeAllowLANAccess", "Allow LAN Access when using an exit node")
|
|
||||||
|
|
||||||
// Handled on the backend
|
|
||||||
val postureChecking =
|
|
||||||
AlwaysNeverUserDecidesMDMSetting("PostureChecking", "Enable Posture Checking")
|
|
||||||
|
|
||||||
val useTailscaleDNSSettings =
|
|
||||||
AlwaysNeverUserDecidesMDMSetting("UseTailscaleDNSSettings", "Use Tailscale DNS Settings")
|
|
||||||
|
|
||||||
// Unused on Android
|
|
||||||
val useTailscaleSubnets =
|
|
||||||
AlwaysNeverUserDecidesMDMSetting("UseTailscaleSubnets", "Use Tailscale Subnets")
|
|
||||||
|
|
||||||
val exitNodesPicker = ShowHideMDMSetting("ExitNodesPicker", "Exit Nodes Picker")
|
|
||||||
|
|
||||||
val manageTailnetLock = ShowHideMDMSetting("ManageTailnetLock", "“Manage Tailnet lock” menu item")
|
|
||||||
|
|
||||||
// Unused on Android
|
|
||||||
val resetToDefaults = ShowHideMDMSetting("ResetToDefaults", "“Reset to Defaults” menu item")
|
|
||||||
|
|
||||||
val runExitNode = ShowHideMDMSetting("RunExitNode", "Run as Exit Node")
|
|
||||||
|
|
||||||
// Unused on Android
|
|
||||||
val testMenu = ShowHideMDMSetting("TestMenu", "Show Debug Menu")
|
|
||||||
|
|
||||||
// Unused on Android
|
|
||||||
val updateMenu = ShowHideMDMSetting("UpdateMenu", "“Update Available” menu item")
|
|
||||||
|
|
||||||
// (jonathan) TODO: Use this when suggested exit nodes are implemented
|
|
||||||
val allowedSuggestedExitNodes =
|
|
||||||
StringArrayListMDMSetting("AllowedSuggestedExitNodes", "Allowed Suggested Exit Nodes")
|
|
||||||
|
|
||||||
// Allows admins to define a list of packages that won't be routed via Tailscale.
|
|
||||||
val excludedPackages = StringMDMSetting("ExcludedPackageNames", "Excluded Package Names")
|
|
||||||
// Allows admins to define a list of packages that will be routed via Tailscale, letting all other
|
|
||||||
// apps skip the VPN tunnel.
|
|
||||||
val includedPackages = StringMDMSetting("IncludedPackageNames", "Included Package Names")
|
|
||||||
|
|
||||||
// Handled on the backend
|
|
||||||
val authKey = StringMDMSetting("AuthKey", "Auth Key for login")
|
|
||||||
|
|
||||||
val allSettings by lazy {
|
|
||||||
MDMSettings::class
|
|
||||||
.declaredMemberProperties
|
|
||||||
.filter {
|
|
||||||
it.visibility == KVisibility.PUBLIC &&
|
|
||||||
it.returnType.jvmErasure.isSubclassOf(MDMSetting::class)
|
|
||||||
}
|
|
||||||
.map { it.call(MDMSettings) as MDMSetting<*> }
|
|
||||||
}
|
|
||||||
|
|
||||||
val allSettingsByKey by lazy { allSettings.associateBy { it.key } }
|
|
||||||
|
|
||||||
fun update(app: App, restrictionsManager: RestrictionsManager?) {
|
|
||||||
val bundle = restrictionsManager?.applicationRestrictions
|
|
||||||
val preferences = lazy { app.getEncryptedPrefs() }
|
|
||||||
allSettings.forEach { it.setFrom(bundle, preferences) }
|
|
||||||
app.notifyPolicyChanged()
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,121 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.mdm
|
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.os.Bundle
|
|
||||||
import com.tailscale.ipn.App
|
|
||||||
import com.tailscale.ipn.ui.util.set
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
|
|
||||||
data class SettingState<T>(val value: T, val isSet: Boolean)
|
|
||||||
|
|
||||||
abstract class MDMSetting<T>(defaultValue: T, val key: String, val localizedTitle: String) {
|
|
||||||
val defaultValue = defaultValue
|
|
||||||
val flow = MutableStateFlow(SettingState(defaultValue, false))
|
|
||||||
|
|
||||||
fun setFrom(bundle: Bundle?, prefs: Lazy<SharedPreferences>) {
|
|
||||||
val v: T? = getFrom(bundle, prefs)
|
|
||||||
flow.set(SettingState(v ?: defaultValue, v != null))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getFrom(bundle: Bundle?, prefs: Lazy<SharedPreferences>): T? {
|
|
||||||
return when {
|
|
||||||
bundle != null -> bundle.takeIf { it.containsKey(key) }?.let { getFromBundle(it) }
|
|
||||||
else -> prefs.value.takeIf { it.contains(key) }?.let { getFromPrefs(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected abstract fun getFromBundle(bundle: Bundle): T
|
|
||||||
protected abstract fun getFromPrefs(prefs: SharedPreferences): T
|
|
||||||
}
|
|
||||||
|
|
||||||
class BooleanMDMSetting(key: String, localizedTitle: String) :
|
|
||||||
MDMSetting<Boolean>(false, key, localizedTitle) {
|
|
||||||
override fun getFromBundle(bundle: Bundle) = bundle.getBoolean(key)
|
|
||||||
override fun getFromPrefs(prefs: SharedPreferences) = prefs.getBoolean(key, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
class StringMDMSetting(key: String, localizedTitle: String) :
|
|
||||||
MDMSetting<String?>(null, key, localizedTitle) {
|
|
||||||
override fun getFromBundle(bundle: Bundle) = bundle.getString(key)
|
|
||||||
override fun getFromPrefs(prefs: SharedPreferences) = prefs.getString(key, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
class StringArrayListMDMSetting(key: String, localizedTitle: String) :
|
|
||||||
MDMSetting<List<String>?>(null, key, localizedTitle) {
|
|
||||||
override fun getFromBundle(bundle: Bundle): List<String>? {
|
|
||||||
// Try to retrieve the value as a String[] first
|
|
||||||
val stringArray = bundle.getStringArray(key)
|
|
||||||
if (stringArray != null) {
|
|
||||||
return stringArray.toList()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optionally, handle other types if necessary
|
|
||||||
val stringArrayList = bundle.getStringArrayList(key)
|
|
||||||
if (stringArrayList != null) {
|
|
||||||
return stringArrayList
|
|
||||||
}
|
|
||||||
|
|
||||||
// If neither String[] nor ArrayList<String> is found, return null
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getFromPrefs(prefs: SharedPreferences): List<String>? {
|
|
||||||
return prefs.getStringSet(key, HashSet<String>())?.toList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AlwaysNeverUserDecidesMDMSetting(key: String, localizedTitle: String) :
|
|
||||||
MDMSetting<AlwaysNeverUserDecides>(AlwaysNeverUserDecides.UserDecides, key, localizedTitle) {
|
|
||||||
override fun getFromBundle(bundle: Bundle) =
|
|
||||||
AlwaysNeverUserDecides.fromString(bundle.getString(key))
|
|
||||||
override fun getFromPrefs(prefs: SharedPreferences) =
|
|
||||||
AlwaysNeverUserDecides.fromString(prefs.getString(key, null))
|
|
||||||
}
|
|
||||||
|
|
||||||
class ShowHideMDMSetting(key: String, localizedTitle: String) :
|
|
||||||
MDMSetting<ShowHide>(ShowHide.Show, key, localizedTitle) {
|
|
||||||
override fun getFromBundle(bundle: Bundle) =
|
|
||||||
ShowHide.fromString(bundle.getString(key))
|
|
||||||
override fun getFromPrefs(prefs: SharedPreferences) =
|
|
||||||
ShowHide.fromString(prefs.getString(key, null))
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class AlwaysNeverUserDecides(val value: String) {
|
|
||||||
Always("always"),
|
|
||||||
Never("never"),
|
|
||||||
UserDecides("user-decides");
|
|
||||||
|
|
||||||
val hiddenFromUser: Boolean
|
|
||||||
get() {
|
|
||||||
return this != UserDecides
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun toString(): String {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun fromString(value: String?): AlwaysNeverUserDecides {
|
|
||||||
return values().find { it.value == value } ?: UserDecides
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class ShowHide(val value: String) {
|
|
||||||
Show("show"),
|
|
||||||
Hide("hide");
|
|
||||||
|
|
||||||
override fun toString(): String {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun fromString(value: String?): ShowHide {
|
|
||||||
return ShowHide.values().find { it.value == value } ?: Show
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,27 +0,0 @@
|
|||||||
// 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"
|
|
||||||
}
|
|
@ -1,368 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.localapi
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.tailscale.ipn.ui.model.BugReportID
|
|
||||||
import com.tailscale.ipn.ui.model.Errors
|
|
||||||
import com.tailscale.ipn.ui.model.Ipn
|
|
||||||
import com.tailscale.ipn.ui.model.IpnLocal
|
|
||||||
import com.tailscale.ipn.ui.model.IpnState
|
|
||||||
import com.tailscale.ipn.ui.model.StableNodeID
|
|
||||||
import com.tailscale.ipn.ui.model.Tailcfg
|
|
||||||
import com.tailscale.ipn.ui.util.InputStreamAdapter
|
|
||||||
import com.tailscale.ipn.util.TSLog
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.decodeFromStream
|
|
||||||
import kotlinx.serialization.serializer
|
|
||||||
import libtailscale.FilePart
|
|
||||||
import java.nio.charset.Charset
|
|
||||||
import kotlin.reflect.KType
|
|
||||||
import kotlin.reflect.typeOf
|
|
||||||
|
|
||||||
private object Endpoint {
|
|
||||||
const val DEBUG = "debug"
|
|
||||||
const val DEBUG_LOG = "debug-log"
|
|
||||||
const val BUG_REPORT = "bugreport"
|
|
||||||
const val PREFS = "prefs"
|
|
||||||
const val FILE_TARGETS = "file-targets"
|
|
||||||
const val UPLOAD_METRICS = "upload-client-metrics"
|
|
||||||
const val START = "start"
|
|
||||||
const val LOGIN_INTERACTIVE = "login-interactive"
|
|
||||||
const val RESET_AUTH = "reset-auth"
|
|
||||||
const val LOGOUT = "logout"
|
|
||||||
const val PROFILES = "profiles/"
|
|
||||||
const val PROFILES_CURRENT = "profiles/current"
|
|
||||||
const val STATUS = "status"
|
|
||||||
const val TKA_STATUS = "tka/status"
|
|
||||||
const val TKA_SIGN = "tka/sign"
|
|
||||||
const val TKA_VERIFY_DEEP_LINK = "tka/verify-deeplink"
|
|
||||||
const val PING = "ping"
|
|
||||||
const val FILES = "files"
|
|
||||||
const val FILE_PUT = "file-put"
|
|
||||||
const val TAILFS_SERVER_ADDRESS = "tailfs/fileserver-address"
|
|
||||||
const val ENABLE_EXIT_NODE = "set-use-exit-node-enabled"
|
|
||||||
}
|
|
||||||
|
|
||||||
typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit
|
|
||||||
|
|
||||||
typealias TailnetLockStatusResponseHandler = (Result<IpnState.NetworkLockStatus>) -> Unit
|
|
||||||
|
|
||||||
typealias BugReportIdHandler = (Result<BugReportID>) -> Unit
|
|
||||||
|
|
||||||
typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
|
|
||||||
|
|
||||||
typealias PingResultHandler = (Result<IpnState.PingResult>) -> Unit
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client provides a mechanism for calling Go's LocalAPIClient. Every LocalAPI endpoint has a
|
|
||||||
* corresponding method on this Client.
|
|
||||||
*/
|
|
||||||
class Client(private val scope: CoroutineScope) {
|
|
||||||
private val TAG = Client::class.simpleName
|
|
||||||
|
|
||||||
fun start(options: Ipn.Options, responseHandler: (Result<Unit>) -> Unit) {
|
|
||||||
val body = Json.encodeToString(options).toByteArray()
|
|
||||||
return post(Endpoint.START, body, responseHandler = responseHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun status(responseHandler: StatusResponseHandler) {
|
|
||||||
get(Endpoint.STATUS, responseHandler = responseHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ping(peer: Tailcfg.Node, responseHandler: PingResultHandler) {
|
|
||||||
val ip = peer.primaryIPv4Address.orEmpty()
|
|
||||||
if (ip.isEmpty()) {
|
|
||||||
responseHandler(Result.failure(Exception("No IP address for peer $peer")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val path = "${Endpoint.PING}?ip=${ip}&type=disco"
|
|
||||||
post(path, timeoutMillis = 2000L, responseHandler = responseHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun bugReportId(responseHandler: BugReportIdHandler) {
|
|
||||||
post(Endpoint.BUG_REPORT, responseHandler = responseHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun prefs(responseHandler: PrefsHandler) {
|
|
||||||
get(Endpoint.PREFS, responseHandler = responseHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun editPrefs(prefs: Ipn.MaskedPrefs, responseHandler: (Result<Ipn.Prefs>) -> Unit) {
|
|
||||||
val body = Json.encodeToString(prefs).toByteArray()
|
|
||||||
return patch(Endpoint.PREFS, body, responseHandler = responseHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setUseExitNode(use: Boolean, responseHandler: (Result<Ipn.Prefs>) -> Unit) {
|
|
||||||
val path = "${Endpoint.ENABLE_EXIT_NODE}?enabled=$use"
|
|
||||||
return post(path, responseHandler = responseHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun profiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit) {
|
|
||||||
get(Endpoint.PROFILES, responseHandler = responseHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun currentProfile(responseHandler: (Result<IpnLocal.LoginProfile>) -> Unit) {
|
|
||||||
return get(Endpoint.PROFILES_CURRENT, responseHandler = responseHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addProfile(responseHandler: (Result<String>) -> Unit = {}) {
|
|
||||||
return put(Endpoint.PROFILES, responseHandler = responseHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteProfile(
|
|
||||||
profile: IpnLocal.LoginProfile,
|
|
||||||
responseHandler: (Result<String>) -> Unit = {}
|
|
||||||
) {
|
|
||||||
return delete(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun switchProfile(
|
|
||||||
profile: IpnLocal.LoginProfile,
|
|
||||||
responseHandler: (Result<String>) -> Unit = {}
|
|
||||||
) {
|
|
||||||
return post(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startLoginInteractive(responseHandler: (Result<Unit>) -> Unit) {
|
|
||||||
return post(Endpoint.LOGIN_INTERACTIVE, responseHandler = responseHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun logout(responseHandler: (Result<String>) -> Unit) {
|
|
||||||
return post(Endpoint.LOGOUT, responseHandler = responseHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun tailnetLockStatus(responseHandler: TailnetLockStatusResponseHandler) {
|
|
||||||
get(Endpoint.TKA_STATUS, responseHandler = responseHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun fileTargets(responseHandler: (Result<List<Ipn.FileTarget>>) -> Unit) {
|
|
||||||
get(Endpoint.FILE_TARGETS, responseHandler = responseHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun putTaildropFiles(
|
|
||||||
context: Context,
|
|
||||||
peerId: StableNodeID,
|
|
||||||
files: Collection<Ipn.OutgoingFile>,
|
|
||||||
responseHandler: (Result<String>) -> Unit
|
|
||||||
) {
|
|
||||||
val manifest = Json.encodeToString(files)
|
|
||||||
val manifestPart = FilePart()
|
|
||||||
manifestPart.body = InputStreamAdapter(manifest.byteInputStream(Charset.defaultCharset()))
|
|
||||||
manifestPart.filename = "manifest.json"
|
|
||||||
manifestPart.contentType = "application/json"
|
|
||||||
val parts = mutableListOf(manifestPart)
|
|
||||||
|
|
||||||
try {
|
|
||||||
parts.addAll(
|
|
||||||
files.map { file ->
|
|
||||||
val stream =
|
|
||||||
context.contentResolver.openInputStream(file.uri)
|
|
||||||
?: throw Exception("Error opening file stream")
|
|
||||||
|
|
||||||
val part = FilePart()
|
|
||||||
part.filename = file.Name
|
|
||||||
part.contentLength = file.DeclaredSize
|
|
||||||
part.body = InputStreamAdapter(stream)
|
|
||||||
part
|
|
||||||
})
|
|
||||||
} catch (e: Exception) {
|
|
||||||
parts.forEach { it.body.close() }
|
|
||||||
TSLog.e(TAG, "Error creating file upload body: $e")
|
|
||||||
responseHandler(Result.failure(e))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return postMultipart(
|
|
||||||
"${Endpoint.FILE_PUT}/${peerId}",
|
|
||||||
FileParts(parts),
|
|
||||||
responseHandler,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun <reified T> get(
|
|
||||||
path: String,
|
|
||||||
body: ByteArray? = null,
|
|
||||||
noinline responseHandler: (Result<T>) -> Unit
|
|
||||||
) {
|
|
||||||
Request(
|
|
||||||
scope = scope,
|
|
||||||
method = "GET",
|
|
||||||
path = path,
|
|
||||||
body = body,
|
|
||||||
responseType = typeOf<T>(),
|
|
||||||
responseHandler = responseHandler)
|
|
||||||
.execute()
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun <reified T> put(
|
|
||||||
path: String,
|
|
||||||
body: ByteArray? = null,
|
|
||||||
noinline responseHandler: (Result<T>) -> Unit
|
|
||||||
) {
|
|
||||||
Request(
|
|
||||||
scope = scope,
|
|
||||||
method = "PUT",
|
|
||||||
path = path,
|
|
||||||
body = body,
|
|
||||||
responseType = typeOf<T>(),
|
|
||||||
responseHandler = responseHandler)
|
|
||||||
.execute()
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun <reified T> post(
|
|
||||||
path: String,
|
|
||||||
body: ByteArray? = null,
|
|
||||||
timeoutMillis: Long = 30000,
|
|
||||||
noinline responseHandler: (Result<T>) -> Unit
|
|
||||||
) {
|
|
||||||
Request(
|
|
||||||
scope = scope,
|
|
||||||
method = "POST",
|
|
||||||
path = path,
|
|
||||||
body = body,
|
|
||||||
timeoutMillis = timeoutMillis,
|
|
||||||
responseType = typeOf<T>(),
|
|
||||||
responseHandler = responseHandler)
|
|
||||||
.execute()
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun <reified T> postMultipart(
|
|
||||||
path: String,
|
|
||||||
parts: FileParts,
|
|
||||||
noinline responseHandler: (Result<T>) -> Unit
|
|
||||||
) {
|
|
||||||
Request(
|
|
||||||
scope = scope,
|
|
||||||
method = "POST",
|
|
||||||
path = path,
|
|
||||||
parts = parts,
|
|
||||||
timeoutMillis = 24 * 60 * 60 * 1000, // 24 hours
|
|
||||||
responseType = typeOf<T>(),
|
|
||||||
responseHandler = responseHandler)
|
|
||||||
.execute()
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun <reified T> patch(
|
|
||||||
path: String,
|
|
||||||
body: ByteArray? = null,
|
|
||||||
noinline responseHandler: (Result<T>) -> Unit
|
|
||||||
) {
|
|
||||||
Request(
|
|
||||||
scope = scope,
|
|
||||||
method = "PATCH",
|
|
||||||
path = path,
|
|
||||||
body = body,
|
|
||||||
responseType = typeOf<T>(),
|
|
||||||
responseHandler = responseHandler)
|
|
||||||
.execute()
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun <reified T> delete(
|
|
||||||
path: String,
|
|
||||||
noinline responseHandler: (Result<T>) -> Unit
|
|
||||||
) {
|
|
||||||
Request(
|
|
||||||
scope = scope,
|
|
||||||
method = "DELETE",
|
|
||||||
path = path,
|
|
||||||
responseType = typeOf<T>(),
|
|
||||||
responseHandler = responseHandler)
|
|
||||||
.execute()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Request<T>(
|
|
||||||
private val scope: CoroutineScope,
|
|
||||||
private val method: String,
|
|
||||||
path: String,
|
|
||||||
private val body: ByteArray? = null,
|
|
||||||
private val parts: FileParts? = null,
|
|
||||||
private val timeoutMillis: Long = 30000,
|
|
||||||
private val responseType: KType,
|
|
||||||
private val responseHandler: (Result<T>) -> Unit
|
|
||||||
) {
|
|
||||||
private val fullPath = "/localapi/v0/$path"
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "LocalAPIRequest"
|
|
||||||
|
|
||||||
private val jsonDecoder = Json { ignoreUnknownKeys = true }
|
|
||||||
|
|
||||||
private lateinit var app: libtailscale.Application
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
fun setApp(newApp: libtailscale.Application) {
|
|
||||||
app = newApp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
|
||||||
fun execute() {
|
|
||||||
scope.launch(Dispatchers.IO) {
|
|
||||||
TSLog.d(TAG, "Executing request:${method}:${fullPath} on app $app")
|
|
||||||
try {
|
|
||||||
val resp =
|
|
||||||
if (parts != null) app.callLocalAPIMultipart(timeoutMillis, method, fullPath, parts)
|
|
||||||
else
|
|
||||||
app.callLocalAPI(
|
|
||||||
timeoutMillis,
|
|
||||||
method,
|
|
||||||
fullPath,
|
|
||||||
body?.let { InputStreamAdapter(it.inputStream()) })
|
|
||||||
// TODO: use the streaming body for performance
|
|
||||||
// An empty body is a perfectly valid response and indicates success
|
|
||||||
val respData = resp.bodyBytes() ?: ByteArray(0)
|
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val response: Result<T> =
|
|
||||||
when (responseType) {
|
|
||||||
typeOf<String>() -> Result.success(respData.decodeToString() as T)
|
|
||||||
typeOf<Unit>() -> Result.success(Unit as T)
|
|
||||||
else ->
|
|
||||||
try {
|
|
||||||
Result.success(
|
|
||||||
jsonDecoder.decodeFromStream(
|
|
||||||
Json.serializersModule.serializer(responseType), respData.inputStream())
|
|
||||||
as T)
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
// If we couldn't parse the response body, assume it's an error response
|
|
||||||
try {
|
|
||||||
val error =
|
|
||||||
jsonDecoder.decodeFromStream<Errors.GenericError>(respData.inputStream())
|
|
||||||
throw Exception(error.error)
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
Result.failure(t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (resp.statusCode() >= 400) {
|
|
||||||
throw Exception(
|
|
||||||
"Request failed with status ${resp.statusCode()}: ${respData.toString(Charset.defaultCharset())}")
|
|
||||||
}
|
|
||||||
// The response handler will invoked internally by the request parser
|
|
||||||
scope.launch { responseHandler(response) }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
TSLog.e(TAG, "Error executing request:${method}:${fullPath}: $e")
|
|
||||||
scope.launch { responseHandler(Result.failure(e)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FileParts(private val parts: List<FilePart>) : libtailscale.FileParts {
|
|
||||||
override fun get(i: Int): FilePart {
|
|
||||||
return parts[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun len(): Int {
|
|
||||||
return parts.size
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,30 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
@ -1,85 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.model
|
|
||||||
|
|
||||||
import androidx.compose.material3.ListItemColors
|
|
||||||
import androidx.compose.material3.ListItemDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import com.tailscale.ipn.ui.theme.warning
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
class Health {
|
|
||||||
@Serializable
|
|
||||||
data class State(
|
|
||||||
// WarnableCode -> UnhealthyState or null
|
|
||||||
var Warnings: Map<String, UnhealthyState?>? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class UnhealthyState(
|
|
||||||
var WarnableCode: String,
|
|
||||||
var Severity: Severity,
|
|
||||||
var Title: String,
|
|
||||||
var Text: String,
|
|
||||||
var BrokenSince: String? = null,
|
|
||||||
var Args: Map<String, String>? = null,
|
|
||||||
var ImpactsConnectivity: Boolean? = false,
|
|
||||||
var DependsOn: List<String>? = null, // an array of WarnableCodes this depends on
|
|
||||||
) : Comparable<UnhealthyState> {
|
|
||||||
fun hiddenByDependencies(currentWarnableCodes: Set<String>): Boolean {
|
|
||||||
return this.DependsOn?.let {
|
|
||||||
it.any { depWarnableCode -> currentWarnableCodes.contains(depWarnableCode) }
|
|
||||||
} == true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun compareTo(other: UnhealthyState): Int {
|
|
||||||
// Compare by severity first
|
|
||||||
val severityComparison = Severity.compareTo(other.Severity)
|
|
||||||
if (severityComparison != 0) {
|
|
||||||
return severityComparison
|
|
||||||
}
|
|
||||||
|
|
||||||
// If severities are equal, compare by warnableCode
|
|
||||||
return WarnableCode.compareTo(other.WarnableCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
enum class Severity : Comparable<Severity> {
|
|
||||||
low,
|
|
||||||
medium,
|
|
||||||
high;
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun listItemColors(): ListItemColors {
|
|
||||||
val default = ListItemDefaults.colors()
|
|
||||||
return when (this) {
|
|
||||||
Severity.low ->
|
|
||||||
ListItemColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
headlineColor = MaterialTheme.colorScheme.secondary,
|
|
||||||
leadingIconColor = MaterialTheme.colorScheme.secondary,
|
|
||||||
overlineColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.8f),
|
|
||||||
supportingTextColor = MaterialTheme.colorScheme.secondary,
|
|
||||||
trailingIconColor = MaterialTheme.colorScheme.secondary,
|
|
||||||
disabledHeadlineColor = default.disabledHeadlineColor,
|
|
||||||
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
|
||||||
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
|
||||||
Severity.medium,
|
|
||||||
Severity.high ->
|
|
||||||
ListItemColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.warning,
|
|
||||||
headlineColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f),
|
|
||||||
supportingTextColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
disabledHeadlineColor = default.disabledHeadlineColor,
|
|
||||||
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
|
||||||
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,240 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.model
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.Transient
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
class Ipn {
|
|
||||||
|
|
||||||
// Represents the overall state of the Tailscale engine.
|
|
||||||
enum class State(val value: Int) {
|
|
||||||
NoState(0),
|
|
||||||
InUseOtherUser(1),
|
|
||||||
NeedsLogin(2),
|
|
||||||
NeedsMachineAuth(3),
|
|
||||||
Stopped(4),
|
|
||||||
Starting(5),
|
|
||||||
Running(6),
|
|
||||||
// Stopping represents a state where a request to stop Tailscale has been issue but has not
|
|
||||||
// completed. This state allows UI to optimistically reflect a stopped state, and to fallback if
|
|
||||||
// necessary.
|
|
||||||
Stopping(7);
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun fromInt(value: Int): State {
|
|
||||||
return State.values().firstOrNull { it.value == value } ?: NoState
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A nofitication message recieved on the Notify bus. Fields will be populated based
|
|
||||||
// on which NotifyWatchOpts were set when the Notifier was created.
|
|
||||||
@Serializable
|
|
||||||
data class Notify(
|
|
||||||
val Version: String? = null,
|
|
||||||
val ErrMessage: String? = null,
|
|
||||||
val LoginFinished: Empty.Message? = null,
|
|
||||||
val FilesWaiting: Empty.Message? = null,
|
|
||||||
val OutgoingFiles: List<OutgoingFile>? = null,
|
|
||||||
val State: Int? = null,
|
|
||||||
var Prefs: Prefs? = null,
|
|
||||||
var NetMap: Netmap.NetworkMap? = null,
|
|
||||||
var Engine: EngineStatus? = null,
|
|
||||||
var BrowseToURL: String? = null,
|
|
||||||
var BackendLogId: String? = null,
|
|
||||||
var LocalTCPPort: Int? = null,
|
|
||||||
var IncomingFiles: List<PartialFile>? = null,
|
|
||||||
var ClientVersion: Tailcfg.ClientVersion? = null,
|
|
||||||
var TailFSShares: List<String>? = null,
|
|
||||||
var Health: Health.State? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Prefs(
|
|
||||||
var ControlURL: String = "",
|
|
||||||
var RouteAll: Boolean = false,
|
|
||||||
var AllowsSingleHosts: Boolean = false,
|
|
||||||
var CorpDNS: Boolean = false,
|
|
||||||
var WantRunning: Boolean = false,
|
|
||||||
var LoggedOut: Boolean = false,
|
|
||||||
var ShieldsUp: Boolean = false,
|
|
||||||
var AdvertiseRoutes: List<String>? = null,
|
|
||||||
var AdvertiseTags: List<String>? = null,
|
|
||||||
var ExitNodeID: StableNodeID? = null,
|
|
||||||
var ExitNodeAllowLANAccess: Boolean = false,
|
|
||||||
var Config: Persist.Persist? = null,
|
|
||||||
var ForceDaemon: Boolean = false,
|
|
||||||
var HostName: String = "",
|
|
||||||
var AutoUpdate: AutoUpdatePrefs? = AutoUpdatePrefs(true, true),
|
|
||||||
var InternalExitNodePrior: String? = null,
|
|
||||||
) {
|
|
||||||
|
|
||||||
// For the InternalExitNodePrior and ExitNodeId, these will treats the empty string as null to
|
|
||||||
// simplify the downstream logic.
|
|
||||||
|
|
||||||
val selectedExitNodeID: String?
|
|
||||||
get() {
|
|
||||||
return if (InternalExitNodePrior.isNullOrEmpty()) null else InternalExitNodePrior
|
|
||||||
}
|
|
||||||
|
|
||||||
val activeExitNodeID: String?
|
|
||||||
get() {
|
|
||||||
return if (ExitNodeID.isNullOrEmpty()) null else ExitNodeID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class MaskedPrefs(
|
|
||||||
var ControlURLSet: Boolean? = null,
|
|
||||||
var RouteAllSet: Boolean? = null,
|
|
||||||
var CorpDNSSet: Boolean? = null,
|
|
||||||
var ExitNodeIDSet: Boolean? = null,
|
|
||||||
var ExitNodeAllowLANAccessSet: Boolean? = null,
|
|
||||||
var WantRunningSet: Boolean? = null,
|
|
||||||
var ShieldsUpSet: Boolean? = null,
|
|
||||||
var AdvertiseRoutesSet: Boolean? = null,
|
|
||||||
var ForceDaemonSet: Boolean? = null,
|
|
||||||
var HostnameSet: Boolean? = null,
|
|
||||||
var InternalExitNodePriorSet: Boolean? = null,
|
|
||||||
) {
|
|
||||||
|
|
||||||
var ControlURL: String? = null
|
|
||||||
set(value) {
|
|
||||||
field = value
|
|
||||||
ControlURLSet = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var RouteAll: Boolean? = null
|
|
||||||
set(value) {
|
|
||||||
field = value
|
|
||||||
RouteAllSet = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var CorpDNS: Boolean? = null
|
|
||||||
set(value) {
|
|
||||||
field = value
|
|
||||||
CorpDNSSet = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var ExitNodeID: StableNodeID? = null
|
|
||||||
set(value) {
|
|
||||||
field = value
|
|
||||||
ExitNodeIDSet = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var InternalExitNodePrior: String? = null
|
|
||||||
set(value) {
|
|
||||||
field = value
|
|
||||||
InternalExitNodePriorSet = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var ExitNodeAllowLANAccess: Boolean? = null
|
|
||||||
set(value) {
|
|
||||||
field = value
|
|
||||||
ExitNodeAllowLANAccessSet = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var WantRunning: Boolean? = null
|
|
||||||
set(value) {
|
|
||||||
field = value
|
|
||||||
WantRunningSet = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var ShieldsUp: Boolean? = null
|
|
||||||
set(value) {
|
|
||||||
field = value
|
|
||||||
ShieldsUpSet = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var AdvertiseRoutes: List<String>? = null
|
|
||||||
set(value) {
|
|
||||||
field = value
|
|
||||||
AdvertiseRoutesSet = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var ForceDaemon: Boolean? = null
|
|
||||||
set(value) {
|
|
||||||
field = value
|
|
||||||
ForceDaemonSet = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var Hostname: String? = null
|
|
||||||
set(value) {
|
|
||||||
field = value
|
|
||||||
HostnameSet = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class AutoUpdatePrefs(
|
|
||||||
var Check: Boolean? = null,
|
|
||||||
var Apply: Boolean? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class EngineStatus(
|
|
||||||
val RBytes: Long,
|
|
||||||
val WBytes: Long,
|
|
||||||
val NumLive: Int,
|
|
||||||
val LivePeers: Map<String, IpnState.PeerStatusLite>,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class PartialFile(
|
|
||||||
val Name: String,
|
|
||||||
val Started: String,
|
|
||||||
val DeclaredSize: Long,
|
|
||||||
val Received: Long,
|
|
||||||
val PartialPath: String? = null,
|
|
||||||
var FinalPath: String? = null,
|
|
||||||
val Done: Boolean? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class OutgoingFile(
|
|
||||||
val ID: String = "",
|
|
||||||
val Name: String,
|
|
||||||
val PeerID: StableNodeID = "",
|
|
||||||
val Started: String = "",
|
|
||||||
val DeclaredSize: Long,
|
|
||||||
val Sent: Long = 0L,
|
|
||||||
val PartialPath: String? = null,
|
|
||||||
var FinalPath: String? = null,
|
|
||||||
val Finished: Boolean = false,
|
|
||||||
val Succeeded: Boolean = false,
|
|
||||||
) {
|
|
||||||
@Transient lateinit var uri: Uri // only used on client
|
|
||||||
|
|
||||||
fun prepare(peerId: StableNodeID): OutgoingFile {
|
|
||||||
val f = copy(ID = UUID.randomUUID().toString(), PeerID = peerId)
|
|
||||||
f.uri = uri
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable data class FileTarget(var Node: Tailcfg.Node, var PeerAPIURL: String)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Options(
|
|
||||||
var FrontendLogID: String? = null,
|
|
||||||
var UpdatePrefs: Prefs? = null,
|
|
||||||
var AuthKey: String? = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
class Persist {
|
|
||||||
@Serializable
|
|
||||||
data class Persist(
|
|
||||||
var PrivateMachineKey: String =
|
|
||||||
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
|
|
||||||
var PrivateNodeKey: String =
|
|
||||||
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
|
|
||||||
var OldPrivateNodeKey: String =
|
|
||||||
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
|
|
||||||
var Provider: String = "",
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,152 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import java.net.URL
|
|
||||||
|
|
||||||
class IpnState {
|
|
||||||
@Serializable
|
|
||||||
data class PeerStatusLite(
|
|
||||||
val RxBytes: Long,
|
|
||||||
val TxBytes: Long,
|
|
||||||
val LastHandshake: String,
|
|
||||||
val NodeKey: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class PeerStatus(
|
|
||||||
val ID: StableNodeID,
|
|
||||||
val HostName: String,
|
|
||||||
val DNSName: String,
|
|
||||||
val TailscaleIPs: List<Addr>? = null,
|
|
||||||
val Tags: List<String>? = null,
|
|
||||||
val PrimaryRoutes: List<String>? = null,
|
|
||||||
val Addrs: List<String>? = null,
|
|
||||||
val CurAddr: String? = null,
|
|
||||||
val Relay: String? = null,
|
|
||||||
val Online: Boolean,
|
|
||||||
val ExitNode: Boolean,
|
|
||||||
val ExitNodeOption: Boolean,
|
|
||||||
val Active: Boolean,
|
|
||||||
val PeerAPIURL: List<String>? = null,
|
|
||||||
val Capabilities: List<String>? = null,
|
|
||||||
val SSH_HostKeys: List<String>? = null,
|
|
||||||
val ShareeNode: Boolean? = null,
|
|
||||||
val Expired: Boolean? = null,
|
|
||||||
val Location: Tailcfg.Location? = null,
|
|
||||||
) {
|
|
||||||
fun computedName(status: Status): String {
|
|
||||||
val name = DNSName
|
|
||||||
val suffix = status.CurrentTailnet?.MagicDNSSuffix
|
|
||||||
|
|
||||||
suffix ?: return name
|
|
||||||
|
|
||||||
if (!(name.endsWith("." + suffix + "."))) {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
return name.dropLast(suffix.count() + 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ExitNodeStatus(
|
|
||||||
val ID: StableNodeID,
|
|
||||||
val Online: Boolean,
|
|
||||||
val TailscaleIPs: List<Prefix>? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class TailnetStatus(
|
|
||||||
val Name: String,
|
|
||||||
val MagicDNSSuffix: String,
|
|
||||||
val MagicDNSEnabled: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Status(
|
|
||||||
val Version: String,
|
|
||||||
val TUN: Boolean,
|
|
||||||
val BackendState: String,
|
|
||||||
val AuthURL: String,
|
|
||||||
val TailscaleIPs: List<Addr>? = null,
|
|
||||||
val Self: PeerStatus? = null,
|
|
||||||
val ExitNodeStatus: ExitNodeStatus? = null,
|
|
||||||
val Health: List<String>? = null,
|
|
||||||
val CurrentTailnet: TailnetStatus? = null,
|
|
||||||
val CertDomains: List<String>? = null,
|
|
||||||
val Peer: Map<String, PeerStatus>? = null,
|
|
||||||
val User: Map<String, Tailcfg.UserProfile>? = null,
|
|
||||||
val ClientVersion: Tailcfg.ClientVersion? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class NetworkLockStatus(
|
|
||||||
var Enabled: Boolean? = null,
|
|
||||||
var PublicKey: String? = null,
|
|
||||||
var NodeKey: String? = null,
|
|
||||||
var NodeKeySigned: Boolean? = null,
|
|
||||||
var FilteredPeers: List<TKAFilteredPeer>? = null,
|
|
||||||
var StateID: ULong? = null,
|
|
||||||
var TrustedKeys: List<TKAKey>? = null
|
|
||||||
) {
|
|
||||||
|
|
||||||
fun IsPublicKeyTrusted(): Boolean {
|
|
||||||
return TrustedKeys?.any { it.Key == PublicKey } == true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class TKAFilteredPeer(
|
|
||||||
var Name: String,
|
|
||||||
var TailscaleIPs: List<Addr>,
|
|
||||||
var NodeKey: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable data class TKAKey(var Key: String)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class PingResult(
|
|
||||||
var IP: Addr,
|
|
||||||
var Err: String,
|
|
||||||
var LatencySeconds: Double,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
class IpnLocal {
|
|
||||||
@Serializable
|
|
||||||
data class LoginProfile(
|
|
||||||
var ID: String,
|
|
||||||
val Name: String,
|
|
||||||
val Key: String,
|
|
||||||
val UserProfile: Tailcfg.UserProfile,
|
|
||||||
val NetworkProfile: Tailcfg.NetworkProfile? = null,
|
|
||||||
val LocalUserID: String,
|
|
||||||
var ControlURL: String? = null,
|
|
||||||
) {
|
|
||||||
fun isEmpty(): Boolean {
|
|
||||||
return ID.isEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns true if the profile uses a custom control server (not Tailscale SaaS).
|
|
||||||
private fun isUsingCustomControlServer(): Boolean {
|
|
||||||
return ControlURL != null && ControlURL != "https://controlplane.tailscale.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns the hostname of the custom control server, if any was set.
|
|
||||||
//
|
|
||||||
// Returns null if the ControlURL provided by the backend is an invalid URL, and
|
|
||||||
// a hostname cannot be extracted.
|
|
||||||
fun customControlServerHostname(): String? {
|
|
||||||
if (!isUsingCustomControlServer()) return null
|
|
||||||
|
|
||||||
return try {
|
|
||||||
URL(ControlURL).host
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,55 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,90 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.model
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
|
||||||
import com.google.accompanist.permissions.PermissionState
|
|
||||||
import com.google.accompanist.permissions.isGranted
|
|
||||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
|
||||||
import com.google.accompanist.permissions.shouldShowRationale
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
|
|
||||||
object Permissions {
|
|
||||||
/** Permissions to prompt for on MainView. */
|
|
||||||
@OptIn(ExperimentalPermissionsApi::class)
|
|
||||||
val prompt: List<Pair<Permission, PermissionState>>
|
|
||||||
@Composable
|
|
||||||
get() {
|
|
||||||
val permissionStates = rememberMultiplePermissionsState(permissions = all.map { it.name })
|
|
||||||
return all.zip(permissionStates.permissions).filter { (_, state) ->
|
|
||||||
!state.status.isGranted && !state.status.shouldShowRationale
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** All permissions with granted status. */
|
|
||||||
@OptIn(ExperimentalPermissionsApi::class)
|
|
||||||
val withGrantedStatus: List<Pair<Permission, Boolean>>
|
|
||||||
@Composable
|
|
||||||
get() {
|
|
||||||
val permissionStates = rememberMultiplePermissionsState(permissions = all.map { it.name })
|
|
||||||
val result = mutableListOf<Pair<Permission, Boolean>>()
|
|
||||||
result.addAll(
|
|
||||||
all.zip(permissionStates.permissions).map { (permission, state) ->
|
|
||||||
Pair(permission, state.status.isGranted)
|
|
||||||
})
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
// On Android versions prior to 13, we have to programmatically check if notifications are
|
|
||||||
// being allowed.
|
|
||||||
val notificationsEnabled =
|
|
||||||
NotificationManagerCompat.from(LocalContext.current).areNotificationsEnabled()
|
|
||||||
result.add(
|
|
||||||
Pair(
|
|
||||||
Permission(
|
|
||||||
"",
|
|
||||||
R.string.permission_post_notifications,
|
|
||||||
R.string.permission_post_notifications_needed),
|
|
||||||
notificationsEnabled))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All permissions that Tailscale requires. MainView takes care of prompting for permissions, and
|
|
||||||
* PermissionsView provides a list of permissions with corresponding statuses and a link to the
|
|
||||||
* application settings.
|
|
||||||
*
|
|
||||||
* When new permissions are needed, just add them to this list and the necessary strings to
|
|
||||||
* strings.xml and the rest should take care of itself.
|
|
||||||
*/
|
|
||||||
private val all: List<Permission> by lazy {
|
|
||||||
val result = mutableListOf<Permission>()
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
|
||||||
result.add(
|
|
||||||
Permission(
|
|
||||||
Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
|
||||||
R.string.permission_write_external_storage,
|
|
||||||
R.string.permission_write_external_storage_needed,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
result.add(
|
|
||||||
Permission(
|
|
||||||
Manifest.permission.POST_NOTIFICATIONS,
|
|
||||||
R.string.permission_post_notifications,
|
|
||||||
R.string.permission_post_notifications_needed))
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Permission(
|
|
||||||
val name: String,
|
|
||||||
val title: Int,
|
|
||||||
val description: Int,
|
|
||||||
)
|
|
@ -1,205 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.model
|
|
||||||
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
import com.tailscale.ipn.ui.Links
|
|
||||||
import com.tailscale.ipn.ui.theme.off
|
|
||||||
import com.tailscale.ipn.ui.theme.on
|
|
||||||
import com.tailscale.ipn.ui.util.ComposableStringFormatter
|
|
||||||
import com.tailscale.ipn.ui.util.DisplayAddress
|
|
||||||
import com.tailscale.ipn.ui.util.TimeUtil
|
|
||||||
import com.tailscale.ipn.ui.util.flag
|
|
||||||
import com.tailscale.ipn.ui.viewModel.PeerSettingInfo
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
import java.util.Date
|
|
||||||
|
|
||||||
class Tailcfg {
|
|
||||||
@Serializable
|
|
||||||
data class ClientVersion(
|
|
||||||
var RunningLatest: Boolean? = null,
|
|
||||||
var LatestVersion: String? = null,
|
|
||||||
var UrgentSecurityUpdate: Boolean? = null,
|
|
||||||
var Notify: Boolean? = null,
|
|
||||||
var NotifyURL: String? = null,
|
|
||||||
var NotifyText: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class UserProfile(
|
|
||||||
val ID: Long,
|
|
||||||
val DisplayName: String,
|
|
||||||
val LoginName: String,
|
|
||||||
val ProfilePicURL: String? = null,
|
|
||||||
) {
|
|
||||||
fun isTaggedDevice(): Boolean {
|
|
||||||
return LoginName == "tagged-devices"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Hostinfo(
|
|
||||||
var IPNVersion: String? = null,
|
|
||||||
var FrontendLogID: String? = null,
|
|
||||||
var BackendLogID: String? = null,
|
|
||||||
var OS: String? = null,
|
|
||||||
var OSVersion: String? = null,
|
|
||||||
var Env: String? = null,
|
|
||||||
var Distro: String? = null,
|
|
||||||
var DistroVersion: String? = null,
|
|
||||||
var DistroCodeName: String? = null,
|
|
||||||
var Desktop: Boolean? = null,
|
|
||||||
var Package: String? = null,
|
|
||||||
var DeviceModel: String? = null,
|
|
||||||
var ShareeNode: Boolean? = null,
|
|
||||||
var Hostname: String? = null,
|
|
||||||
var ShieldsUp: Boolean? = null,
|
|
||||||
var NoLogsNoSupport: Boolean? = null,
|
|
||||||
var Machine: String? = null,
|
|
||||||
var RoutableIPs: List<Prefix>? = null,
|
|
||||||
var Services: List<Service>? = null,
|
|
||||||
var Location: Location? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Node(
|
|
||||||
var ID: NodeID,
|
|
||||||
var StableID: StableNodeID,
|
|
||||||
var Name: String,
|
|
||||||
var User: UserID,
|
|
||||||
var Sharer: UserID? = null,
|
|
||||||
var Key: KeyNodePublic,
|
|
||||||
var KeyExpiry: String,
|
|
||||||
var Machine: MachineKey,
|
|
||||||
var Addresses: List<Prefix>? = null,
|
|
||||||
var AllowedIPs: List<Prefix>? = null,
|
|
||||||
var Endpoints: List<String>? = null,
|
|
||||||
var Hostinfo: Hostinfo,
|
|
||||||
var Created: Time,
|
|
||||||
var LastSeen: Time? = null,
|
|
||||||
var Online: Boolean? = null,
|
|
||||||
var Capabilities: List<String>? = null,
|
|
||||||
var CapMap: Map<String, JsonElement?>? = null,
|
|
||||||
var ComputedName: String?,
|
|
||||||
var ComputedNameWithHost: String?
|
|
||||||
) {
|
|
||||||
val isAdmin: Boolean
|
|
||||||
get() =
|
|
||||||
Capabilities?.contains("https://tailscale.com/cap/is-admin") == true ||
|
|
||||||
CapMap?.contains("https://tailscale.com/cap/is-admin") == true
|
|
||||||
|
|
||||||
// Derives the url to directly administer a node
|
|
||||||
val nodeAdminUrl: String
|
|
||||||
get() = primaryIPv4Address?.let { "${Links.ADMIN_URL}/machines/${it}" } ?: Links.ADMIN_URL
|
|
||||||
|
|
||||||
val primaryIPv4Address: String?
|
|
||||||
get() = displayAddresses.firstOrNull { it.type == DisplayAddress.addrType.V4 }?.address
|
|
||||||
|
|
||||||
val primaryIPv6Address: String?
|
|
||||||
get() = displayAddresses.firstOrNull { it.type == DisplayAddress.addrType.V6 }?.address
|
|
||||||
|
|
||||||
// isExitNode reproduces the Go logic in local.go peerStatusFromNode
|
|
||||||
val isExitNode: Boolean =
|
|
||||||
AllowedIPs?.contains("0.0.0.0/0") ?: false && AllowedIPs?.contains("::/0") ?: false
|
|
||||||
|
|
||||||
val isMullvadNode: Boolean
|
|
||||||
get() = Name.endsWith(".mullvad.ts.net.")
|
|
||||||
|
|
||||||
val displayName: String
|
|
||||||
get() = ComputedName ?: Name
|
|
||||||
|
|
||||||
val exitNodeName: String
|
|
||||||
get() {
|
|
||||||
if (isMullvadNode &&
|
|
||||||
Hostinfo.Location?.Country != null &&
|
|
||||||
Hostinfo.Location?.City != null &&
|
|
||||||
Hostinfo.Location?.CountryCode != null) {
|
|
||||||
return "${Hostinfo.Location!!.CountryCode!!.flag()} ${Hostinfo.Location!!.Country!!}: ${Hostinfo.Location!!.City!!}"
|
|
||||||
}
|
|
||||||
return displayName
|
|
||||||
}
|
|
||||||
|
|
||||||
val keyDoesNotExpire: Boolean
|
|
||||||
get() = KeyExpiry == "0001-01-01T00:00:00Z"
|
|
||||||
|
|
||||||
fun isSelfNode(netmap: Netmap.NetworkMap): Boolean = StableID == netmap.SelfNode.StableID
|
|
||||||
|
|
||||||
fun connectedOrSelfNode(nm: Netmap.NetworkMap?) =
|
|
||||||
Online == true || StableID == nm?.SelfNode?.StableID
|
|
||||||
|
|
||||||
fun connectedStrRes(nm: Netmap.NetworkMap?) =
|
|
||||||
if (connectedOrSelfNode(nm)) R.string.connected else R.string.not_connected
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun connectedColor(nm: Netmap.NetworkMap?) =
|
|
||||||
if (connectedOrSelfNode(nm)) MaterialTheme.colorScheme.on else MaterialTheme.colorScheme.off
|
|
||||||
|
|
||||||
val nameWithoutTrailingDot = Name.trimEnd('.')
|
|
||||||
|
|
||||||
val displayAddresses: List<DisplayAddress>
|
|
||||||
get() {
|
|
||||||
var addresses = mutableListOf<DisplayAddress>()
|
|
||||||
addresses.add(DisplayAddress(nameWithoutTrailingDot))
|
|
||||||
Addresses?.let { addresses.addAll(it.map { addr -> DisplayAddress(addr) }) }
|
|
||||||
return addresses
|
|
||||||
}
|
|
||||||
|
|
||||||
val info: List<PeerSettingInfo>
|
|
||||||
get() {
|
|
||||||
val result = mutableListOf<PeerSettingInfo>()
|
|
||||||
if (Hostinfo.OS?.isNotEmpty() == true) {
|
|
||||||
result.add(
|
|
||||||
PeerSettingInfo(R.string.os, ComposableStringFormatter(Hostinfo.OS!!)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (keyDoesNotExpire) {
|
|
||||||
result.add(
|
|
||||||
PeerSettingInfo(
|
|
||||||
R.string.key_expiry, ComposableStringFormatter(R.string.deviceKeyNeverExpires)))
|
|
||||||
} else {
|
|
||||||
result.add(PeerSettingInfo(R.string.key_expiry, TimeUtil.keyExpiryFromGoTime(KeyExpiry)))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun expiryLabel(): String {
|
|
||||||
if (KeyExpiry == GoZeroTimeString) {
|
|
||||||
return stringResource(R.string.deviceKeyNeverExpires)
|
|
||||||
}
|
|
||||||
|
|
||||||
val expDate = TimeUtil.dateFromGoString(KeyExpiry)
|
|
||||||
val template = if (expDate > Date()) R.string.deviceKeyExpires else R.string.deviceKeyExpired
|
|
||||||
return stringResource(template, TimeUtil.keyExpiryFromGoTime(KeyExpiry).getString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Service(var Proto: String, var Port: Int, var Description: String? = null)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class NetworkProfile(var MagicDNSName: String? = null, var DomainName: String? = null)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Location(
|
|
||||||
var Country: String? = null,
|
|
||||||
var CountryCode: String? = null,
|
|
||||||
var City: String? = null,
|
|
||||||
var CityCode: String? = null,
|
|
||||||
var Priority: Int? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class DNSConfig(
|
|
||||||
var Resolvers: List<DnsType.Resolver>? = null,
|
|
||||||
var Routes: Map<String, List<DnsType.Resolver>?>? = null,
|
|
||||||
var FallbackResolvers: List<DnsType.Resolver>? = null,
|
|
||||||
var Domains: List<String>? = null,
|
|
||||||
var Nameservers: List<Addr>? = null
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,36 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
@ -1,140 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.notifier
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import androidx.core.app.ActivityCompat
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import com.tailscale.ipn.App
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
import com.tailscale.ipn.UninitializedApp.Companion.notificationManager
|
|
||||||
import com.tailscale.ipn.ui.model.Health
|
|
||||||
import com.tailscale.ipn.ui.model.Health.UnhealthyState
|
|
||||||
import com.tailscale.ipn.ui.util.set
|
|
||||||
import com.tailscale.ipn.util.TSLog
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.FlowPreview
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.debounce
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@OptIn(FlowPreview::class)
|
|
||||||
class HealthNotifier(
|
|
||||||
healthStateFlow: StateFlow<Health.State?>,
|
|
||||||
scope: CoroutineScope,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
const val HEALTH_CHANNEL_ID = "tailscale-health"
|
|
||||||
}
|
|
||||||
|
|
||||||
private val TAG = "Health"
|
|
||||||
private val ignoredWarnableCodes: Set<String> =
|
|
||||||
setOf(
|
|
||||||
// Ignored on Android because installing unstable takes quite some effort
|
|
||||||
"is-using-unstable-version",
|
|
||||||
|
|
||||||
// Ignored on Android because we already have a dedicated connected/not connected
|
|
||||||
// notification
|
|
||||||
"wantrunning-false")
|
|
||||||
|
|
||||||
init {
|
|
||||||
scope.launch {
|
|
||||||
healthStateFlow
|
|
||||||
.distinctUntilChanged { old, new -> old?.Warnings?.count() == new?.Warnings?.count() }
|
|
||||||
.debounce(5000)
|
|
||||||
.collect { health ->
|
|
||||||
TSLog.d(TAG, "Health updated: ${health?.Warnings?.keys?.sorted()}")
|
|
||||||
health?.Warnings?.let {
|
|
||||||
notifyHealthUpdated(it.values.mapNotNull { it }.toTypedArray())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val currentWarnings: StateFlow<Set<UnhealthyState>> = MutableStateFlow(setOf())
|
|
||||||
val currentIcon: StateFlow<Int?> = MutableStateFlow(null)
|
|
||||||
|
|
||||||
private fun notifyHealthUpdated(warnings: Array<UnhealthyState>) {
|
|
||||||
val warningsBeforeAdd = currentWarnings.value
|
|
||||||
val currentWarnableCodes = warnings.map { it.WarnableCode }.toSet()
|
|
||||||
val addedWarnings: MutableSet<UnhealthyState> = mutableSetOf()
|
|
||||||
val isWarmingUp = warnings.any { it.WarnableCode == "warming-up" }
|
|
||||||
|
|
||||||
for (warning in warnings) {
|
|
||||||
if (ignoredWarnableCodes.contains(warning.WarnableCode)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
addedWarnings.add(warning)
|
|
||||||
|
|
||||||
if (this.currentWarnings.value.contains(warning)) {
|
|
||||||
// Already notified, skip
|
|
||||||
continue
|
|
||||||
} else if (warning.hiddenByDependencies(currentWarnableCodes)) {
|
|
||||||
// Ignore this warning because a dependency is also unhealthy
|
|
||||||
TSLog.d(TAG, "Ignoring ${warning.WarnableCode} because of dependency")
|
|
||||||
continue
|
|
||||||
} else if (!isWarmingUp) {
|
|
||||||
TSLog.d(TAG, "Adding health warning: ${warning.WarnableCode}")
|
|
||||||
this.currentWarnings.set(this.currentWarnings.value + warning)
|
|
||||||
if (warning.Severity == Health.Severity.high) {
|
|
||||||
this.sendNotification(warning.Title, warning.Text, warning.WarnableCode)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
TSLog.d(TAG, "Ignoring ${warning.WarnableCode} because warming up")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val warningsToDrop = warningsBeforeAdd.minus(addedWarnings)
|
|
||||||
if (warningsToDrop.isNotEmpty()) {
|
|
||||||
TSLog.d(TAG, "Dropping health warnings with codes $warningsToDrop")
|
|
||||||
this.removeNotifications(warningsToDrop)
|
|
||||||
}
|
|
||||||
currentWarnings.set(this.currentWarnings.value.subtract(warningsToDrop))
|
|
||||||
this.updateIcon()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateIcon() {
|
|
||||||
if (currentWarnings.value.isEmpty()) {
|
|
||||||
this.currentIcon.set(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (currentWarnings.value.any {
|
|
||||||
(it.Severity == Health.Severity.high || it.ImpactsConnectivity == true)
|
|
||||||
}) {
|
|
||||||
this.currentIcon.set(R.drawable.warning_rounded)
|
|
||||||
} else {
|
|
||||||
this.currentIcon.set(R.drawable.info)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendNotification(title: String, text: String, code: String) {
|
|
||||||
TSLog.d(TAG, "Sending notification for $code")
|
|
||||||
val notification =
|
|
||||||
NotificationCompat.Builder(App.get().applicationContext, HEALTH_CHANNEL_ID)
|
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setContentText(text)
|
|
||||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
|
||||||
.build()
|
|
||||||
if (ActivityCompat.checkSelfPermission(
|
|
||||||
App.get().applicationContext, Manifest.permission.POST_NOTIFICATIONS) !=
|
|
||||||
PackageManager.PERMISSION_GRANTED) {
|
|
||||||
TSLog.d(TAG, "Notification permission not granted")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
notificationManager.notify(code.hashCode(), notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun removeNotifications(warnings: Set<UnhealthyState>) {
|
|
||||||
TSLog.d(TAG, "Removing notifications for $warnings")
|
|
||||||
for (warning in warnings) {
|
|
||||||
notificationManager.cancel(warning.WarnableCode.hashCode())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,116 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.notifier
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.tailscale.ipn.App
|
|
||||||
import com.tailscale.ipn.ui.model.Empty
|
|
||||||
import com.tailscale.ipn.ui.model.Health
|
|
||||||
import com.tailscale.ipn.ui.model.Ipn
|
|
||||||
import com.tailscale.ipn.ui.model.Ipn.Notify
|
|
||||||
import com.tailscale.ipn.ui.model.Netmap
|
|
||||||
import com.tailscale.ipn.ui.util.set
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.decodeFromStream
|
|
||||||
import com.tailscale.ipn.util.TSLog
|
|
||||||
|
|
||||||
// Notifier is a wrapper around the IPN Bus notifier. It provides a way to watch
|
|
||||||
// for changes in various parts of the Tailscale engine. You will typically only use
|
|
||||||
// a single Notifier per instance of your application which lasts for the lifetime of
|
|
||||||
// the process.
|
|
||||||
//
|
|
||||||
// The primary entry point here is watchIPNBus which will start a watcher on the IPN bus
|
|
||||||
// and return you the session Id. When you are done with your watcher, you must call
|
|
||||||
// unwatchIPNBus with the sessionId.
|
|
||||||
object Notifier {
|
|
||||||
private val TAG = Notifier::class.simpleName
|
|
||||||
private val decoder = Json { ignoreUnknownKeys = true }
|
|
||||||
|
|
||||||
// General IPN Bus State
|
|
||||||
private val _state = MutableStateFlow(Ipn.State.NoState)
|
|
||||||
val state: StateFlow<Ipn.State> = _state
|
|
||||||
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
|
|
||||||
val prefs: StateFlow<Ipn.Prefs?> = MutableStateFlow(null)
|
|
||||||
val engineStatus: StateFlow<Ipn.EngineStatus?> = MutableStateFlow(null)
|
|
||||||
val tailFSShares: StateFlow<Map<String, String>?> = MutableStateFlow(null)
|
|
||||||
val browseToURL: StateFlow<String?> = MutableStateFlow(null)
|
|
||||||
val loginFinished: StateFlow<String?> = MutableStateFlow(null)
|
|
||||||
val version: StateFlow<String?> = MutableStateFlow(null)
|
|
||||||
val health: StateFlow<Health.State?> = MutableStateFlow(null)
|
|
||||||
|
|
||||||
// Taildrop-specific State
|
|
||||||
val outgoingFiles: StateFlow<List<Ipn.OutgoingFile>?> = MutableStateFlow(null)
|
|
||||||
val incomingFiles: StateFlow<List<Ipn.PartialFile>?> = MutableStateFlow(null)
|
|
||||||
val filesWaiting: StateFlow<Empty.Message?> = MutableStateFlow(null)
|
|
||||||
|
|
||||||
private lateinit var app: libtailscale.Application
|
|
||||||
private var manager: libtailscale.NotificationManager? = null
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
fun setApp(newApp: libtailscale.Application) {
|
|
||||||
app = newApp
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
|
||||||
fun start(scope: CoroutineScope) {
|
|
||||||
TSLog.d(TAG, "Starting Notifier")
|
|
||||||
if (!::app.isInitialized) {
|
|
||||||
App.get()
|
|
||||||
}
|
|
||||||
scope.launch(Dispatchers.IO) {
|
|
||||||
val mask =
|
|
||||||
NotifyWatchOpt.Netmap.value or
|
|
||||||
NotifyWatchOpt.Prefs.value or
|
|
||||||
NotifyWatchOpt.InitialState.value or
|
|
||||||
NotifyWatchOpt.InitialHealthState.value
|
|
||||||
manager =
|
|
||||||
app.watchNotifications(mask.toLong()) { notification ->
|
|
||||||
val notify = decoder.decodeFromStream<Notify>(notification.inputStream())
|
|
||||||
notify.State?.let { state.set(Ipn.State.fromInt(it)) }
|
|
||||||
notify.NetMap?.let(netmap::set)
|
|
||||||
notify.Prefs?.let(prefs::set)
|
|
||||||
notify.Engine?.let(engineStatus::set)
|
|
||||||
notify.TailFSShares?.let(tailFSShares::set)
|
|
||||||
notify.BrowseToURL?.let(browseToURL::set)
|
|
||||||
notify.LoginFinished?.let { loginFinished.set(it.property) }
|
|
||||||
notify.Version?.let(version::set)
|
|
||||||
notify.OutgoingFiles?.let(outgoingFiles::set)
|
|
||||||
notify.FilesWaiting?.let(filesWaiting::set)
|
|
||||||
notify.IncomingFiles?.let(incomingFiles::set)
|
|
||||||
notify.Health?.let(health::set)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
TSLog.d(TAG, "Stopping Notifier")
|
|
||||||
manager?.let {
|
|
||||||
it.stop()
|
|
||||||
manager = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which
|
|
||||||
// what we want to see on the Notify bus
|
|
||||||
private enum class NotifyWatchOpt(val value: Int) {
|
|
||||||
EngineUpdates(1),
|
|
||||||
InitialState(2),
|
|
||||||
Prefs(4),
|
|
||||||
Netmap(8),
|
|
||||||
NoPrivateKey(16),
|
|
||||||
InitialTailFSShares(32),
|
|
||||||
InitialOutgoingFiles(64),
|
|
||||||
InitialHealthState(128),
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setState(newState: Ipn.State) {
|
|
||||||
_state.value = newState
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,9 +0,0 @@
|
|||||||
// 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)
|
|
@ -1,467 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.theme
|
|
||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.material3.ButtonColors
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.ColorScheme
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.ListItemColors
|
|
||||||
import androidx.compose.material3.ListItemDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
|
||||||
import androidx.compose.material3.TextFieldColors
|
|
||||||
import androidx.compose.material3.TopAppBarColors
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.material3.Typography
|
|
||||||
import androidx.compose.material3.darkColorScheme
|
|
||||||
import androidx.compose.material3.lightColorScheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.unit.TextUnit
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
|
|
||||||
val colors =
|
|
||||||
if (useDarkTheme) {
|
|
||||||
DarkColors
|
|
||||||
} else {
|
|
||||||
LightColors
|
|
||||||
}
|
|
||||||
|
|
||||||
val typography =
|
|
||||||
Typography(
|
|
||||||
// titleMedium is styled to be slightly larger than bodyMedium for emphasis
|
|
||||||
titleMedium =
|
|
||||||
MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp, lineHeight = 26.sp),
|
|
||||||
// bodyMedium is styled to use same line height as titleMedium to ensure even vertical
|
|
||||||
// margins in list items.
|
|
||||||
bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp))
|
|
||||||
|
|
||||||
// TODO: Migrate to Activity.enableEdgeToEdge
|
|
||||||
@Suppress("deprecation") val systemUiController = rememberSystemUiController()
|
|
||||||
|
|
||||||
DisposableEffect(systemUiController, useDarkTheme) {
|
|
||||||
systemUiController.setStatusBarColor(color = colors.surfaceContainer)
|
|
||||||
systemUiController.setNavigationBarColor(color = Color.Black)
|
|
||||||
onDispose {}
|
|
||||||
}
|
|
||||||
|
|
||||||
MaterialTheme(colorScheme = colors, typography = typography, content = content)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val LightColors =
|
|
||||||
lightColorScheme(
|
|
||||||
primary = Color(0xFF4B70CC), // blue-500
|
|
||||||
onPrimary = Color(0xFFFFFFFF), // white
|
|
||||||
primaryContainer = Color(0xFFF0F5FF), // blue-0
|
|
||||||
onPrimaryContainer = Color(0xFF3E5DB3), // blue-600
|
|
||||||
error = Color(0xFFB22C30), // red-500
|
|
||||||
onError = Color(0xFFFFFFFF), // white
|
|
||||||
errorContainer = Color(0xFFFEF6F3), // red-0
|
|
||||||
onErrorContainer = Color(0xFF930921), // red-600
|
|
||||||
surfaceDim = Color(0xFFF7F5F4), // gray-100
|
|
||||||
surface = Color(0xFFFFFFFF), // white,
|
|
||||||
background = Color(0xFFF7F5F4), // gray-100
|
|
||||||
surfaceBright = Color(0xFFFFFFFF), // white
|
|
||||||
surfaceContainerLowest = Color(0xFFFFFFFF), // white
|
|
||||||
surfaceContainerLow = Color(0xFFF7F5F4), // gray-100
|
|
||||||
surfaceContainer = Color(0xFFF7F5F4), // gray-100
|
|
||||||
surfaceContainerHigh = Color(0xFFF7F5F4), // gray-100
|
|
||||||
surfaceContainerHighest = Color(0xFFF7F5F4), // gray-100
|
|
||||||
surfaceVariant = Color(0xFFF7F5F4), // gray-100,
|
|
||||||
onSurface = Color(0xFF232222), // gray-800
|
|
||||||
onSurfaceVariant = Color(0xFF706E6D), // gray-500
|
|
||||||
outline = Color(0xFF706E6D), // gray-500
|
|
||||||
outlineVariant = Color(0xFFEDEBEA), // gray-200
|
|
||||||
inverseSurface = Color(0xFF232222), // gray-800
|
|
||||||
inverseOnSurface = Color(0xFFFFFFFF), // white
|
|
||||||
scrim = Color(0xAA000000), // black
|
|
||||||
)
|
|
||||||
|
|
||||||
private val DarkColors =
|
|
||||||
darkColorScheme(
|
|
||||||
primary = Color(0xFF3E5DB3), // blue-600
|
|
||||||
onPrimary = Color(0xFFFFFFFF), // white
|
|
||||||
primaryContainer = Color(0xFFf0f5ff), // blue-0
|
|
||||||
onPrimaryContainer = Color(0xFF5A82DC), // blue-400
|
|
||||||
error = Color(0xFFEF5350), // red-400
|
|
||||||
onError = Color(0xFFFFFFFF), // white
|
|
||||||
errorContainer = Color(0xFFfff6f4), // red-0
|
|
||||||
onErrorContainer = Color(0xFF940822), // red-600
|
|
||||||
surfaceDim = Color(0xFF1f1e1e), // gray-900
|
|
||||||
surface = Color(0xFF232222), // gray-800
|
|
||||||
background = Color(0xFF181717), // gray-1000
|
|
||||||
surfaceBright = Color(0xFF444342), // gray-600
|
|
||||||
surfaceContainerLowest = Color(0xFF1f1e1e), // gray-900
|
|
||||||
surfaceContainerLow = Color(0xFF232222), // gray-800
|
|
||||||
surfaceContainer = Color(0xFF181717), // gray-1000
|
|
||||||
surfaceContainerHigh = Color(0xFF232222), // gray-800
|
|
||||||
surfaceContainerHighest = Color(0xFF2e2d2d), // gray-700
|
|
||||||
surfaceVariant = Color(0xFF1f1e1e), // gray-900
|
|
||||||
onSurface = Color(0xFFfaf9f8), // gray-0
|
|
||||||
onSurfaceVariant = Color(0xFFafacab), // gray-400
|
|
||||||
outline = Color(0xFF706E6D), // gray-500
|
|
||||||
outlineVariant = Color(0xFF2E2D2D), // gray-700
|
|
||||||
inverseSurface = Color(0xFFEDEBEA), // gray-200
|
|
||||||
inverseOnSurface = Color(0xFF000000), // black
|
|
||||||
scrim = Color(0xAA000000), // black
|
|
||||||
)
|
|
||||||
|
|
||||||
val ColorScheme.warning: Color
|
|
||||||
@Composable
|
|
||||||
get() =
|
|
||||||
if (isSystemInDarkTheme()) {
|
|
||||||
Color(0xFFBB5504) // yellow-400
|
|
||||||
} else {
|
|
||||||
Color(0xFFD97917) // yellow-300
|
|
||||||
}
|
|
||||||
|
|
||||||
val ColorScheme.onWarning: Color
|
|
||||||
get() = Color(0xFFFFFFFF) // white
|
|
||||||
|
|
||||||
val ColorScheme.warningContainer: Color
|
|
||||||
get() = Color(0xFFFFFAEE) // orange-0
|
|
||||||
|
|
||||||
val ColorScheme.onWarningContainer: Color
|
|
||||||
get() = Color(0xFF7E1E22) // orange-600
|
|
||||||
|
|
||||||
val ColorScheme.success: Color
|
|
||||||
get() = Color(0xFF0A825D) // green-400
|
|
||||||
|
|
||||||
val ColorScheme.onSuccess: Color
|
|
||||||
get() = Color(0xFFFFFFFF) // white
|
|
||||||
|
|
||||||
val ColorScheme.successContainer: Color
|
|
||||||
get() = Color(0xFFEFFEEC) // green-0
|
|
||||||
|
|
||||||
val ColorScheme.onSuccessContainer: Color
|
|
||||||
get() = Color(0xFF0E4B3B) // green-600
|
|
||||||
|
|
||||||
val ColorScheme.on: Color
|
|
||||||
get() = Color(0xFF1CA672) // green-300
|
|
||||||
|
|
||||||
val ColorScheme.off: Color
|
|
||||||
@Composable
|
|
||||||
get() =
|
|
||||||
if (isSystemInDarkTheme()) {
|
|
||||||
Color(0xFF444342) // gray-600
|
|
||||||
} else {
|
|
||||||
Color(0xFFD9D6D5) // gray-300
|
|
||||||
}
|
|
||||||
|
|
||||||
val ColorScheme.link: Color
|
|
||||||
get() = onPrimaryContainer
|
|
||||||
|
|
||||||
val ColorScheme.customError: Color
|
|
||||||
@Composable
|
|
||||||
get() =
|
|
||||||
if (isSystemInDarkTheme()) {
|
|
||||||
Color(0xFF940821) // red-600
|
|
||||||
} else {
|
|
||||||
Color(0xFFB22D30) // red-500
|
|
||||||
}
|
|
||||||
|
|
||||||
val ColorScheme.customErrorContainer: Color
|
|
||||||
@Composable
|
|
||||||
get() =
|
|
||||||
if (isSystemInDarkTheme()) {
|
|
||||||
Color(0xFF760012) // red-700
|
|
||||||
} else {
|
|
||||||
Color(0xFF940821) // red-600
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main color scheme for list items, uses onPrimaryContainer color for leading and trailing icons.
|
|
||||||
*/
|
|
||||||
val ColorScheme.listItem: ListItemColors
|
|
||||||
@Composable
|
|
||||||
get() {
|
|
||||||
val default = ListItemDefaults.colors()
|
|
||||||
return ListItemColors(
|
|
||||||
containerColor = default.containerColor,
|
|
||||||
headlineColor = default.headlineColor,
|
|
||||||
leadingIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
overlineColor = default.overlineColor,
|
|
||||||
supportingTextColor = default.supportingTextColor,
|
|
||||||
trailingIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
disabledHeadlineColor = default.disabledHeadlineColor,
|
|
||||||
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
|
||||||
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Like listItem, but with the overline content using the onSurface color. */
|
|
||||||
val ColorScheme.titledListItem: ListItemColors
|
|
||||||
@Composable
|
|
||||||
get() {
|
|
||||||
val default = listItem
|
|
||||||
return ListItemColors(
|
|
||||||
containerColor = default.containerColor,
|
|
||||||
headlineColor = default.headlineColor,
|
|
||||||
leadingIconColor = default.leadingIconColor,
|
|
||||||
overlineColor = MaterialTheme.colorScheme.onSurface,
|
|
||||||
supportingTextColor = default.supportingTextColor,
|
|
||||||
trailingIconColor = default.trailingIconColor,
|
|
||||||
disabledHeadlineColor = default.disabledHeadlineColor,
|
|
||||||
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
|
||||||
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Color scheme for disabled list items. */
|
|
||||||
val ColorScheme.disabledListItem: ListItemColors
|
|
||||||
@Composable
|
|
||||||
get() {
|
|
||||||
val default = ListItemDefaults.colors()
|
|
||||||
return ListItemColors(
|
|
||||||
containerColor = default.containerColor,
|
|
||||||
headlineColor = MaterialTheme.colorScheme.disabled,
|
|
||||||
leadingIconColor = default.leadingIconColor,
|
|
||||||
overlineColor = default.overlineColor,
|
|
||||||
supportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
trailingIconColor = default.trailingIconColor,
|
|
||||||
disabledHeadlineColor = default.disabledHeadlineColor,
|
|
||||||
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
|
||||||
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Color scheme for list items that should be styled as a surface container. */
|
|
||||||
val ColorScheme.surfaceContainerListItem: ListItemColors
|
|
||||||
@Composable
|
|
||||||
get() {
|
|
||||||
val default = ListItemDefaults.colors()
|
|
||||||
return ListItemColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
headlineColor = MaterialTheme.colorScheme.onSurface,
|
|
||||||
leadingIconColor = MaterialTheme.colorScheme.onSurface,
|
|
||||||
overlineColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
supportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
trailingIconColor = MaterialTheme.colorScheme.onSurface,
|
|
||||||
disabledHeadlineColor = default.disabledHeadlineColor,
|
|
||||||
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
|
||||||
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Color scheme for list items that should be styled as a primary item. */
|
|
||||||
val ColorScheme.primaryListItem: ListItemColors
|
|
||||||
@Composable
|
|
||||||
get() {
|
|
||||||
val default = ListItemDefaults.colors()
|
|
||||||
return ListItemColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
|
||||||
headlineColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
|
|
||||||
supportingTextColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
disabledHeadlineColor = default.disabledHeadlineColor,
|
|
||||||
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
|
||||||
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Color scheme for list items that should be styled as a warning item. */
|
|
||||||
val ColorScheme.warningListItem: ListItemColors
|
|
||||||
@Composable
|
|
||||||
get() {
|
|
||||||
val default = ListItemDefaults.colors()
|
|
||||||
return ListItemColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.warning,
|
|
||||||
headlineColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f),
|
|
||||||
supportingTextColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
disabledHeadlineColor = default.disabledHeadlineColor,
|
|
||||||
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
|
||||||
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Color scheme for list items that should be styled as an error item. */
|
|
||||||
val ColorScheme.errorListItem: ListItemColors
|
|
||||||
@Composable
|
|
||||||
get() {
|
|
||||||
val default = ListItemDefaults.colors()
|
|
||||||
return ListItemColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.customError,
|
|
||||||
headlineColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f),
|
|
||||||
supportingTextColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
disabledHeadlineColor = default.disabledHeadlineColor,
|
|
||||||
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
|
||||||
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Main color scheme for top app bar, styles it as a surface container. */
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
val ColorScheme.topAppBar: TopAppBarColors
|
|
||||||
@Composable
|
|
||||||
get() =
|
|
||||||
TopAppBarDefaults.topAppBarColors()
|
|
||||||
.copy(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
|
||||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
|
|
||||||
val ColorScheme.secondaryButton: ButtonColors
|
|
||||||
@Composable
|
|
||||||
get() {
|
|
||||||
val defaults = ButtonDefaults.buttonColors()
|
|
||||||
if (isSystemInDarkTheme()) {
|
|
||||||
return ButtonColors(
|
|
||||||
containerColor = Color(0xFF4B70CC), // blue-500
|
|
||||||
contentColor = Color(0xFFFFFFFF), // white
|
|
||||||
disabledContainerColor = defaults.disabledContainerColor,
|
|
||||||
disabledContentColor = defaults.disabledContentColor)
|
|
||||||
} else {
|
|
||||||
return ButtonColors(
|
|
||||||
containerColor = Color(0xFF5A82DC), // blue-400
|
|
||||||
contentColor = Color(0xFFFFFFFF), // white
|
|
||||||
disabledContainerColor = defaults.disabledContainerColor,
|
|
||||||
disabledContentColor = defaults.disabledContentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val ColorScheme.errorButton: ButtonColors
|
|
||||||
@Composable
|
|
||||||
get() {
|
|
||||||
val defaults = ButtonDefaults.buttonColors()
|
|
||||||
if (isSystemInDarkTheme()) {
|
|
||||||
return ButtonColors(
|
|
||||||
containerColor = Color(0xFFB22D30), // red-500
|
|
||||||
contentColor = Color(0xFFFFFFFF), // white
|
|
||||||
disabledContainerColor = defaults.disabledContainerColor,
|
|
||||||
disabledContentColor = defaults.disabledContentColor)
|
|
||||||
} else {
|
|
||||||
return ButtonColors(
|
|
||||||
containerColor = Color(0xFFD04841), // red-400
|
|
||||||
contentColor = Color(0xFFFFFFFF), // white
|
|
||||||
disabledContainerColor = defaults.disabledContainerColor,
|
|
||||||
disabledContentColor = defaults.disabledContentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val ColorScheme.warningButton: ButtonColors
|
|
||||||
@Composable
|
|
||||||
get() {
|
|
||||||
val defaults = ButtonDefaults.buttonColors()
|
|
||||||
if (isSystemInDarkTheme()) {
|
|
||||||
return ButtonColors(
|
|
||||||
containerColor = Color(0xFFD97917), // yellow-300
|
|
||||||
contentColor = Color(0xFFFFFFFF), // white
|
|
||||||
disabledContainerColor = defaults.disabledContainerColor,
|
|
||||||
disabledContentColor = defaults.disabledContentColor)
|
|
||||||
} else {
|
|
||||||
return ButtonColors(
|
|
||||||
containerColor = Color(0xFFE5993E), // yellow-200
|
|
||||||
contentColor = Color(0xFFFFFFFF), // white
|
|
||||||
disabledContainerColor = defaults.disabledContainerColor,
|
|
||||||
disabledContentColor = defaults.disabledContentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val ColorScheme.defaultTextColor: Color
|
|
||||||
@Composable
|
|
||||||
get() =
|
|
||||||
if (isSystemInDarkTheme()) {
|
|
||||||
Color.White
|
|
||||||
} else {
|
|
||||||
Color.Black
|
|
||||||
}
|
|
||||||
|
|
||||||
val ColorScheme.logoBackground: Color
|
|
||||||
@Composable
|
|
||||||
get() =
|
|
||||||
if (isSystemInDarkTheme()) {
|
|
||||||
Color(0xFFFFFFFF) // white
|
|
||||||
} else {
|
|
||||||
Color(0xFF1F1E1E)
|
|
||||||
}
|
|
||||||
|
|
||||||
val ColorScheme.standaloneLogoDotEnabled: Color
|
|
||||||
@Composable
|
|
||||||
get() =
|
|
||||||
if (isSystemInDarkTheme()) {
|
|
||||||
Color(0xFFFFFFFF)
|
|
||||||
} else {
|
|
||||||
Color(0xFF000000)
|
|
||||||
}
|
|
||||||
|
|
||||||
val ColorScheme.standaloneLogoDotDisabled: Color
|
|
||||||
@Composable
|
|
||||||
get() =
|
|
||||||
if (isSystemInDarkTheme()) {
|
|
||||||
Color(0x66FFFFFF)
|
|
||||||
} else {
|
|
||||||
Color(0x661F1E1E)
|
|
||||||
}
|
|
||||||
|
|
||||||
val ColorScheme.onBackgroundLogoDotEnabled: Color
|
|
||||||
@Composable
|
|
||||||
get() =
|
|
||||||
if (isSystemInDarkTheme()) {
|
|
||||||
Color(0xFF141414)
|
|
||||||
} else {
|
|
||||||
Color(0xFFFFFFFF)
|
|
||||||
}
|
|
||||||
|
|
||||||
val ColorScheme.onBackgroundLogoDotDisabled: Color
|
|
||||||
@Composable
|
|
||||||
get() =
|
|
||||||
if (isSystemInDarkTheme()) {
|
|
||||||
Color(0x66141414)
|
|
||||||
} else {
|
|
||||||
Color(0x66FFFFFF)
|
|
||||||
}
|
|
||||||
|
|
||||||
val ColorScheme.exitNodeToggleButton: ButtonColors
|
|
||||||
@Composable
|
|
||||||
get() {
|
|
||||||
val defaults = ButtonDefaults.buttonColors()
|
|
||||||
return if (isSystemInDarkTheme()) {
|
|
||||||
ButtonColors(
|
|
||||||
containerColor = Color(0xFF444342), // grey-600
|
|
||||||
contentColor = Color(0xFFFFFFFF), // white
|
|
||||||
disabledContainerColor = defaults.disabledContainerColor,
|
|
||||||
disabledContentColor = defaults.disabledContentColor)
|
|
||||||
} else {
|
|
||||||
ButtonColors(
|
|
||||||
containerColor = Color(0xFFEDEBEA), // grey-300
|
|
||||||
contentColor = Color(0xFF000000), // black
|
|
||||||
disabledContainerColor = defaults.disabledContainerColor,
|
|
||||||
disabledContentColor = defaults.disabledContentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val ColorScheme.disabled: Color
|
|
||||||
get() = Color(0xFFAFACAB) // gray-400
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
val ColorScheme.searchBarColors: TextFieldColors
|
|
||||||
@Composable
|
|
||||||
get() {
|
|
||||||
return OutlinedTextFieldDefaults.colors(
|
|
||||||
focusedLeadingIconColor = MaterialTheme.colorScheme.onSurface,
|
|
||||||
unfocusedLeadingIconColor = MaterialTheme.colorScheme.onSurface,
|
|
||||||
focusedTextColor = MaterialTheme.colorScheme.onSurface,
|
|
||||||
unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
disabledTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
focusedBorderColor = Color.Transparent,
|
|
||||||
unfocusedBorderColor = Color.Transparent)
|
|
||||||
}
|
|
||||||
|
|
||||||
val TextStyle.short: TextStyle
|
|
||||||
get() = copy(lineHeight = 20.sp)
|
|
||||||
|
|
||||||
val Typography.minTextSize: TextUnit
|
|
||||||
get() = 10.sp
|
|
@ -1,24 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.util
|
|
||||||
|
|
||||||
import com.tailscale.ipn.ui.model.Ipn
|
|
||||||
|
|
||||||
class AdvertisedRoutesHelper {
|
|
||||||
companion object {
|
|
||||||
fun exitNodeOnFromPrefs(prefs: Ipn.Prefs): Boolean {
|
|
||||||
var v4 = false
|
|
||||||
var v6 = false
|
|
||||||
prefs.AdvertiseRoutes?.forEach {
|
|
||||||
if (it == "0.0.0.0/0") {
|
|
||||||
v4 = true
|
|
||||||
}
|
|
||||||
if (it == "::/0") {
|
|
||||||
v6 = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return v4 && v6
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,30 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.util
|
|
||||||
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.tailscale.ipn.UninitializedApp
|
|
||||||
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
|
|
||||||
|
|
||||||
object AndroidTVUtil {
|
|
||||||
fun isAndroidTV(): Boolean {
|
|
||||||
val pm = UninitializedApp.get().packageManager
|
|
||||||
return (pm.hasSystemFeature(@Suppress("deprecation") PackageManager.FEATURE_TELEVISION) ||
|
|
||||||
pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Applies a letterbox effect iff we're running on Android TV to reduce the overall width
|
|
||||||
// of the UI.
|
|
||||||
fun Modifier.universalFit(): Modifier {
|
|
||||||
return when (isAndroidTV()) {
|
|
||||||
true -> this.padding(horizontal = 150.dp, vertical = 10.dp).clip(RoundedCornerShape(10.dp))
|
|
||||||
false -> this
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,20 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.util
|
|
||||||
|
|
||||||
import com.tailscale.ipn.BuildConfig
|
|
||||||
|
|
||||||
class AppVersion {
|
|
||||||
companion object {
|
|
||||||
// Returns the short version of the build version, which is what users typically expect.
|
|
||||||
// For instance, if the build version is "1.75.80-t8fdffb8da-g2daeee584df",
|
|
||||||
// this function returns "1.75.80".
|
|
||||||
fun Short(): String {
|
|
||||||
// Split the full version string by hyphen (-)
|
|
||||||
val parts = BuildConfig.VERSION_NAME.split("-")
|
|
||||||
// Return only the part before the first hyphen
|
|
||||||
return parts[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,78 +0,0 @@
|
|||||||
// 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) }
|
|
||||||
})
|
|
||||||
}
|
|
@ -1,61 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.util
|
|
||||||
|
|
||||||
import androidx.compose.foundation.LocalIndication
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.focusable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ClipboardValueView(value: String, title: String? = null, subtitle: String? = null) {
|
|
||||||
val isFocused = remember { mutableStateOf(false) }
|
|
||||||
val localClipboardManager = LocalClipboardManager.current
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
|
|
||||||
ListItem(
|
|
||||||
modifier = Modifier
|
|
||||||
.focusable(interactionSource = interactionSource)
|
|
||||||
.onFocusChanged { focusState -> isFocused.value = focusState.isFocused }
|
|
||||||
.clickable(
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
indication = LocalIndication.current
|
|
||||||
) { localClipboardManager.setText(AnnotatedString(value)) }
|
|
||||||
.background(
|
|
||||||
if (isFocused.value) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
|
||||||
else Color.Transparent
|
|
||||||
),
|
|
||||||
overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } },
|
|
||||||
headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) },
|
|
||||||
supportingContent = subtitle?.let {
|
|
||||||
{ Text(it, modifier = Modifier.padding(top = 8.dp), style = MaterialTheme.typography.bodyMedium) }
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
Icon(
|
|
||||||
painterResource(R.drawable.clipboard),
|
|
||||||
contentDescription = stringResource(R.string.copy_to_clipboard),
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,22 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
@ -1,53 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.util
|
|
||||||
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
import com.tailscale.ipn.ui.theme.on
|
|
||||||
|
|
||||||
sealed class ConnectionMode {
|
|
||||||
class NotConnected : ConnectionMode()
|
|
||||||
|
|
||||||
class Derp(val relayName: String) : ConnectionMode()
|
|
||||||
|
|
||||||
class Direct : ConnectionMode()
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun titleString(): String {
|
|
||||||
return when (this) {
|
|
||||||
is NotConnected -> stringResource(id = R.string.not_connected)
|
|
||||||
is Derp -> stringResource(R.string.relayed_connection, relayName)
|
|
||||||
is Direct -> stringResource(R.string.direct_connection)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun contentKey(): String {
|
|
||||||
return when (this) {
|
|
||||||
is NotConnected -> "NotConnected"
|
|
||||||
is Derp -> "Derp($relayName)"
|
|
||||||
is Direct -> "Direct"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun iconDrawable(): Int {
|
|
||||||
return when (this) {
|
|
||||||
is NotConnected -> R.drawable.xmark_circle
|
|
||||||
is Derp -> R.drawable.link_off
|
|
||||||
is Direct -> R.drawable.link
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun color(): Color {
|
|
||||||
return when (this) {
|
|
||||||
is NotConnected -> MaterialTheme.colorScheme.onPrimary
|
|
||||||
is Derp -> MaterialTheme.colorScheme.error
|
|
||||||
is Direct -> MaterialTheme.colorScheme.on
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,46 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
@ -1,33 +0,0 @@
|
|||||||
// 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))
|
|
||||||
}
|
|
@ -1,21 +0,0 @@
|
|||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,34 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.util
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.pm.ApplicationInfo
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
|
|
||||||
data class InstalledApp(val name: String, val packageName: String)
|
|
||||||
|
|
||||||
class InstalledAppsManager(
|
|
||||||
val packageManager: PackageManager,
|
|
||||||
) {
|
|
||||||
fun fetchInstalledApps(): List<InstalledApp> {
|
|
||||||
return packageManager
|
|
||||||
.getInstalledApplications(PackageManager.GET_META_DATA)
|
|
||||||
.filter(appIsIncluded)
|
|
||||||
.map {
|
|
||||||
InstalledApp(
|
|
||||||
name = it.loadLabel(packageManager).toString(),
|
|
||||||
packageName = it.packageName,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.sortedBy { it.name }
|
|
||||||
}
|
|
||||||
|
|
||||||
private val appIsIncluded: (ApplicationInfo) -> Boolean = { app ->
|
|
||||||
app.packageName != "com.tailscale.ipn" &&
|
|
||||||
// Only show apps that can access the Internet
|
|
||||||
packageManager.checkPermission(Manifest.permission.INTERNET, app.packageName) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,125 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.util
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.focusable
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.LazyItemScope
|
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
|
||||||
import androidx.compose.foundation.text.ClickableText
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
object Lists {
|
|
||||||
@Composable
|
|
||||||
fun SectionDivider(title: String? = null) {
|
|
||||||
Box(Modifier.size(0.dp, 16.dp))
|
|
||||||
title?.let { LargeTitle(title) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ItemDivider() {
|
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LargeTitle(
|
|
||||||
title: String,
|
|
||||||
bottomPadding: Dp = 0.dp,
|
|
||||||
style: TextStyle = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight: FontWeight? = null,
|
|
||||||
focusable: Boolean = false
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier.fillMaxWidth()
|
|
||||||
.background(color = MaterialTheme.colorScheme.surface, shape = RectangleShape)) {
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
modifier =
|
|
||||||
Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding)
|
|
||||||
.focusable(focusable),
|
|
||||||
style = style,
|
|
||||||
fontWeight = fontWeight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MutedHeader(text: String) {
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier.fillMaxWidth()
|
|
||||||
.background(color = MaterialTheme.colorScheme.surface, shape = RectangleShape)) {
|
|
||||||
Text(
|
|
||||||
modifier = Modifier.padding(start = 16.dp, top = 16.dp),
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun InfoItem(text: CharSequence, onClick: (() -> Unit)? = null) {
|
|
||||||
val style =
|
|
||||||
MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
ListItem(
|
|
||||||
headlineContent = {
|
|
||||||
Box(modifier = Modifier.padding(vertical = 4.dp)) {
|
|
||||||
onClick?.let {
|
|
||||||
ClickableText(text = text as AnnotatedString, style = style, onClick = { onClick() })
|
|
||||||
} ?: run { Text(text as String, style = style) }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MultilineDescription(headlineContent: @Composable () -> Unit) {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = {
|
|
||||||
Box(modifier = Modifier.padding(vertical = 8.dp)) { headlineContent() }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Similar to items() but includes a horizontal divider between items. */
|
|
||||||
|
|
||||||
/** Similar to items() but includes a horizontal divider between items. */
|
|
||||||
inline fun <T> LazyListScope.itemsWithDividers(
|
|
||||||
items: List<T>,
|
|
||||||
noinline key: ((item: T) -> Any)? = null,
|
|
||||||
forceLeading: Boolean = false,
|
|
||||||
crossinline contentType: (item: T) -> Any? = { _ -> null },
|
|
||||||
crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
|
|
||||||
) =
|
|
||||||
items(
|
|
||||||
count = items.size,
|
|
||||||
key = if (key != null) { index: Int -> key(items[index]) } else null,
|
|
||||||
contentType = { index -> contentType(items[index]) }) {
|
|
||||||
if (forceLeading && it == 0 || it > 0 && it < items.size) {
|
|
||||||
Lists.ItemDivider()
|
|
||||||
}
|
|
||||||
itemContent(items[it])
|
|
||||||
}
|
|
||||||
|
|
||||||
inline fun <T> LazyListScope.itemsWithDividers(
|
|
||||||
items: Array<T>,
|
|
||||||
noinline key: ((item: T) -> Any)? = null,
|
|
||||||
forceLeading: Boolean = false,
|
|
||||||
crossinline contentType: (item: T) -> Any? = { _ -> null },
|
|
||||||
crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
|
|
||||||
) = itemsWithDividers(items.toList(), key, forceLeading, contentType, itemContent)
|
|
@ -1,65 +0,0 @@
|
|||||||
// 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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,129 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.util
|
|
||||||
|
|
||||||
import androidx.compose.ui.util.fastAny
|
|
||||||
import com.tailscale.ipn.mdm.MDMSettings
|
|
||||||
import com.tailscale.ipn.ui.model.Netmap
|
|
||||||
import com.tailscale.ipn.ui.model.Tailcfg
|
|
||||||
import com.tailscale.ipn.ui.model.UserID
|
|
||||||
|
|
||||||
data class PeerSet(val user: Tailcfg.UserProfile?, val peers: List<Tailcfg.Node>)
|
|
||||||
|
|
||||||
class PeerCategorizer {
|
|
||||||
var peerSets: List<PeerSet> = emptyList()
|
|
||||||
var lastSearchResult: List<PeerSet> = emptyList()
|
|
||||||
var lastSearchTerm: String = ""
|
|
||||||
|
|
||||||
fun regenerateGroupedPeers(netmap: Netmap.NetworkMap) {
|
|
||||||
val peers: List<Tailcfg.Node> = netmap.Peers ?: return
|
|
||||||
val selfNode = netmap.SelfNode
|
|
||||||
var grouped = mutableMapOf<UserID, MutableList<Tailcfg.Node>>()
|
|
||||||
|
|
||||||
val mdm = MDMSettings.hiddenNetworkDevices.flow.value.value
|
|
||||||
val hideMyDevices = mdm?.contains("current-user") ?: false
|
|
||||||
val hideOtherDevices = mdm?.contains("other-users") ?: false
|
|
||||||
val hideTaggedDevices = mdm?.contains("tagged-devices") ?: false
|
|
||||||
|
|
||||||
val me = netmap.currentUserProfile()
|
|
||||||
|
|
||||||
for (peer in (peers + selfNode)) {
|
|
||||||
|
|
||||||
val userId = peer.User
|
|
||||||
val profile = netmap.userProfile(userId)
|
|
||||||
|
|
||||||
// Mullvad nodes should not be shown in the peer list
|
|
||||||
if (peer.isMullvadNode) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide devices based on MDM settings
|
|
||||||
if (hideMyDevices && userId == me?.ID) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hideOtherDevices && userId != me?.ID) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hideTaggedDevices && (profile?.isTaggedDevice() == true)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!grouped.containsKey(userId)) {
|
|
||||||
grouped[userId] = mutableListOf()
|
|
||||||
}
|
|
||||||
grouped[userId]?.add(peer)
|
|
||||||
}
|
|
||||||
|
|
||||||
peerSets =
|
|
||||||
grouped
|
|
||||||
.map { (userId, peers) ->
|
|
||||||
val profile = netmap.userProfile(userId)
|
|
||||||
PeerSet(
|
|
||||||
profile,
|
|
||||||
peers.sortedWith { a, b ->
|
|
||||||
when {
|
|
||||||
a.StableID == b.StableID -> 0
|
|
||||||
a.isSelfNode(netmap) -> -1
|
|
||||||
b.isSelfNode(netmap) -> 1
|
|
||||||
else ->
|
|
||||||
(a.ComputedName?.lowercase() ?: "").compareTo(
|
|
||||||
b.ComputedName?.lowercase() ?: "")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
.sortedBy {
|
|
||||||
if (it.user?.ID == me?.ID) {
|
|
||||||
""
|
|
||||||
} else {
|
|
||||||
it.user?.DisplayName?.lowercase() ?: "unknown user"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun groupedAndFilteredPeers(searchTerm: String = ""): List<PeerSet> {
|
|
||||||
if (searchTerm.isEmpty()) {
|
|
||||||
return peerSets
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchTerm == this.lastSearchTerm) {
|
|
||||||
return lastSearchResult
|
|
||||||
}
|
|
||||||
|
|
||||||
// We can optimize out typing... If the search term starts with the last search term, we can
|
|
||||||
// just search the last result
|
|
||||||
val setsToSearch =
|
|
||||||
if (this.lastSearchTerm.isNotEmpty() && searchTerm.startsWith(this.lastSearchTerm))
|
|
||||||
lastSearchResult
|
|
||||||
else peerSets
|
|
||||||
this.lastSearchTerm = searchTerm
|
|
||||||
|
|
||||||
val matchingSets =
|
|
||||||
setsToSearch
|
|
||||||
.map { peerSet ->
|
|
||||||
val user = peerSet.user
|
|
||||||
val peers = peerSet.peers
|
|
||||||
|
|
||||||
val userMatches = user?.DisplayName?.contains(searchTerm, ignoreCase = true) ?: false
|
|
||||||
if (userMatches) {
|
|
||||||
return@map peerSet
|
|
||||||
}
|
|
||||||
|
|
||||||
val matchingPeers =
|
|
||||||
peers.filter {
|
|
||||||
it.displayName.contains(searchTerm, ignoreCase = true) ||
|
|
||||||
(it.Addresses ?: emptyList()).fastAny { addr -> addr.contains(searchTerm) }
|
|
||||||
}
|
|
||||||
if (matchingPeers.isNotEmpty()) {
|
|
||||||
PeerSet(user, matchingPeers)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.filterNotNull()
|
|
||||||
lastSearchResult = matchingSets
|
|
||||||
return matchingSets
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,12 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
@ -1,124 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.util
|
|
||||||
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
import com.tailscale.ipn.util.TSLog
|
|
||||||
import java.time.Duration
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.util.Date
|
|
||||||
|
|
||||||
object TimeUtil {
|
|
||||||
val TAG = "TimeUtil"
|
|
||||||
|
|
||||||
fun keyExpiryFromGoTime(goTime: String?): ComposableStringFormatter {
|
|
||||||
|
|
||||||
val time = goTime ?: return ComposableStringFormatter(R.string.empty)
|
|
||||||
val expTime = epochMillisFromGoTime(time)
|
|
||||||
val now = Instant.now().toEpochMilli()
|
|
||||||
|
|
||||||
var diff = (expTime - now) / 1000
|
|
||||||
|
|
||||||
// Rather than use plurals here, we'll just use the singular form for everything and
|
|
||||||
// double the minimum. "in 70 minutes" instead of "in 1 hour". 121 minutes becomes
|
|
||||||
// 2 hours, as does 179 minutes... Close enough for what this is used for.
|
|
||||||
|
|
||||||
// Key is already expired (x minutes ago)
|
|
||||||
if (diff < 0) {
|
|
||||||
diff = -diff
|
|
||||||
return when (diff) {
|
|
||||||
in 0..60 -> ComposableStringFormatter(R.string.under_a_minute)
|
|
||||||
in 61..7200 ->
|
|
||||||
ComposableStringFormatter(R.string.ago_x_minutes, diff / 60) // 1 minute to 1 hour
|
|
||||||
in 7201..172800 ->
|
|
||||||
ComposableStringFormatter(R.string.ago_x_hours, diff / 3600) // 2 hours to 24 hours
|
|
||||||
in 172801..5184000 ->
|
|
||||||
ComposableStringFormatter(R.string.ago_x_days, diff / 86400) // 2 Days to 60 days
|
|
||||||
in 5184001..124416000 ->
|
|
||||||
ComposableStringFormatter(R.string.ago_x_months, diff / 2592000) // ~2 months to 2 years
|
|
||||||
else ->
|
|
||||||
ComposableStringFormatter(
|
|
||||||
R.string.ago_x_years,
|
|
||||||
diff.toDouble() / 31536000.0) // 2 years to n years (in decimal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Key is not expired (in x minutes)
|
|
||||||
return when (diff) {
|
|
||||||
in 0..60 -> ComposableStringFormatter(R.string.under_a_minute)
|
|
||||||
in 61..7200 ->
|
|
||||||
ComposableStringFormatter(R.string.in_x_minutes, diff / 60) // 1 minute to 1 hour
|
|
||||||
in 7201..172800 ->
|
|
||||||
ComposableStringFormatter(R.string.in_x_hours, diff / 3600) // 2 hours to 24 hours
|
|
||||||
in 172801..5184000 ->
|
|
||||||
ComposableStringFormatter(R.string.in_x_days, diff / 86400) // 2 Days to 60 days
|
|
||||||
in 5184001..124416000 ->
|
|
||||||
ComposableStringFormatter(R.string.in_x_months, diff / 2592000) // ~2 months to 2 years
|
|
||||||
else ->
|
|
||||||
ComposableStringFormatter(
|
|
||||||
R.string.in_x_years, diff.toDouble() / 31536000.0) // 2 years to n years (in decimal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun epochMillisFromGoTime(goTime: String): Long {
|
|
||||||
val ta = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(goTime)
|
|
||||||
val i = Instant.from(ta)
|
|
||||||
return i.toEpochMilli()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dateFromGoString(goTime: String): Date {
|
|
||||||
val ta = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(goTime)
|
|
||||||
val i = Instant.from(ta)
|
|
||||||
return Date.from(i)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns true if the given Go time string is in the past, or will occur within the given
|
|
||||||
// duration from now.
|
|
||||||
fun isWithinExpiryNotificationWindow(window: Duration, goTime: String): Boolean {
|
|
||||||
val expTime = epochMillisFromGoTime(goTime)
|
|
||||||
val now = Instant.now().toEpochMilli()
|
|
||||||
return (expTime - now) / 1000 < window.seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parses a Go duration string (e.g. "2h3.2m4s") and returns a Java Duration object.
|
|
||||||
// Returns null if the input string is not a valid Go duration or contains
|
|
||||||
// units other than y,w,d,h,m,s (ms and us are explicitly not supported).
|
|
||||||
fun duration(goDuration: String): Duration? {
|
|
||||||
if (goDuration.contains("ms") || goDuration.contains("us")) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
var duration = 0.0
|
|
||||||
var valStr = ""
|
|
||||||
for (c in goDuration) {
|
|
||||||
// Scan digits and decimal points
|
|
||||||
if (c.isDigit() || c == '.') {
|
|
||||||
valStr += c
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
val durationFragment = valStr.toDouble()
|
|
||||||
duration +=
|
|
||||||
when (c) {
|
|
||||||
'y' -> durationFragment * 31536000.0 // 365 days
|
|
||||||
'w' -> durationFragment * 604800.0
|
|
||||||
'd' -> durationFragment * 86400.0
|
|
||||||
'h' -> durationFragment * 3600.0
|
|
||||||
'm' -> durationFragment * 60.0
|
|
||||||
's' -> durationFragment
|
|
||||||
else -> {
|
|
||||||
TSLog.e(TAG, "Invalid duration string: $goDuration")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: NumberFormatException) {
|
|
||||||
TSLog.e(TAG, "Invalid duration string: $goDuration")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
valStr = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Duration.ofSeconds(duration.toLong())
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,104 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.view
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.tailscale.ipn.BuildConfig
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
import com.tailscale.ipn.ui.Links
|
|
||||||
import com.tailscale.ipn.ui.theme.logoBackground
|
|
||||||
import com.tailscale.ipn.ui.util.AppVersion
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AboutView(backToSettings: BackNavigation) {
|
|
||||||
val localClipboardManager = LocalClipboardManager.current
|
|
||||||
|
|
||||||
Scaffold(topBar = { Header(R.string.about_view_header, onBack = backToSettings) }) { innerPadding
|
|
||||||
->
|
|
||||||
Column(
|
|
||||||
verticalArrangement =
|
|
||||||
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
modifier =
|
|
||||||
Modifier.fillMaxWidth()
|
|
||||||
.fillMaxHeight()
|
|
||||||
.padding(innerPadding)
|
|
||||||
.verticalScroll(rememberScrollState())) {
|
|
||||||
TailscaleLogoView(
|
|
||||||
usesOnBackgroundColors = true,
|
|
||||||
modifier =
|
|
||||||
Modifier.width(100.dp)
|
|
||||||
.height(100.dp)
|
|
||||||
.clip(RoundedCornerShape(50))
|
|
||||||
.background(MaterialTheme.colorScheme.logoBackground)
|
|
||||||
.padding(25.dp))
|
|
||||||
|
|
||||||
Column(
|
|
||||||
verticalArrangement =
|
|
||||||
Arrangement.spacedBy(space = 2.dp, alignment = Alignment.CenterVertically),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.about_view_title),
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
fontSize = MaterialTheme.typography.titleLarge.fontSize)
|
|
||||||
Text(
|
|
||||||
modifier =
|
|
||||||
Modifier.clickable {
|
|
||||||
// When users tap on the version number, the extended version string
|
|
||||||
// (including commit hashes) is copied to the clipboard.
|
|
||||||
// This may be useful for debugging purposes...
|
|
||||||
localClipboardManager.setText(AnnotatedString(BuildConfig.VERSION_NAME))
|
|
||||||
},
|
|
||||||
// ... but we always display the short version in the UI to avoid user
|
|
||||||
// confusion.
|
|
||||||
text = "${stringResource(R.string.version)} ${AppVersion.Short()}",
|
|
||||||
fontWeight = MaterialTheme.typography.bodyMedium.fontWeight,
|
|
||||||
fontSize = MaterialTheme.typography.bodyMedium.fontSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
OpenURLButton(stringResource(R.string.acknowledgements), Links.LICENSES_URL)
|
|
||||||
OpenURLButton(stringResource(R.string.privacy_policy), Links.PRIVACY_POLICY_URL)
|
|
||||||
OpenURLButton(stringResource(R.string.terms_of_service), Links.TERMS_URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.about_view_footnotes),
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontSize = MaterialTheme.typography.labelMedium.fontSize,
|
|
||||||
textAlign = TextAlign.Center)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun AboutPreview() {
|
|
||||||
AboutView({})
|
|
||||||
}
|
|
@ -1,95 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.view
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.focusable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Person
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ripple
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import coil.annotation.ExperimentalCoilApi
|
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
import com.tailscale.ipn.ui.model.IpnLocal
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoilApi::class)
|
|
||||||
@Composable
|
|
||||||
fun Avatar(
|
|
||||||
profile: IpnLocal.LoginProfile?,
|
|
||||||
size: Int = 50,
|
|
||||||
action: (() -> Unit)? = null,
|
|
||||||
isFocusable: Boolean = false
|
|
||||||
) {
|
|
||||||
var isFocused = remember { mutableStateOf(false) }
|
|
||||||
val focusManager = LocalFocusManager.current
|
|
||||||
|
|
||||||
// Outer Box for the larger focusable and clickable area
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(4.dp)
|
|
||||||
.size((size * 1.5f).dp) // Focusable area is larger than the avatar
|
|
||||||
.clip(CircleShape) // Ensure both the focus and click area are circular
|
|
||||||
.background(
|
|
||||||
if (isFocused.value) MaterialTheme.colorScheme.surface
|
|
||||||
else Color.Transparent,
|
|
||||||
)
|
|
||||||
.onFocusChanged { focusState ->
|
|
||||||
isFocused.value = focusState.isFocused
|
|
||||||
}
|
|
||||||
.focusable() // Make this outer Box focusable (after onFocusChanged)
|
|
||||||
.clickable(
|
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
|
||||||
indication = ripple(bounded = true), // Apply ripple effect inside circular bounds
|
|
||||||
onClick = {
|
|
||||||
action?.invoke()
|
|
||||||
focusManager.clearFocus() // Clear focus after clicking the avatar
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
// Inner Box to hold the avatar content (Icon or AsyncImage)
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(size.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
) {
|
|
||||||
// Always display the default icon as a background layer
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Person,
|
|
||||||
contentDescription = stringResource(R.string.settings_title),
|
|
||||||
modifier =
|
|
||||||
Modifier.size((size * 0.8f).dp)
|
|
||||||
.clip(CircleShape) // Icon size slightly smaller than the Box
|
|
||||||
)
|
|
||||||
|
|
||||||
// Overlay the profile picture if available
|
|
||||||
profile?.UserProfile?.ProfilePicURL?.let { url ->
|
|
||||||
AsyncImage(
|
|
||||||
model = url,
|
|
||||||
modifier = Modifier.size(size.dp).clip(CircleShape),
|
|
||||||
contentDescription = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,94 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
@ -1,41 +0,0 @@
|
|||||||
// 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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,157 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.view
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
import com.tailscale.ipn.ui.theme.listItem
|
|
||||||
import com.tailscale.ipn.ui.util.set
|
|
||||||
import com.tailscale.ipn.ui.viewModel.LoginWithAuthKeyViewModel
|
|
||||||
import com.tailscale.ipn.ui.viewModel.LoginWithCustomControlURLViewModel
|
|
||||||
|
|
||||||
data class LoginViewStrings(
|
|
||||||
var title: String,
|
|
||||||
var explanation: String,
|
|
||||||
var inputTitle: String,
|
|
||||||
var placeholder: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LoginWithCustomControlURLView(
|
|
||||||
onNavigateHome: BackNavigation,
|
|
||||||
backToSettings: BackNavigation,
|
|
||||||
viewModel: LoginWithCustomControlURLViewModel = LoginWithCustomControlURLViewModel()
|
|
||||||
) {
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
Header(
|
|
||||||
R.string.add_account,
|
|
||||||
onBack = backToSettings,
|
|
||||||
)
|
|
||||||
}) { innerPadding ->
|
|
||||||
val error by viewModel.errorDialog.collectAsState()
|
|
||||||
val strings =
|
|
||||||
LoginViewStrings(
|
|
||||||
title = stringResource(id = R.string.custom_control_menu),
|
|
||||||
explanation = stringResource(id = R.string.custom_control_menu_desc),
|
|
||||||
inputTitle = stringResource(id = R.string.custom_control_url_title),
|
|
||||||
placeholder = stringResource(id = R.string.custom_control_placeholder),
|
|
||||||
)
|
|
||||||
|
|
||||||
error?.let { ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) }) }
|
|
||||||
|
|
||||||
LoginView(
|
|
||||||
innerPadding = innerPadding,
|
|
||||||
strings = strings,
|
|
||||||
onSubmitAction = { viewModel.setControlURL(it, onNavigateHome) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LoginWithAuthKeyView(
|
|
||||||
onNavigateHome: BackNavigation,
|
|
||||||
backToSettings: BackNavigation,
|
|
||||||
viewModel: LoginWithAuthKeyViewModel = LoginWithAuthKeyViewModel()
|
|
||||||
) {
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
Header(
|
|
||||||
R.string.add_account,
|
|
||||||
onBack = backToSettings,
|
|
||||||
)
|
|
||||||
}) { innerPadding ->
|
|
||||||
val error by viewModel.errorDialog.collectAsState()
|
|
||||||
val strings =
|
|
||||||
LoginViewStrings(
|
|
||||||
title = stringResource(id = R.string.auth_key_title),
|
|
||||||
explanation = stringResource(id = R.string.auth_key_explanation),
|
|
||||||
inputTitle = stringResource(id = R.string.auth_key_input_title),
|
|
||||||
placeholder = stringResource(id = R.string.auth_key_placeholder),
|
|
||||||
)
|
|
||||||
// Show the error overlay if need be
|
|
||||||
error?.let { ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) }) }
|
|
||||||
|
|
||||||
LoginView(
|
|
||||||
innerPadding = innerPadding,
|
|
||||||
strings = strings,
|
|
||||||
onSubmitAction = { viewModel.setAuthKey(it, onNavigateHome) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LoginView(
|
|
||||||
innerPadding: PaddingValues = PaddingValues(16.dp),
|
|
||||||
strings: LoginViewStrings,
|
|
||||||
onSubmitAction: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
|
|
||||||
var textVal by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier =
|
|
||||||
Modifier.padding(innerPadding)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(MaterialTheme.colorScheme.surface)) {
|
|
||||||
ListItem(
|
|
||||||
colors = MaterialTheme.colorScheme.listItem,
|
|
||||||
headlineContent = { Text(text = strings.title) },
|
|
||||||
supportingContent = { Text(text = strings.explanation) })
|
|
||||||
|
|
||||||
ListItem(
|
|
||||||
colors = MaterialTheme.colorScheme.listItem,
|
|
||||||
headlineContent = { Text(text = strings.inputTitle) },
|
|
||||||
supportingContent = {
|
|
||||||
OutlinedTextField(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors =
|
|
||||||
TextFieldDefaults.colors(
|
|
||||||
focusedContainerColor = Color.Transparent,
|
|
||||||
unfocusedContainerColor = Color.Transparent),
|
|
||||||
textStyle = MaterialTheme.typography.bodyMedium,
|
|
||||||
value = textVal,
|
|
||||||
onValueChange = { textVal = it },
|
|
||||||
placeholder = {
|
|
||||||
Text(strings.placeholder, style = MaterialTheme.typography.bodySmall)
|
|
||||||
},
|
|
||||||
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
ListItem(
|
|
||||||
colors = MaterialTheme.colorScheme.listItem,
|
|
||||||
headlineContent = {
|
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Button(
|
|
||||||
onClick = { onSubmitAction(textVal) },
|
|
||||||
content = { Text(stringResource(id = R.string.add_account_short)) })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,116 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.view
|
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
import com.tailscale.ipn.mdm.MDMSettings
|
|
||||||
import com.tailscale.ipn.ui.model.DnsType
|
|
||||||
import com.tailscale.ipn.ui.notifier.Notifier
|
|
||||||
import com.tailscale.ipn.ui.util.ClipboardValueView
|
|
||||||
import com.tailscale.ipn.ui.util.Lists
|
|
||||||
import com.tailscale.ipn.ui.util.LoadingIndicator
|
|
||||||
import com.tailscale.ipn.ui.util.itemsWithDividers
|
|
||||||
import com.tailscale.ipn.ui.util.set
|
|
||||||
import com.tailscale.ipn.ui.viewModel.DNSEnablementState
|
|
||||||
import com.tailscale.ipn.ui.viewModel.DNSSettingsViewModel
|
|
||||||
import com.tailscale.ipn.ui.viewModel.DNSSettingsViewModelFactory
|
|
||||||
|
|
||||||
data class ViewableRoute(val name: String, val resolvers: List<DnsType.Resolver>)
|
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
|
||||||
fun DNSSettingsView(
|
|
||||||
backToSettings: BackNavigation,
|
|
||||||
model: DNSSettingsViewModel = viewModel(factory = DNSSettingsViewModelFactory())
|
|
||||||
) {
|
|
||||||
val state: DNSEnablementState by model.enablementState.collectAsState()
|
|
||||||
val resolvers = model.dnsConfig.collectAsState().value?.Resolvers ?: emptyList()
|
|
||||||
val domains = model.dnsConfig.collectAsState().value?.Domains ?: emptyList()
|
|
||||||
val routes: List<ViewableRoute> =
|
|
||||||
model.dnsConfig.collectAsState().value?.Routes?.mapNotNull { entry ->
|
|
||||||
entry.value?.let { resolvers -> ViewableRoute(name = entry.key, resolvers) } ?: run { null }
|
|
||||||
} ?: emptyList()
|
|
||||||
val useCorpDNS = Notifier.prefs.collectAsState().value?.CorpDNS == true
|
|
||||||
val dnsSettingsMDMDisposition by MDMSettings.useTailscaleDNSSettings.flow.collectAsState()
|
|
||||||
|
|
||||||
Scaffold(topBar = { Header(R.string.dns_settings, onBack = backToSettings) }) { innerPadding ->
|
|
||||||
LoadingIndicator.Wrap {
|
|
||||||
LazyColumn(Modifier.padding(innerPadding)) {
|
|
||||||
item("state") {
|
|
||||||
ListItem(
|
|
||||||
leadingContent = {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(state.symbolDrawable),
|
|
||||||
contentDescription = null,
|
|
||||||
tint = state.tint(),
|
|
||||||
modifier = Modifier.size(36.dp))
|
|
||||||
},
|
|
||||||
headlineContent = {
|
|
||||||
Text(stringResource(state.title), style = MaterialTheme.typography.titleMedium)
|
|
||||||
},
|
|
||||||
supportingContent = { Text(stringResource(state.caption)) })
|
|
||||||
|
|
||||||
if (!dnsSettingsMDMDisposition.value.hiddenFromUser) {
|
|
||||||
Lists.ItemDivider()
|
|
||||||
Setting.Switch(
|
|
||||||
R.string.use_ts_dns,
|
|
||||||
isOn = useCorpDNS,
|
|
||||||
onToggle = {
|
|
||||||
LoadingIndicator.start()
|
|
||||||
model.toggleCorpDNS { LoadingIndicator.stop() }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolvers.isNotEmpty()) {
|
|
||||||
item("resolversHeader") { Lists.SectionDivider(stringResource(R.string.resolvers)) }
|
|
||||||
|
|
||||||
itemsWithDividers(resolvers) { resolver -> ClipboardValueView(resolver.Addr.orEmpty()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (domains.isNotEmpty()) {
|
|
||||||
item("domainsHeader") { Lists.SectionDivider(stringResource(R.string.search_domains)) }
|
|
||||||
|
|
||||||
itemsWithDividers(domains) { domain -> ClipboardValueView(domain) }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (routes.isNotEmpty()) {
|
|
||||||
routes.forEach { route ->
|
|
||||||
item { Lists.SectionDivider("Route: ${route.name}") }
|
|
||||||
|
|
||||||
itemsWithDividers(route.resolvers) { resolver ->
|
|
||||||
ClipboardValueView(resolver.Addr.orEmpty())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun DNSSettingsViewPreview() {
|
|
||||||
val vm = DNSSettingsViewModel()
|
|
||||||
vm.enablementState.set(DNSEnablementState.ENABLED)
|
|
||||||
DNSSettingsView(backToSettings = {}, vm)
|
|
||||||
}
|
|
@ -1,82 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
@ -1,225 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.view
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.Check
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
import com.tailscale.ipn.mdm.MDMSettings
|
|
||||||
import com.tailscale.ipn.mdm.ShowHide
|
|
||||||
import com.tailscale.ipn.ui.notifier.Notifier
|
|
||||||
import com.tailscale.ipn.ui.theme.disabledListItem
|
|
||||||
import com.tailscale.ipn.ui.theme.listItem
|
|
||||||
import com.tailscale.ipn.ui.util.Lists
|
|
||||||
import com.tailscale.ipn.ui.util.LoadingIndicator
|
|
||||||
import com.tailscale.ipn.ui.util.itemsWithDividers
|
|
||||||
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
|
|
||||||
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
|
|
||||||
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
|
|
||||||
import com.tailscale.ipn.ui.viewModel.selected
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ExitNodePicker(
|
|
||||||
nav: ExitNodePickerNav,
|
|
||||||
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
|
|
||||||
) {
|
|
||||||
LoadingIndicator.Wrap {
|
|
||||||
Scaffold(topBar = { Header(R.string.choose_exit_node, onBack = nav.onNavigateBackHome) }) {
|
|
||||||
innerPadding ->
|
|
||||||
val tailnetExitNodes by model.tailnetExitNodes.collectAsState()
|
|
||||||
val mullvadExitNodesByCountryCode by model.mullvadExitNodesByCountryCode.collectAsState()
|
|
||||||
val mullvadExitNodeCount by model.mullvadExitNodeCount.collectAsState()
|
|
||||||
val anyActive by model.anyActive.collectAsState()
|
|
||||||
val shouldShowMullvadInfo by model.shouldShowMullvadInfo.collectAsState()
|
|
||||||
val allowLANAccess = Notifier.prefs.collectAsState().value?.ExitNodeAllowLANAccess == true
|
|
||||||
val showRunAsExitNode by MDMSettings.runExitNode.flow.collectAsState()
|
|
||||||
val allowLanAccessMDMDisposition by MDMSettings.exitNodeAllowLANAccess.flow.collectAsState()
|
|
||||||
val managedByOrganization by model.managedByOrganization.collectAsState()
|
|
||||||
val forcedExitNodeId = MDMSettings.exitNodeID.flow.collectAsState().value.value
|
|
||||||
|
|
||||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
|
||||||
item(key = "header") {
|
|
||||||
if (forcedExitNodeId != null) {
|
|
||||||
Text(
|
|
||||||
text =
|
|
||||||
managedByOrganization.value?.let {
|
|
||||||
stringResource(R.string.exit_node_mdm_orgname, it)
|
|
||||||
} ?: stringResource(R.string.exit_node_mdm),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp))
|
|
||||||
} else {
|
|
||||||
ExitNodeItem(
|
|
||||||
model,
|
|
||||||
ExitNodePickerViewModel.ExitNode(
|
|
||||||
label = stringResource(R.string.none),
|
|
||||||
online = MutableStateFlow(true),
|
|
||||||
selected = !anyActive,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
if (showRunAsExitNode.value == ShowHide.Show) {
|
|
||||||
Lists.ItemDivider()
|
|
||||||
RunAsExitNodeItem(nav = nav, viewModel = model, anyActive)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "divider1") { Lists.SectionDivider() }
|
|
||||||
|
|
||||||
itemsWithDividers(tailnetExitNodes, key = { it.id!! }) { node -> ExitNodeItem(model, node) }
|
|
||||||
|
|
||||||
if (mullvadExitNodeCount > 0) {
|
|
||||||
item(key = "mullvad") {
|
|
||||||
Lists.SectionDivider()
|
|
||||||
MullvadItem(
|
|
||||||
nav, mullvadExitNodesByCountryCode.size, mullvadExitNodesByCountryCode.selected)
|
|
||||||
}
|
|
||||||
} else if (shouldShowMullvadInfo) {
|
|
||||||
item(key = "mullvad_info") {
|
|
||||||
Lists.SectionDivider()
|
|
||||||
MullvadInfoItem(nav)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!allowLanAccessMDMDisposition.value.hiddenFromUser) {
|
|
||||||
item(key = "allowLANAccess") {
|
|
||||||
Lists.SectionDivider()
|
|
||||||
|
|
||||||
Setting.Switch(R.string.allow_lan_access, isOn = allowLANAccess) {
|
|
||||||
LoadingIndicator.start()
|
|
||||||
model.toggleAllowLANAccess { LoadingIndicator.stop() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ExitNodeItem(
|
|
||||||
viewModel: ExitNodePickerViewModel,
|
|
||||||
node: ExitNodePickerViewModel.ExitNode,
|
|
||||||
) {
|
|
||||||
val online by node.online.collectAsState()
|
|
||||||
val isRunningExitNode = viewModel.isRunningExitNode.collectAsState().value
|
|
||||||
val forcedExitNodeId = MDMSettings.exitNodeID.flow.collectAsState().value.value
|
|
||||||
|
|
||||||
Box {
|
|
||||||
var modifier: Modifier = Modifier
|
|
||||||
if (online && !isRunningExitNode && forcedExitNodeId == null) {
|
|
||||||
modifier = modifier.clickable { viewModel.setExitNode(node) }
|
|
||||||
}
|
|
||||||
ListItem(
|
|
||||||
modifier = modifier,
|
|
||||||
colors =
|
|
||||||
if (online && !isRunningExitNode) MaterialTheme.colorScheme.listItem
|
|
||||||
else MaterialTheme.colorScheme.disabledListItem,
|
|
||||||
headlineContent = {
|
|
||||||
Text(node.city.ifEmpty { node.label }, style = MaterialTheme.typography.bodyMedium)
|
|
||||||
},
|
|
||||||
supportingContent = {
|
|
||||||
if (!online)
|
|
||||||
Text(stringResource(R.string.offline), style = MaterialTheme.typography.bodyMedium)
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
Row {
|
|
||||||
if (node.selected) {
|
|
||||||
Icon(Icons.Outlined.Check, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MullvadItem(nav: ExitNodePickerNav, count: Int, selected: Boolean) {
|
|
||||||
Box {
|
|
||||||
ListItem(
|
|
||||||
modifier = Modifier.clickable { nav.onNavigateToMullvad() },
|
|
||||||
headlineContent = {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.mullvad_exit_nodes),
|
|
||||||
style = MaterialTheme.typography.bodyMedium)
|
|
||||||
},
|
|
||||||
supportingContent = {
|
|
||||||
Text(
|
|
||||||
"$count ${stringResource(R.string.countries)}",
|
|
||||||
style = MaterialTheme.typography.bodyMedium)
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
if (selected) {
|
|
||||||
Icon(Icons.Outlined.Check, null)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MullvadInfoItem(nav: ExitNodePickerNav) {
|
|
||||||
Box {
|
|
||||||
ListItem(
|
|
||||||
modifier = Modifier.clickable { nav.onNavigateToMullvadInfo() },
|
|
||||||
headlineContent = {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.mullvad_exit_nodes),
|
|
||||||
style = MaterialTheme.typography.bodyMedium)
|
|
||||||
},
|
|
||||||
supportingContent = {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.enable_in_the_admin_console),
|
|
||||||
style = MaterialTheme.typography.bodyMedium)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RunAsExitNodeItem(
|
|
||||||
nav: ExitNodePickerNav,
|
|
||||||
viewModel: ExitNodePickerViewModel,
|
|
||||||
anyActive: Boolean
|
|
||||||
) {
|
|
||||||
val isRunningExitNode = viewModel.isRunningExitNode.collectAsState().value
|
|
||||||
|
|
||||||
Box {
|
|
||||||
var modifier: Modifier = Modifier
|
|
||||||
if (!anyActive) {
|
|
||||||
modifier = modifier.clickable { nav.onNavigateToRunAsExitNode() }
|
|
||||||
}
|
|
||||||
ListItem(
|
|
||||||
modifier = modifier,
|
|
||||||
colors =
|
|
||||||
if (!anyActive) MaterialTheme.colorScheme.listItem
|
|
||||||
else MaterialTheme.colorScheme.disabledListItem,
|
|
||||||
headlineContent = {
|
|
||||||
Text(
|
|
||||||
stringResource(id = R.string.run_as_exit_node),
|
|
||||||
style = MaterialTheme.typography.bodyMedium)
|
|
||||||
},
|
|
||||||
supportingContent = {
|
|
||||||
if (isRunningExitNode) {
|
|
||||||
Text(stringResource(R.string.enabled))
|
|
||||||
} else {
|
|
||||||
Text(stringResource(R.string.disabled))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,99 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.view
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
import com.tailscale.ipn.ui.model.Health
|
|
||||||
import com.tailscale.ipn.ui.theme.success
|
|
||||||
import com.tailscale.ipn.ui.viewModel.HealthViewModel
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun HealthView(backToSettings: BackNavigation, model: HealthViewModel = viewModel()) {
|
|
||||||
val warnings by model.warnings.collectAsState()
|
|
||||||
|
|
||||||
Scaffold(topBar = { Header(titleRes = R.string.health_warnings, onBack = backToSettings) }) {
|
|
||||||
innerPadding ->
|
|
||||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
|
||||||
if (warnings.isEmpty()) {
|
|
||||||
item("allGood") {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.Top),
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.check_circle),
|
|
||||||
modifier = Modifier.size(48.dp),
|
|
||||||
contentDescription = "A green checkmark",
|
|
||||||
tint = MaterialTheme.colorScheme.success)
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement =
|
|
||||||
Arrangement.spacedBy(2.dp, alignment = Alignment.CenterVertically),
|
|
||||||
modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.no_issues_found),
|
|
||||||
fontSize = MaterialTheme.typography.titleMedium.fontSize,
|
|
||||||
fontWeight = MaterialTheme.typography.titleMedium.fontWeight)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.tailscale_is_operating_normally),
|
|
||||||
color = MaterialTheme.colorScheme.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
items(warnings) { HealthWarningView(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun HealthWarningView(warning: Health.UnhealthyState) {
|
|
||||||
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainerLow)) {
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
|
|
||||||
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
|
|
||||||
.fillMaxWidth()) {
|
|
||||||
ListItem(
|
|
||||||
colors = warning.Severity.listItemColors(),
|
|
||||||
headlineContent = {
|
|
||||||
if (warning.Title.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
warning.Title,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
supportingContent = {
|
|
||||||
Text(warning.Text, style = MaterialTheme.typography.bodyMedium)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,68 +0,0 @@
|
|||||||
// 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({}) } }
|
|
||||||
}
|
|
@ -1,88 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.view
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
import com.tailscale.ipn.ui.theme.AppTheme
|
|
||||||
import com.tailscale.ipn.ui.util.set
|
|
||||||
import com.tailscale.ipn.ui.viewModel.LoginQRViewModel
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel()) {
|
|
||||||
Surface(color = MaterialTheme.colorScheme.scrim, modifier = Modifier.fillMaxSize()) {
|
|
||||||
Dialog(onDismissRequest = onDismiss) {
|
|
||||||
val image by model.qrCode.collectAsState()
|
|
||||||
val numCode by model.numCode.collectAsState()
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier =
|
|
||||||
Modifier.clip(RoundedCornerShape(10.dp))
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
|
||||||
.padding(20.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.scan_to_connect_to_your_tailnet),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface)
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier.size(200.dp)
|
|
||||||
.background(MaterialTheme.colorScheme.onSurface)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
contentAlignment = Alignment.Center) {
|
|
||||||
image?.let {
|
|
||||||
Image(
|
|
||||||
bitmap = it,
|
|
||||||
contentDescription = "Scan to login",
|
|
||||||
modifier = Modifier.fillMaxSize())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
numCode?.let { it ->
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.enter_code_to_connect_to_tailnet, it),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface)
|
|
||||||
}
|
|
||||||
Button(onClick = onDismiss) { Text(text = stringResource(R.string.dismiss)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
@Preview
|
|
||||||
fun LoginQRViewPreview() {
|
|
||||||
val vm = LoginQRViewModel()
|
|
||||||
vm.qrCode.set(vm.generateQRCode("https://tailscale.com", 200, 0))
|
|
||||||
vm.numCode.set("123456789")
|
|
||||||
AppTheme { LoginQRView({}, vm) }
|
|
||||||
}
|
|
@ -1,61 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.view
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
import com.tailscale.ipn.mdm.MDMSetting
|
|
||||||
import com.tailscale.ipn.mdm.MDMSettings
|
|
||||||
import com.tailscale.ipn.ui.util.itemsWithDividers
|
|
||||||
import com.tailscale.ipn.ui.viewModel.IpnViewModel
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun MDMSettingsDebugView(
|
|
||||||
backToSettings: BackNavigation,
|
|
||||||
@Suppress("UNUSED_PARAMETER") model: IpnViewModel = viewModel()
|
|
||||||
) {
|
|
||||||
Scaffold(topBar = { Header(R.string.current_mdm_settings, onBack = backToSettings) }) {
|
|
||||||
innerPadding ->
|
|
||||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
|
||||||
itemsWithDividers(MDMSettings.allSettings.sortedBy { "${it::class.java.name}|${it.key}" }) {
|
|
||||||
setting ->
|
|
||||||
MDMSettingView(setting)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MDMSettingView(setting: MDMSetting<*>) {
|
|
||||||
val value by setting.flow.collectAsState()
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text(setting.localizedTitle, maxLines = 3) },
|
|
||||||
supportingContent = {
|
|
||||||
Text(
|
|
||||||
setting.key,
|
|
||||||
fontSize = MaterialTheme.typography.labelSmall.fontSize,
|
|
||||||
fontFamily = FontFamily.Monospace)
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
Text(
|
|
||||||
if (value.isSet) value.value.toString() else "[not set]",
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
maxLines = 1,
|
|
||||||
fontWeight = FontWeight.SemiBold)
|
|
||||||
})
|
|
||||||
}
|
|
@ -1,813 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.view
|
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.combinedClickable
|
|
||||||
import androidx.compose.foundation.focusable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Clear
|
|
||||||
import androidx.compose.material.icons.filled.Search
|
|
||||||
import androidx.compose.material.icons.outlined.ArrowDropDown
|
|
||||||
import androidx.compose.material.icons.outlined.Lock
|
|
||||||
import androidx.compose.material.icons.outlined.Settings
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.ListItemDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.SearchBar
|
|
||||||
import androidx.compose.material3.SearchBarDefaults
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
|
||||||
import com.tailscale.ipn.App
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
import com.tailscale.ipn.mdm.MDMSettings
|
|
||||||
import com.tailscale.ipn.mdm.ShowHide
|
|
||||||
import com.tailscale.ipn.ui.model.Ipn
|
|
||||||
import com.tailscale.ipn.ui.model.IpnLocal
|
|
||||||
import com.tailscale.ipn.ui.model.Netmap
|
|
||||||
import com.tailscale.ipn.ui.model.Permissions
|
|
||||||
import com.tailscale.ipn.ui.model.Tailcfg
|
|
||||||
import com.tailscale.ipn.ui.theme.customErrorContainer
|
|
||||||
import com.tailscale.ipn.ui.theme.disabled
|
|
||||||
import com.tailscale.ipn.ui.theme.errorButton
|
|
||||||
import com.tailscale.ipn.ui.theme.errorListItem
|
|
||||||
import com.tailscale.ipn.ui.theme.exitNodeToggleButton
|
|
||||||
import com.tailscale.ipn.ui.theme.listItem
|
|
||||||
import com.tailscale.ipn.ui.theme.minTextSize
|
|
||||||
import com.tailscale.ipn.ui.theme.primaryListItem
|
|
||||||
import com.tailscale.ipn.ui.theme.secondaryButton
|
|
||||||
import com.tailscale.ipn.ui.theme.short
|
|
||||||
import com.tailscale.ipn.ui.theme.surfaceContainerListItem
|
|
||||||
import com.tailscale.ipn.ui.theme.warningButton
|
|
||||||
import com.tailscale.ipn.ui.theme.warningListItem
|
|
||||||
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
|
|
||||||
import com.tailscale.ipn.ui.util.AutoResizingText
|
|
||||||
import com.tailscale.ipn.ui.util.Lists
|
|
||||||
import com.tailscale.ipn.ui.util.LoadingIndicator
|
|
||||||
import com.tailscale.ipn.ui.util.PeerSet
|
|
||||||
import com.tailscale.ipn.ui.util.itemsWithDividers
|
|
||||||
import com.tailscale.ipn.ui.util.set
|
|
||||||
import com.tailscale.ipn.ui.viewModel.IpnViewModel.NodeState
|
|
||||||
import com.tailscale.ipn.ui.viewModel.MainViewModel
|
|
||||||
import com.tailscale.ipn.ui.viewModel.VpnViewModel
|
|
||||||
|
|
||||||
// Navigation actions for the MainView
|
|
||||||
data class MainViewNavigation(
|
|
||||||
val onNavigateToSettings: () -> Unit,
|
|
||||||
val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
|
|
||||||
val onNavigateToExitNodes: () -> Unit,
|
|
||||||
val onNavigateToHealth: () -> Unit
|
|
||||||
)
|
|
||||||
|
|
||||||
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun MainView(
|
|
||||||
loginAtUrl: (String) -> Unit,
|
|
||||||
navigation: MainViewNavigation,
|
|
||||||
viewModel: MainViewModel
|
|
||||||
) {
|
|
||||||
val currentPingDevice by viewModel.pingViewModel.peer.collectAsState()
|
|
||||||
val healthIcon by viewModel.healthIcon.collectAsState()
|
|
||||||
|
|
||||||
LoadingIndicator.Wrap {
|
|
||||||
Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(paddingInsets),
|
|
||||||
verticalArrangement = Arrangement.Center) {
|
|
||||||
// Assume VPN has been prepared for optimistic UI. Whether or not it has been prepared
|
|
||||||
// cannot be known
|
|
||||||
// until permission has been granted to prepare the VPN.
|
|
||||||
val isPrepared by viewModel.isVpnPrepared.collectAsState(initial = true)
|
|
||||||
val isOn by viewModel.vpnToggleState.collectAsState(initial = false)
|
|
||||||
val state by viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
|
|
||||||
val user by viewModel.loggedInUser.collectAsState(initial = null)
|
|
||||||
val stateVal by viewModel.stateRes.collectAsState(initial = R.string.placeholder)
|
|
||||||
val stateStr = stringResource(id = stateVal)
|
|
||||||
val netmap by viewModel.netmap.collectAsState(initial = null)
|
|
||||||
val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState()
|
|
||||||
val disableToggle by MDMSettings.forceEnabled.flow.collectAsState()
|
|
||||||
val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false)
|
|
||||||
|
|
||||||
// Hide the header only on Android TV when the user needs to login
|
|
||||||
val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin)
|
|
||||||
|
|
||||||
ListItem(
|
|
||||||
colors = MaterialTheme.colorScheme.surfaceContainerListItem,
|
|
||||||
leadingContent = {
|
|
||||||
if (!hideHeader) {
|
|
||||||
TintedSwitch(
|
|
||||||
onCheckedChange = {
|
|
||||||
if (!disableToggle.value) {
|
|
||||||
viewModel.toggleVpn()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = !disableToggle.value,
|
|
||||||
checked = isOn)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
headlineContent = {
|
|
||||||
user?.NetworkProfile?.DomainName?.let { domain ->
|
|
||||||
AutoResizingText(
|
|
||||||
text = domain,
|
|
||||||
style = MaterialTheme.typography.titleMedium.short,
|
|
||||||
minFontSize = MaterialTheme.typography.minTextSize,
|
|
||||||
overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
supportingContent = {
|
|
||||||
if (!hideHeader) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(text = stateStr, style = MaterialTheme.typography.bodyMedium.short)
|
|
||||||
healthIcon?.let {
|
|
||||||
Spacer(modifier = Modifier.size(4.dp))
|
|
||||||
IconButton(
|
|
||||||
onClick = { navigation.onNavigateToHealth() },
|
|
||||||
modifier = Modifier.size(16.dp)) {
|
|
||||||
Icon(
|
|
||||||
painterResource(id = it),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
Box(modifier = Modifier.padding(8.dp), contentAlignment = Alignment.CenterEnd) {
|
|
||||||
when (user) {
|
|
||||||
null -> SettingsButton { navigation.onNavigateToSettings() }
|
|
||||||
else -> {
|
|
||||||
Avatar(profile = user, size = 36, { navigation.onNavigateToSettings() }, isFocusable=true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
when (state) {
|
|
||||||
Ipn.State.Running -> {
|
|
||||||
|
|
||||||
PromptPermissionsIfNecessary()
|
|
||||||
|
|
||||||
viewModel.showVPNPermissionLauncherIfUnauthorized()
|
|
||||||
|
|
||||||
if (showKeyExpiry) {
|
|
||||||
ExpiryNotification(netmap = netmap, action = { viewModel.login() })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showExitNodePicker.value == ShowHide.Show) {
|
|
||||||
ExitNodeStatus(
|
|
||||||
navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
PeerList(
|
|
||||||
viewModel = viewModel,
|
|
||||||
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
|
|
||||||
onSearch = { viewModel.searchPeers(it) })
|
|
||||||
}
|
|
||||||
Ipn.State.NoState,
|
|
||||||
Ipn.State.Starting -> StartingView()
|
|
||||||
else -> {
|
|
||||||
ConnectView(
|
|
||||||
state,
|
|
||||||
isPrepared,
|
|
||||||
// If Tailscale is stopping, don't automatically restart; wait for user to take
|
|
||||||
// action (eg, if the user connected to another VPN).
|
|
||||||
state != Ipn.State.Stopping,
|
|
||||||
user,
|
|
||||||
{ viewModel.toggleVpn() },
|
|
||||||
{ viewModel.login() },
|
|
||||||
loginAtUrl,
|
|
||||||
netmap?.SelfNode,
|
|
||||||
{ viewModel.showVPNPermissionLauncherIfUnauthorized() })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPingDevice?.let { _ ->
|
|
||||||
ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) {
|
|
||||||
PingView(model = viewModel.pingViewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
|
|
||||||
val nodeState by viewModel.nodeState.collectAsState()
|
|
||||||
val maybePrefs by viewModel.prefs.collectAsState()
|
|
||||||
val netmap by viewModel.netmap.collectAsState()
|
|
||||||
|
|
||||||
// There's nothing to render if we haven't loaded the prefs yet
|
|
||||||
val prefs = maybePrefs ?: return
|
|
||||||
|
|
||||||
// The activeExitNode is the source of truth. The selectedExitNode is only relevant if we
|
|
||||||
// don't have an active node.
|
|
||||||
val chosenExitNodeId = prefs.activeExitNodeID ?: prefs.selectedExitNodeID
|
|
||||||
|
|
||||||
val exitNodePeer = chosenExitNodeId?.let { id -> netmap?.Peers?.find { it.StableID == id } }
|
|
||||||
val name = exitNodePeer?.exitNodeName
|
|
||||||
|
|
||||||
val managedByOrganization by viewModel.managedByOrganization.collectAsState()
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surfaceContainer)) {
|
|
||||||
if (nodeState == NodeState.OFFLINE_MDM) {
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier.padding(start = 16.dp, end = 16.dp, top = 56.dp, bottom = 16.dp)
|
|
||||||
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
|
|
||||||
.background(MaterialTheme.colorScheme.customErrorContainer)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.align(Alignment.TopCenter)) {
|
|
||||||
Column(
|
|
||||||
modifier =
|
|
||||||
Modifier.padding(start = 16.dp, end = 16.dp, top = 36.dp, bottom = 16.dp)) {
|
|
||||||
Text(
|
|
||||||
text =
|
|
||||||
managedByOrganization.value?.let {
|
|
||||||
stringResource(R.string.exit_node_offline_mdm_orgname, it)
|
|
||||||
} ?: stringResource(R.string.exit_node_offline_mdm),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = Color.White)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp)
|
|
||||||
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
|
|
||||||
.fillMaxWidth()) {
|
|
||||||
ListItem(
|
|
||||||
modifier = Modifier.clickable { navAction() },
|
|
||||||
colors =
|
|
||||||
when (nodeState) {
|
|
||||||
NodeState.ACTIVE_AND_RUNNING -> MaterialTheme.colorScheme.primaryListItem
|
|
||||||
NodeState.ACTIVE_NOT_RUNNING -> MaterialTheme.colorScheme.listItem
|
|
||||||
NodeState.RUNNING_AS_EXIT_NODE -> MaterialTheme.colorScheme.warningListItem
|
|
||||||
NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorListItem
|
|
||||||
NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorListItem
|
|
||||||
NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorListItem
|
|
||||||
else ->
|
|
||||||
ListItemDefaults.colors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface)
|
|
||||||
},
|
|
||||||
overlineContent = {
|
|
||||||
Text(
|
|
||||||
text =
|
|
||||||
if (nodeState == NodeState.OFFLINE_ENABLED ||
|
|
||||||
nodeState == NodeState.OFFLINE_DISABLED ||
|
|
||||||
nodeState == NodeState.OFFLINE_MDM)
|
|
||||||
stringResource(R.string.exit_node_offline)
|
|
||||||
else stringResource(R.string.exit_node),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
headlineContent = {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text =
|
|
||||||
when (nodeState) {
|
|
||||||
NodeState.NONE -> stringResource(id = R.string.none)
|
|
||||||
NodeState.RUNNING_AS_EXIT_NODE ->
|
|
||||||
stringResource(id = R.string.running_exit_node)
|
|
||||||
else -> name ?: ""
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis)
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.ArrowDropDown,
|
|
||||||
contentDescription = null,
|
|
||||||
tint =
|
|
||||||
if (nodeState == NodeState.NONE)
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
if (nodeState != NodeState.NONE) {
|
|
||||||
Button(
|
|
||||||
colors =
|
|
||||||
when (nodeState) {
|
|
||||||
NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorButton
|
|
||||||
NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorButton
|
|
||||||
NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorButton
|
|
||||||
NodeState.RUNNING_AS_EXIT_NODE ->
|
|
||||||
MaterialTheme.colorScheme.warningButton
|
|
||||||
NodeState.ACTIVE_NOT_RUNNING ->
|
|
||||||
MaterialTheme.colorScheme.exitNodeToggleButton
|
|
||||||
else -> MaterialTheme.colorScheme.secondaryButton
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
if (nodeState == NodeState.RUNNING_AS_EXIT_NODE)
|
|
||||||
viewModel.setRunningExitNode(false)
|
|
||||||
else viewModel.toggleExitNode()
|
|
||||||
}) {
|
|
||||||
Text(
|
|
||||||
when (nodeState) {
|
|
||||||
NodeState.OFFLINE_DISABLED -> stringResource(id = R.string.enable)
|
|
||||||
NodeState.ACTIVE_NOT_RUNNING ->
|
|
||||||
stringResource(id = R.string.enable)
|
|
||||||
NodeState.RUNNING_AS_EXIT_NODE ->
|
|
||||||
stringResource(id = R.string.stop)
|
|
||||||
else -> stringResource(id = R.string.disable)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SettingsButton(action: () -> Unit) {
|
|
||||||
IconButton(modifier = Modifier.size(24.dp), onClick = { action() }) {
|
|
||||||
Icon(
|
|
||||||
Icons.Outlined.Settings,
|
|
||||||
contentDescription = "Open settings",
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun StartingView() {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
TailscaleLogoView(
|
|
||||||
animated = true, usesOnBackgroundColors = false, Modifier.size(40.dp).alpha(0.3f))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ConnectView(
|
|
||||||
state: Ipn.State,
|
|
||||||
isPrepared: Boolean,
|
|
||||||
shouldStartAutomatically: Boolean,
|
|
||||||
user: IpnLocal.LoginProfile?,
|
|
||||||
connectAction: () -> Unit,
|
|
||||||
loginAction: () -> Unit,
|
|
||||||
loginAtUrlAction: (String) -> Unit,
|
|
||||||
selfNode: Tailcfg.Node?,
|
|
||||||
showVPNPermissionLauncherIfUnauthorized: () -> Unit
|
|
||||||
) {
|
|
||||||
LaunchedEffect(isPrepared) {
|
|
||||||
if (!isPrepared && shouldStartAutomatically) {
|
|
||||||
showVPNPermissionLauncherIfUnauthorized()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(8.dp).fillMaxWidth(0.7f).fillMaxHeight(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
if (!isPrepared) {
|
|
||||||
TailscaleLogoView(modifier = Modifier.size(50.dp))
|
|
||||||
Spacer(modifier = Modifier.size(1.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.welcome_to_tailscale),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
textAlign = TextAlign.Center)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.give_permissions),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
textAlign = TextAlign.Center)
|
|
||||||
Spacer(modifier = Modifier.size(1.dp))
|
|
||||||
PrimaryActionButton(onClick = connectAction) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.connect),
|
|
||||||
fontSize = MaterialTheme.typography.titleMedium.fontSize)
|
|
||||||
}
|
|
||||||
} else if (state == Ipn.State.NeedsMachineAuth) {
|
|
||||||
Icon(
|
|
||||||
modifier = Modifier.size(40.dp),
|
|
||||||
imageVector = Icons.Outlined.Lock,
|
|
||||||
contentDescription = "Device requires authentication")
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.machine_auth_required),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
textAlign = TextAlign.Center)
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.machine_auth_explainer),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
textAlign = TextAlign.Center)
|
|
||||||
Spacer(modifier = Modifier.size(1.dp))
|
|
||||||
selfNode?.let {
|
|
||||||
PrimaryActionButton(onClick = { loginAtUrlAction(it.nodeAdminUrl) }) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.open_admin_console),
|
|
||||||
fontSize = MaterialTheme.typography.titleMedium.fontSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (state != Ipn.State.NeedsLogin && user != null && !user.isEmpty()) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.power),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(40.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.disabled)
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.not_connected),
|
|
||||||
fontSize = MaterialTheme.typography.titleMedium.fontSize,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontFamily = MaterialTheme.typography.titleMedium.fontFamily)
|
|
||||||
val tailnetName = user.NetworkProfile?.DomainName ?: ""
|
|
||||||
Text(
|
|
||||||
buildAnnotatedString {
|
|
||||||
append(stringResource(id = R.string.connect_to_tailnet_prefix))
|
|
||||||
pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
|
||||||
append(tailnetName)
|
|
||||||
pop()
|
|
||||||
append(stringResource(id = R.string.connect_to_tailnet_suffix))
|
|
||||||
},
|
|
||||||
fontSize = MaterialTheme.typography.titleMedium.fontSize,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(1.dp))
|
|
||||||
PrimaryActionButton(onClick = connectAction) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.connect),
|
|
||||||
fontSize = MaterialTheme.typography.titleMedium.fontSize)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
TailscaleLogoView(modifier = Modifier.size(50.dp))
|
|
||||||
Spacer(modifier = Modifier.size(1.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.welcome_to_tailscale),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
textAlign = TextAlign.Center)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.login_to_join_your_tailnet),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
textAlign = TextAlign.Center)
|
|
||||||
Spacer(modifier = Modifier.size(1.dp))
|
|
||||||
PrimaryActionButton(onClick = loginAction) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.log_in),
|
|
||||||
fontSize = MaterialTheme.typography.titleMedium.fontSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
|
||||||
fun PeerList(
|
|
||||||
viewModel: MainViewModel,
|
|
||||||
onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
|
|
||||||
onSearch: (String) -> Unit
|
|
||||||
) {
|
|
||||||
val peerList by viewModel.peers.collectAsState(initial = emptyList<PeerSet>())
|
|
||||||
val searchTermStr by viewModel.searchTerm.collectAsState(initial = "")
|
|
||||||
val showNoResults =
|
|
||||||
remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value
|
|
||||||
|
|
||||||
val netmap = viewModel.netmap.collectAsState()
|
|
||||||
val focusManager = LocalFocusManager.current
|
|
||||||
var isFocussed by remember { mutableStateOf(false) }
|
|
||||||
var isListFocussed by remember { mutableStateOf(false) }
|
|
||||||
val expandedPeer = viewModel.expandedMenuPeer.collectAsState()
|
|
||||||
val localClipboardManager = LocalClipboardManager.current
|
|
||||||
val enableSearch = !isAndroidTV()
|
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
if (enableSearch) {
|
|
||||||
SearchWithDynamicSuggestions(viewModel, onSearch)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(if (showNoResults) 0.dp else 8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Peers display
|
|
||||||
LazyColumn(
|
|
||||||
modifier =
|
|
||||||
Modifier.fillMaxWidth()
|
|
||||||
.weight(1f) // LazyColumn gets the remaining vertical space
|
|
||||||
.onFocusChanged { isListFocussed = it.isFocused }
|
|
||||||
.background(color = MaterialTheme.colorScheme.surface)) {
|
|
||||||
|
|
||||||
// Handle case when no results are found
|
|
||||||
if (showNoResults) {
|
|
||||||
item {
|
|
||||||
Spacer(
|
|
||||||
Modifier.height(16.dp)
|
|
||||||
.fillMaxSize()
|
|
||||||
.focusable(false)
|
|
||||||
.background(color = MaterialTheme.colorScheme.surface))
|
|
||||||
Lists.LargeTitle(
|
|
||||||
stringResource(id = R.string.no_results),
|
|
||||||
bottomPadding = 8.dp,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
fontWeight = FontWeight.Light)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Iterate over peer sets to display them
|
|
||||||
var first = true
|
|
||||||
peerList.forEach { peerSet ->
|
|
||||||
if (!first) {
|
|
||||||
item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() }
|
|
||||||
}
|
|
||||||
first = false
|
|
||||||
|
|
||||||
if (isAndroidTV()) {
|
|
||||||
item { NodesSectionHeader(peerSet = peerSet) }
|
|
||||||
} else {
|
|
||||||
stickyHeader { NodesSectionHeader(peerSet = peerSet) }
|
|
||||||
}
|
|
||||||
|
|
||||||
itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer ->
|
|
||||||
ListItem(
|
|
||||||
modifier =
|
|
||||||
Modifier.combinedClickable(
|
|
||||||
onClick = { onNavigateToPeerDetails(peer) },
|
|
||||||
onLongClick = { viewModel.expandedMenuPeer.set(peer) }),
|
|
||||||
colors = MaterialTheme.colorScheme.listItem,
|
|
||||||
headlineContent = {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier.padding(top = 2.dp)
|
|
||||||
.size(10.dp)
|
|
||||||
.background(
|
|
||||||
color = peer.connectedColor(netmap.value),
|
|
||||||
shape = RoundedCornerShape(percent = 50))) {}
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
Text(text = peer.displayName, style = MaterialTheme.typography.titleMedium)
|
|
||||||
DropdownMenu(
|
|
||||||
expanded = expandedPeer.value?.StableID == peer.StableID,
|
|
||||||
onDismissRequest = { viewModel.hidePeerDropdownMenu() }) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.clipboard),
|
|
||||||
contentDescription = null)
|
|
||||||
},
|
|
||||||
text = { Text(text = stringResource(R.string.copy_ip_address)) },
|
|
||||||
onClick = {
|
|
||||||
viewModel.copyIpAddress(peer, localClipboardManager)
|
|
||||||
viewModel.hidePeerDropdownMenu()
|
|
||||||
})
|
|
||||||
netmap.value?.let { netMap ->
|
|
||||||
if (!peer.isSelfNode(netMap)) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.timer),
|
|
||||||
contentDescription = null)
|
|
||||||
},
|
|
||||||
text = { Text(text = stringResource(R.string.ping)) },
|
|
||||||
onClick = {
|
|
||||||
viewModel.hidePeerDropdownMenu()
|
|
||||||
viewModel.startPing(peer)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
supportingContent = {
|
|
||||||
Text(
|
|
||||||
text = peer.Addresses?.first()?.split("/")?.first() ?: "",
|
|
||||||
style =
|
|
||||||
MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
lineHeight = MaterialTheme.typography.titleMedium.lineHeight))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun NodesSectionHeader(peerSet: PeerSet) {
|
|
||||||
Spacer(Modifier.height(16.dp).fillMaxSize().background(color = MaterialTheme.colorScheme.surface))
|
|
||||||
|
|
||||||
Lists.LargeTitle(
|
|
||||||
peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user),
|
|
||||||
bottomPadding = 8.dp,
|
|
||||||
focusable = isAndroidTV(),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) {
|
|
||||||
if (netmap == null) return
|
|
||||||
|
|
||||||
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) {
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
|
|
||||||
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
|
|
||||||
.fillMaxWidth()) {
|
|
||||||
ListItem(
|
|
||||||
modifier = Modifier.clickable { action() },
|
|
||||||
colors = MaterialTheme.colorScheme.warningListItem,
|
|
||||||
headlineContent = {
|
|
||||||
Text(
|
|
||||||
netmap.SelfNode.expiryLabel(),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
supportingContent = {
|
|
||||||
Text(
|
|
||||||
stringResource(id = R.string.keyExpiryExplainer),
|
|
||||||
style = MaterialTheme.typography.bodyMedium)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalPermissionsApi::class)
|
|
||||||
@Composable
|
|
||||||
fun PromptPermissionsIfNecessary() {
|
|
||||||
Permissions.prompt.forEach { (permission, state) ->
|
|
||||||
ErrorDialog(
|
|
||||||
title = permission.title,
|
|
||||||
message = permission.description,
|
|
||||||
buttonText = R.string._continue) {
|
|
||||||
state.launchPermissionRequest()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun SearchWithDynamicSuggestions(viewModel: MainViewModel, onSearch: (String) -> Unit) {
|
|
||||||
val searchTerm by viewModel.searchTerm.collectAsState()
|
|
||||||
val filteredPeers by viewModel.peers.collectAsState()
|
|
||||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
|
||||||
val netmap by viewModel.netmap.collectAsState()
|
|
||||||
|
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
|
||||||
val focusManager = LocalFocusManager.current
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier =
|
|
||||||
Modifier.fillMaxWidth().focusRequester(focusRequester).clickable {
|
|
||||||
focusRequester.requestFocus()
|
|
||||||
keyboardController?.show()
|
|
||||||
}) {
|
|
||||||
SearchBar(
|
|
||||||
modifier = Modifier.fillMaxWidth().align(Alignment.CenterHorizontally),
|
|
||||||
inputField = {
|
|
||||||
SearchBarDefaults.InputField(
|
|
||||||
query = searchTerm,
|
|
||||||
onQueryChange = { query ->
|
|
||||||
viewModel.updateSearchTerm(query)
|
|
||||||
onSearch(query)
|
|
||||||
expanded = query.isNotEmpty()
|
|
||||||
},
|
|
||||||
onSearch = { query ->
|
|
||||||
viewModel.updateSearchTerm(query)
|
|
||||||
onSearch(query)
|
|
||||||
expanded = false
|
|
||||||
},
|
|
||||||
expanded = expanded,
|
|
||||||
onExpandedChange = { expanded = it },
|
|
||||||
placeholder = { Text("Search") },
|
|
||||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
|
||||||
trailingIcon = {
|
|
||||||
if (expanded) {
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
viewModel.updateSearchTerm("")
|
|
||||||
onSearch("")
|
|
||||||
expanded = false
|
|
||||||
focusManager.clearFocus()
|
|
||||||
keyboardController?.hide()
|
|
||||||
}) {
|
|
||||||
Icon(Icons.Default.Clear, contentDescription = "Clear search")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
expanded = expanded,
|
|
||||||
onExpandedChange = { expanded = it },
|
|
||||||
content = {
|
|
||||||
// Search results or suggestions
|
|
||||||
Column(Modifier.verticalScroll(rememberScrollState()).fillMaxSize()) {
|
|
||||||
filteredPeers.forEach { peerSet ->
|
|
||||||
val userName = peerSet.user?.DisplayName ?: "Unknown User"
|
|
||||||
peerSet.peers.forEach { peer ->
|
|
||||||
val deviceName = peer.displayName ?: "Unknown Device"
|
|
||||||
val ipAddress = peer.Addresses?.firstOrNull()?.split("/")?.first() ?: "No IP"
|
|
||||||
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text(userName) },
|
|
||||||
supportingContent = {
|
|
||||||
Column {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
val onlineColor = peer.connectedColor(netmap)
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier.size(10.dp)
|
|
||||||
.background(onlineColor, shape = RoundedCornerShape(50)))
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
Text(deviceName)
|
|
||||||
}
|
|
||||||
Text(ipAddress)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
|
||||||
modifier =
|
|
||||||
Modifier.clickable {
|
|
||||||
viewModel.updateSearchTerm(userName)
|
|
||||||
onSearch(userName)
|
|
||||||
expanded = false
|
|
||||||
focusManager.clearFocus()
|
|
||||||
keyboardController?.hide()
|
|
||||||
}
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun MainViewPreview() {
|
|
||||||
val vpnViewModel = VpnViewModel(App.get())
|
|
||||||
val vm = MainViewModel(vpnViewModel)
|
|
||||||
|
|
||||||
MainView(
|
|
||||||
{},
|
|
||||||
MainViewNavigation(
|
|
||||||
onNavigateToSettings = {},
|
|
||||||
onNavigateToPeerDetails = {},
|
|
||||||
onNavigateToExitNodes = {},
|
|
||||||
onNavigateToHealth = {}),
|
|
||||||
vm)
|
|
||||||
}
|
|
@ -1,58 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.view
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.safeContentPadding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
import com.tailscale.ipn.mdm.MDMSettings
|
|
||||||
import com.tailscale.ipn.ui.viewModel.IpnViewModel
|
|
||||||
|
|
||||||
@Suppress("UNUSED_PARAMETER")
|
|
||||||
@Composable
|
|
||||||
fun ManagedByView(backToSettings: BackNavigation, model: IpnViewModel = viewModel()) {
|
|
||||||
Scaffold(topBar = { Header(R.string.managed_by, onBack = backToSettings) }) { _ ->
|
|
||||||
Column(
|
|
||||||
verticalArrangement =
|
|
||||||
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
|
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
modifier =
|
|
||||||
Modifier.fillMaxWidth().safeContentPadding().verticalScroll(rememberScrollState())) {
|
|
||||||
val managedByOrganization =
|
|
||||||
MDMSettings.managedByOrganizationName.flow.collectAsState().value.value
|
|
||||||
val managedByCaption = MDMSettings.managedByCaption.flow.collectAsState().value.value
|
|
||||||
val managedByURL = MDMSettings.managedByURL.flow.collectAsState().value.value
|
|
||||||
managedByOrganization?.let {
|
|
||||||
Text(stringResource(R.string.managed_by_explainer_orgName, it))
|
|
||||||
} ?: run { Text(stringResource(R.string.managed_by_explainer)) }
|
|
||||||
managedByCaption?.let {
|
|
||||||
if (it.isNotEmpty()) {
|
|
||||||
Text(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
managedByURL?.let { OpenURLButton(stringResource(R.string.open_support), it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun ManagedByViewPreview() {
|
|
||||||
val vm = IpnViewModel()
|
|
||||||
ManagedByView(backToSettings = {}, vm)
|
|
||||||
}
|
|
@ -1,67 +0,0 @@
|
|||||||
// 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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,99 +0,0 @@
|
|||||||
// 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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,56 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.view
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MullvadInfoView(nav: ExitNodePickerNav) {
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
Header(R.string.choose_mullvad_exit_node, onBack = nav.onNavigateBackToExitNodes)
|
|
||||||
}) { innerPadding ->
|
|
||||||
LazyColumn(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 48.dp),
|
|
||||||
modifier = Modifier.padding(innerPadding)) {
|
|
||||||
item {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = R.drawable.mullvad_logo),
|
|
||||||
contentDescription = stringResource(R.string.the_mullvad_vpn_logo))
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.mullvad_info_title),
|
|
||||||
fontFamily = MaterialTheme.typography.titleLarge.fontFamily,
|
|
||||||
fontSize = MaterialTheme.typography.titleLarge.fontSize,
|
|
||||||
fontWeight = FontWeight.SemiBold)
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.mullvad_info_explainer),
|
|
||||||
color = MaterialTheme.colorScheme.secondary,
|
|
||||||
textAlign = TextAlign.Center)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,154 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.view
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.focusable
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
import com.tailscale.ipn.ui.theme.listItem
|
|
||||||
import com.tailscale.ipn.ui.theme.short
|
|
||||||
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
|
|
||||||
import com.tailscale.ipn.ui.util.Lists
|
|
||||||
import com.tailscale.ipn.ui.util.itemsWithDividers
|
|
||||||
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
|
|
||||||
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModelFactory
|
|
||||||
import com.tailscale.ipn.ui.viewModel.PingViewModel
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun PeerDetails(
|
|
||||||
backToHome: BackNavigation,
|
|
||||||
nodeId: String,
|
|
||||||
pingViewModel: PingViewModel,
|
|
||||||
model: PeerDetailsViewModel =
|
|
||||||
viewModel(
|
|
||||||
factory =
|
|
||||||
PeerDetailsViewModelFactory(nodeId, LocalContext.current.filesDir, pingViewModel))
|
|
||||||
) {
|
|
||||||
val isPinging by model.isPinging.collectAsState()
|
|
||||||
|
|
||||||
model.netmap.collectAsState().value?.let { netmap ->
|
|
||||||
model.node.collectAsState().value?.let { node ->
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
Header(
|
|
||||||
title = {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = node.displayName,
|
|
||||||
style = MaterialTheme.typography.titleMedium.short,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface)
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier.size(8.dp)
|
|
||||||
.background(
|
|
||||||
color = node.connectedColor(netmap),
|
|
||||||
shape = RoundedCornerShape(percent = 50))) {}
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = node.connectedStrRes(netmap)),
|
|
||||||
style = MaterialTheme.typography.bodyMedium.short,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
IconButton(onClick = { model.startPing() }) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.timer),
|
|
||||||
contentDescription = "Ping device")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onBack = backToHome)
|
|
||||||
},
|
|
||||||
) { innerPadding ->
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.padding(innerPadding),
|
|
||||||
) {
|
|
||||||
item(key = "tailscaleAddresses") {
|
|
||||||
Lists.MutedHeader(stringResource(R.string.tailscale_addresses))
|
|
||||||
}
|
|
||||||
|
|
||||||
itemsWithDividers(node.displayAddresses, key = { it.address }) {
|
|
||||||
AddressRow(address = it.address, type = it.typeString)
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "infoDivider") { Lists.SectionDivider() }
|
|
||||||
|
|
||||||
itemsWithDividers(node.info, key = { "info_${it.titleRes}" }) {
|
|
||||||
ValueRow(title = stringResource(id = it.titleRes), value = it.value.getString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isPinging) {
|
|
||||||
ModalBottomSheet(onDismissRequest = { model.onPingDismissal() }) {
|
|
||||||
PingView(model = model.pingViewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AddressRow(address: String, type: String) {
|
|
||||||
val localClipboardManager = LocalClipboardManager.current
|
|
||||||
|
|
||||||
// Android TV doesn't have a clipboard, nor any way to use the values, so visible only.
|
|
||||||
val modifier =
|
|
||||||
if (isAndroidTV()) {
|
|
||||||
Modifier.focusable(false)
|
|
||||||
} else {
|
|
||||||
Modifier.clickable { localClipboardManager.setText(AnnotatedString(address)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
ListItem(
|
|
||||||
modifier = modifier,
|
|
||||||
colors = MaterialTheme.colorScheme.listItem,
|
|
||||||
headlineContent = { Text(text = address) },
|
|
||||||
supportingContent = { Text(text = type) },
|
|
||||||
trailingContent = {
|
|
||||||
// TODO: there is some overlap with other uses of clipboard, DRY
|
|
||||||
if (!isAndroidTV()) {
|
|
||||||
Icon(painter = painterResource(id = R.drawable.clipboard), null)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ValueRow(title: String, value: String) {
|
|
||||||
ListItem(
|
|
||||||
colors = MaterialTheme.colorScheme.listItem,
|
|
||||||
headlineContent = { Text(text = title) },
|
|
||||||
supportingContent = { Text(text = value) })
|
|
||||||
}
|
|
@ -1,66 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
@ -1,55 +0,0 @@
|
|||||||
// 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)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,119 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.view
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
import com.tailscale.ipn.ui.util.LoadingIndicator
|
|
||||||
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
|
|
||||||
import com.tailscale.ipn.ui.viewModel.IpnViewModel
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RunExitNodeView(nav: ExitNodePickerNav, model: IpnViewModel = viewModel()) {
|
|
||||||
val isRunningExitNode by model.isRunningExitNode.collectAsState()
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = { Header(R.string.run_as_exit_node, onBack = nav.onNavigateBackToExitNodes) }) {
|
|
||||||
innerPadding ->
|
|
||||||
LoadingIndicator.Wrap {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement =
|
|
||||||
Arrangement.spacedBy(24.dp, alignment = Alignment.CenterVertically),
|
|
||||||
modifier =
|
|
||||||
Modifier.padding(innerPadding)
|
|
||||||
.padding(24.dp)
|
|
||||||
.fillMaxHeight()
|
|
||||||
.verticalScroll(rememberScrollState())) {
|
|
||||||
RunExitNodeGraphic()
|
|
||||||
|
|
||||||
if (isRunningExitNode) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.running_as_exit_node),
|
|
||||||
fontFamily = MaterialTheme.typography.titleLarge.fontFamily,
|
|
||||||
fontSize = MaterialTheme.typography.titleLarge.fontSize,
|
|
||||||
fontWeight = FontWeight.SemiBold)
|
|
||||||
Text(stringResource(R.string.run_exit_node_explainer_running))
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.run_this_device_as_an_exit_node),
|
|
||||||
fontFamily = MaterialTheme.typography.titleLarge.fontFamily,
|
|
||||||
fontSize = MaterialTheme.typography.titleLarge.fontSize,
|
|
||||||
fontWeight = FontWeight.SemiBold)
|
|
||||||
Text(stringResource(R.string.run_exit_node_explainer))
|
|
||||||
}
|
|
||||||
Text(stringResource(R.string.run_exit_node_caution))
|
|
||||||
|
|
||||||
Button(onClick = { model.setRunningExitNode(!isRunningExitNode) }) {
|
|
||||||
if (isRunningExitNode) {
|
|
||||||
Text(stringResource(R.string.stop_running_as_exit_node))
|
|
||||||
} else {
|
|
||||||
Text(stringResource(R.string.start_running_as_exit_node))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.size(24.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RunExitNodeGraphic() {
|
|
||||||
@Composable
|
|
||||||
fun ArrowForward() {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Outlined.ArrowForward,
|
|
||||||
"Arrow Forward",
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(vertical = 18.dp)) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.computer),
|
|
||||||
"Computer icon",
|
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier.size(36.dp))
|
|
||||||
ArrowForward()
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.android),
|
|
||||||
"Android icon",
|
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier.size(36.dp))
|
|
||||||
ArrowForward()
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.globe),
|
|
||||||
"Globe icon",
|
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier.size(36.dp))
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,214 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.view
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
|
||||||
import androidx.compose.ui.text.withStyle
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import com.tailscale.ipn.BuildConfig
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
import com.tailscale.ipn.mdm.MDMSettings
|
|
||||||
import com.tailscale.ipn.mdm.ShowHide
|
|
||||||
import com.tailscale.ipn.ui.Links
|
|
||||||
import com.tailscale.ipn.ui.theme.link
|
|
||||||
import com.tailscale.ipn.ui.theme.listItem
|
|
||||||
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
|
|
||||||
import com.tailscale.ipn.ui.util.Lists
|
|
||||||
import com.tailscale.ipn.ui.util.set
|
|
||||||
import com.tailscale.ipn.ui.viewModel.SettingsNav
|
|
||||||
import com.tailscale.ipn.ui.viewModel.SettingsViewModel
|
|
||||||
import com.tailscale.ipn.ui.viewModel.VpnViewModel
|
|
||||||
import com.tailscale.ipn.ui.notifier.Notifier
|
|
||||||
import com.tailscale.ipn.ui.util.AndroidTVUtil
|
|
||||||
import com.tailscale.ipn.ui.util.AppVersion
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewModel(), vpnViewModel: VpnViewModel = viewModel()) {
|
|
||||||
val handler = LocalUriHandler.current
|
|
||||||
|
|
||||||
val user by viewModel.loggedInUser.collectAsState()
|
|
||||||
val isAdmin by viewModel.isAdmin.collectAsState()
|
|
||||||
val managedByOrganization by viewModel.managedByOrganization.collectAsState()
|
|
||||||
val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState()
|
|
||||||
val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState()
|
|
||||||
val isVPNPrepared by vpnViewModel.vpnPrepared.collectAsState()
|
|
||||||
val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState()
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
Header(titleRes = R.string.settings_title, onBack = settingsNav.onNavigateBackHome)
|
|
||||||
}) { innerPadding ->
|
|
||||||
Column(modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) {
|
|
||||||
if (isVPNPrepared) {
|
|
||||||
UserView(
|
|
||||||
profile = user,
|
|
||||||
actionState = UserActionState.NAV,
|
|
||||||
onClick = settingsNav.onNavigateToUserSwitcher)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAdmin && !isAndroidTV()) {
|
|
||||||
Lists.ItemDivider()
|
|
||||||
AdminTextView { handler.openUri(Links.ADMIN_URL) }
|
|
||||||
}
|
|
||||||
|
|
||||||
Lists.SectionDivider()
|
|
||||||
Setting.Text(
|
|
||||||
R.string.dns_settings,
|
|
||||||
subtitle =
|
|
||||||
corpDNSEnabled?.let {
|
|
||||||
stringResource(
|
|
||||||
if (it) R.string.using_tailscale_dns else R.string.not_using_tailscale_dns)
|
|
||||||
},
|
|
||||||
onClick = settingsNav.onNavigateToDNSSettings)
|
|
||||||
|
|
||||||
Lists.ItemDivider()
|
|
||||||
Setting.Text(
|
|
||||||
R.string.split_tunneling,
|
|
||||||
subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale),
|
|
||||||
onClick = settingsNav.onNavigateToSplitTunneling)
|
|
||||||
|
|
||||||
if (showTailnetLock.value == ShowHide.Show) {
|
|
||||||
Lists.ItemDivider()
|
|
||||||
Setting.Text(
|
|
||||||
R.string.tailnet_lock,
|
|
||||||
subtitle =
|
|
||||||
tailnetLockEnabled?.let {
|
|
||||||
stringResource(if (it) R.string.enabled else R.string.disabled)
|
|
||||||
},
|
|
||||||
onClick = settingsNav.onNavigateToTailnetLock)
|
|
||||||
}
|
|
||||||
if (!AndroidTVUtil.isAndroidTV()){
|
|
||||||
Lists.ItemDivider()
|
|
||||||
Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions)
|
|
||||||
}
|
|
||||||
|
|
||||||
managedByOrganization.value?.let {
|
|
||||||
Lists.ItemDivider()
|
|
||||||
Setting.Text(
|
|
||||||
title = stringResource(R.string.managed_by_orgName, it),
|
|
||||||
onClick = settingsNav.onNavigateToManagedBy)
|
|
||||||
}
|
|
||||||
|
|
||||||
Lists.SectionDivider()
|
|
||||||
Setting.Text(R.string.bug_report, onClick = settingsNav.onNavigateToBugReport)
|
|
||||||
|
|
||||||
Lists.ItemDivider()
|
|
||||||
Setting.Text(
|
|
||||||
R.string.about_tailscale,
|
|
||||||
subtitle = "${stringResource(id = R.string.version)} ${AppVersion.Short()}",
|
|
||||||
onClick = settingsNav.onNavigateToAbout)
|
|
||||||
|
|
||||||
// TODO: put a heading for the debug section
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
Lists.SectionDivider()
|
|
||||||
Lists.MutedHeader(text = stringResource(R.string.internal_debug_options))
|
|
||||||
Setting.Text(R.string.mdm_settings, onClick = settingsNav.onNavigateToMDMSettings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
object Setting {
|
|
||||||
@Composable
|
|
||||||
fun Text(
|
|
||||||
titleRes: Int = 0,
|
|
||||||
title: String? = null,
|
|
||||||
subtitle: String? = null,
|
|
||||||
destructive: Boolean = false,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
onClick: (() -> Unit)? = null
|
|
||||||
) {
|
|
||||||
var modifier: Modifier = Modifier
|
|
||||||
if (enabled) {
|
|
||||||
onClick?.let { modifier = modifier.clickable(onClick = it) }
|
|
||||||
}
|
|
||||||
ListItem(
|
|
||||||
modifier = modifier,
|
|
||||||
colors = MaterialTheme.colorScheme.listItem,
|
|
||||||
headlineContent = {
|
|
||||||
Text(
|
|
||||||
title ?: stringResource(titleRes),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified)
|
|
||||||
},
|
|
||||||
supportingContent =
|
|
||||||
subtitle?.let {
|
|
||||||
{
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun Switch(
|
|
||||||
titleRes: Int = 0,
|
|
||||||
title: String? = null,
|
|
||||||
isOn: Boolean,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
onToggle: (Boolean) -> Unit = {}
|
|
||||||
) {
|
|
||||||
ListItem(
|
|
||||||
colors = MaterialTheme.colorScheme.listItem,
|
|
||||||
headlineContent = {
|
|
||||||
Text(
|
|
||||||
title ?: stringResource(titleRes),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
TintedSwitch(checked = isOn, onCheckedChange = onToggle, enabled = enabled)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
|
|
||||||
val adminStr = buildAnnotatedString {
|
|
||||||
append(stringResource(id = R.string.settings_admin_prefix))
|
|
||||||
|
|
||||||
pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL)
|
|
||||||
withStyle(
|
|
||||||
style =
|
|
||||||
SpanStyle(
|
|
||||||
color = MaterialTheme.colorScheme.link,
|
|
||||||
textDecoration = TextDecoration.Underline)) {
|
|
||||||
append(stringResource(id = R.string.settings_admin_link))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Lists.InfoItem(adminStr, onClick = onNavigateToAdminConsole)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun SettingsPreview() {
|
|
||||||
val vm = SettingsViewModel()
|
|
||||||
vm.corpDNSEnabled.set(true)
|
|
||||||
vm.tailNetLockEnabled.set(true)
|
|
||||||
vm.isAdmin.set(true)
|
|
||||||
vm.managedByOrganization.set("Tails and Scales Inc.")
|
|
||||||
SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
|
|
||||||
}
|
|
@ -1,104 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.view
|
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.RowScope
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.ripple
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.tailscale.ipn.ui.theme.topAppBar
|
|
||||||
import com.tailscale.ipn.ui.theme.ts_color_light_blue
|
|
||||||
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
|
|
||||||
|
|
||||||
typealias BackNavigation = () -> Unit
|
|
||||||
|
|
||||||
// Header view for all secondary screens
|
|
||||||
// @see TopAppBar actions for additional actions (usually a row of icons)
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun Header(
|
|
||||||
@StringRes titleRes: Int = 0,
|
|
||||||
title: (@Composable () -> Unit)? = null,
|
|
||||||
actions: @Composable RowScope.() -> Unit = {},
|
|
||||||
onBack: (() -> Unit)? = null
|
|
||||||
) {
|
|
||||||
val f = FocusRequester()
|
|
||||||
|
|
||||||
if (isAndroidTV()) {
|
|
||||||
LaunchedEffect(Unit) { f.requestFocus() }
|
|
||||||
}
|
|
||||||
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
title?.let { title() }
|
|
||||||
?: Text(
|
|
||||||
stringResource(titleRes),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface)
|
|
||||||
},
|
|
||||||
colors = MaterialTheme.colorScheme.topAppBar,
|
|
||||||
actions = actions,
|
|
||||||
navigationIcon = { onBack?.let { BackArrow(action = it, focusRequester = f) } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun BackArrow(action: () -> Unit, focusRequester: FocusRequester) {
|
|
||||||
|
|
||||||
Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = "Go back to the previous screen",
|
|
||||||
modifier =
|
|
||||||
Modifier.focusRequester(focusRequester)
|
|
||||||
.clickable(
|
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
|
||||||
indication = ripple(bounded = false),
|
|
||||||
onClick = { action() }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CheckedIndicator() {
|
|
||||||
Icon(Icons.Default.CheckCircle, null, tint = ts_color_light_blue)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SimpleActivityIndicator(size: Int = 32) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.width(size.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ActivityIndicator(progress: Double, size: Int = 32) {
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { progress.toFloat() },
|
|
||||||
modifier = Modifier.width(size.dp),
|
|
||||||
color = ts_color_light_blue,
|
|
||||||
trackColor = MaterialTheme.colorScheme.secondary,
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,111 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.view
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material3.Checkbox
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import com.tailscale.ipn.App
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
import com.tailscale.ipn.ui.util.Lists
|
|
||||||
import com.tailscale.ipn.ui.viewModel.SplitTunnelAppPickerViewModel
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SplitTunnelAppPickerView(
|
|
||||||
backToSettings: BackNavigation,
|
|
||||||
model: SplitTunnelAppPickerViewModel = viewModel()
|
|
||||||
) {
|
|
||||||
val installedApps by model.installedApps.collectAsState()
|
|
||||||
val excludedPackageNames by model.excludedPackageNames.collectAsState()
|
|
||||||
val builtInDisallowedPackageNames: List<String> = App.get().builtInDisallowedPackageNames
|
|
||||||
val mdmIncludedPackages by model.mdmIncludedPackages.collectAsState()
|
|
||||||
val mdmExcludedPackages by model.mdmExcludedPackages.collectAsState()
|
|
||||||
|
|
||||||
Scaffold(topBar = { Header(titleRes = R.string.split_tunneling, onBack = backToSettings) }) {
|
|
||||||
innerPadding ->
|
|
||||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
|
||||||
item(key = "header") {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = {
|
|
||||||
Text(
|
|
||||||
stringResource(
|
|
||||||
R.string
|
|
||||||
.selected_apps_will_access_the_internet_directly_without_using_tailscale))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (mdmExcludedPackages.value?.isNotEmpty() == true) {
|
|
||||||
item("mdmExcludedNotice") {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = {
|
|
||||||
Text(stringResource(R.string.certain_apps_are_not_routed_via_tailscale))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else if (mdmIncludedPackages.value?.isNotEmpty() == true) {
|
|
||||||
item("mdmIncludedNotice") {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = {
|
|
||||||
Text(stringResource(R.string.only_specific_apps_are_routed_via_tailscale))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
item("resolversHeader") {
|
|
||||||
Lists.SectionDivider(
|
|
||||||
stringResource(R.string.count_excluded_apps, excludedPackageNames.count()))
|
|
||||||
}
|
|
||||||
items(installedApps) { app ->
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text(app.name, fontWeight = FontWeight.SemiBold) },
|
|
||||||
leadingContent = {
|
|
||||||
Image(
|
|
||||||
bitmap =
|
|
||||||
model.installedAppsManager.packageManager
|
|
||||||
.getApplicationIcon(app.packageName)
|
|
||||||
.toBitmap()
|
|
||||||
.asImageBitmap(),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.width(40.dp).height(40.dp))
|
|
||||||
},
|
|
||||||
supportingContent = {
|
|
||||||
Text(
|
|
||||||
app.packageName,
|
|
||||||
color = MaterialTheme.colorScheme.secondary,
|
|
||||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
|
||||||
letterSpacing = MaterialTheme.typography.bodySmall.letterSpacing)
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
Checkbox(
|
|
||||||
checked = excludedPackageNames.contains(app.packageName),
|
|
||||||
enabled = !builtInDisallowedPackageNames.contains(app.packageName),
|
|
||||||
onCheckedChange = { checked ->
|
|
||||||
if (checked) {
|
|
||||||
model.exclude(packageName = app.packageName)
|
|
||||||
} else {
|
|
||||||
model.unexclude(packageName = app.packageName)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Lists.ItemDivider()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,199 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.view
|
|
||||||
|
|
||||||
import android.text.format.Formatter
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
import com.tailscale.ipn.ui.model.Ipn
|
|
||||||
import com.tailscale.ipn.ui.model.Tailcfg
|
|
||||||
import com.tailscale.ipn.ui.util.Lists.SectionDivider
|
|
||||||
import com.tailscale.ipn.ui.util.set
|
|
||||||
import com.tailscale.ipn.ui.viewModel.TaildropViewModel
|
|
||||||
import com.tailscale.ipn.ui.viewModel.TaildropViewModelFactory
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TaildropView(
|
|
||||||
requestedTransfers: StateFlow<List<Ipn.OutgoingFile>>,
|
|
||||||
applicationScope: CoroutineScope,
|
|
||||||
viewModel: TaildropViewModel =
|
|
||||||
viewModel(factory = TaildropViewModelFactory(requestedTransfers, applicationScope))
|
|
||||||
) {
|
|
||||||
Scaffold(
|
|
||||||
contentWindowInsets = WindowInsets.Companion.statusBars,
|
|
||||||
topBar = { Header(R.string.share) }) { paddingInsets ->
|
|
||||||
val showDialog = viewModel.showDialog.collectAsState().value
|
|
||||||
|
|
||||||
// Show the error overlay
|
|
||||||
showDialog?.let { ErrorDialog(type = it, action = { viewModel.showDialog.set(null) }) }
|
|
||||||
|
|
||||||
Column(modifier = Modifier.padding(paddingInsets)) {
|
|
||||||
FileShareHeader(
|
|
||||||
fileTransfers = requestedTransfers.collectAsState().value,
|
|
||||||
totalSize = viewModel.totalSize)
|
|
||||||
|
|
||||||
when (viewModel.state.collectAsState().value) {
|
|
||||||
Ipn.State.Running -> {
|
|
||||||
val peers by viewModel.myPeers.collectAsState()
|
|
||||||
val context = LocalContext.current
|
|
||||||
FileSharePeerList(
|
|
||||||
peers = peers,
|
|
||||||
stateViewGenerator = { peerId ->
|
|
||||||
viewModel.TrailingContentForPeer(peerId = peerId)
|
|
||||||
},
|
|
||||||
onShare = { viewModel.share(context, it) })
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
FileShareConnectView { viewModel.startVPN() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun FileSharePeerList(
|
|
||||||
peers: List<Tailcfg.Node>,
|
|
||||||
stateViewGenerator: @Composable (String) -> Unit,
|
|
||||||
onShare: (Tailcfg.Node) -> Unit
|
|
||||||
) {
|
|
||||||
SectionDivider(stringResource(R.string.my_devices))
|
|
||||||
|
|
||||||
when (peers.isEmpty()) {
|
|
||||||
true -> {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp).fillMaxHeight(),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.no_devices_to_share_with),
|
|
||||||
style = MaterialTheme.typography.titleMedium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false -> {
|
|
||||||
LazyColumn {
|
|
||||||
peers.forEach { peer ->
|
|
||||||
item {
|
|
||||||
PeerView(
|
|
||||||
peer = peer,
|
|
||||||
onClick = { onShare(peer) },
|
|
||||||
subtitle = { peer.Hostinfo.OS ?: "" },
|
|
||||||
trailingContent = { stateViewGenerator(peer.StableID) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun FileShareConnectView(onToggle: () -> Unit) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp).fillMaxHeight(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp, alignment = Alignment.CenterVertically),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.connect_to_your_tailnet_to_share_files),
|
|
||||||
style = MaterialTheme.typography.titleMedium)
|
|
||||||
Spacer(modifier = Modifier.size(1.dp))
|
|
||||||
PrimaryActionButton(onClick = onToggle) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.connect),
|
|
||||||
fontSize = MaterialTheme.typography.titleMedium.fontSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun FileShareHeader(fileTransfers: List<Ipn.OutgoingFile>, totalSize: Long) {
|
|
||||||
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
IconForTransfer(fileTransfers)
|
|
||||||
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
|
|
||||||
when (fileTransfers.isEmpty()) {
|
|
||||||
true ->
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.no_files_to_share),
|
|
||||||
style = MaterialTheme.typography.titleMedium)
|
|
||||||
false -> {
|
|
||||||
|
|
||||||
when (fileTransfers.size) {
|
|
||||||
1 -> Text(fileTransfers[0].Name, style = MaterialTheme.typography.titleMedium)
|
|
||||||
else ->
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.file_count, fileTransfers.size),
|
|
||||||
style = MaterialTheme.typography.titleMedium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val size = Formatter.formatFileSize(LocalContext.current, totalSize.toLong())
|
|
||||||
Text(
|
|
||||||
size,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun IconForTransfer(transfers: List<Ipn.OutgoingFile>) {
|
|
||||||
// (jonathan) TODO: Thumbnails?
|
|
||||||
when (transfers.size) {
|
|
||||||
0 ->
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.warning),
|
|
||||||
contentDescription = "no files",
|
|
||||||
modifier = Modifier.size(32.dp))
|
|
||||||
1 -> {
|
|
||||||
// Show a thumbnail for single image shares.
|
|
||||||
val context = LocalContext.current
|
|
||||||
context.contentResolver.getType(transfers[0].uri)?.let {
|
|
||||||
if (it.startsWith("image/")) {
|
|
||||||
AsyncImage(
|
|
||||||
model = transfers[0].uri,
|
|
||||||
contentDescription = "one file",
|
|
||||||
modifier = Modifier.size(40.dp))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.single_file),
|
|
||||||
contentDescription = "files",
|
|
||||||
modifier = Modifier.size(40.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else ->
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.single_file),
|
|
||||||
contentDescription = "files",
|
|
||||||
modifier = Modifier.size(40.dp))
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,140 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.view
|
|
||||||
|
|
||||||
import androidx.compose.foundation.LocalIndication
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.focusable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.text.ClickableText
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
|
||||||
import androidx.compose.ui.text.withStyle
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
import com.tailscale.ipn.ui.Links
|
|
||||||
import com.tailscale.ipn.ui.theme.defaultTextColor
|
|
||||||
import com.tailscale.ipn.ui.theme.link
|
|
||||||
import com.tailscale.ipn.ui.util.ClipboardValueView
|
|
||||||
import com.tailscale.ipn.ui.util.Lists
|
|
||||||
import com.tailscale.ipn.ui.util.LoadingIndicator
|
|
||||||
import com.tailscale.ipn.ui.util.set
|
|
||||||
import com.tailscale.ipn.ui.viewModel.TailnetLockSetupViewModel
|
|
||||||
import com.tailscale.ipn.ui.viewModel.TailnetLockSetupViewModelFactory
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TailnetLockSetupView(
|
|
||||||
backToSettings: BackNavigation,
|
|
||||||
model: TailnetLockSetupViewModel = viewModel(factory = TailnetLockSetupViewModelFactory())
|
|
||||||
) {
|
|
||||||
val statusItems by model.statusItems.collectAsState()
|
|
||||||
val nodeKey by model.nodeKey.collectAsState()
|
|
||||||
val tailnetLockKey by model.tailnetLockKey.collectAsState()
|
|
||||||
val tailnetLockTlPubKey = tailnetLockKey.replace("nlpub", "tlpub")
|
|
||||||
|
|
||||||
Scaffold(topBar = { Header(R.string.tailnet_lock, onBack = backToSettings) }) { innerPadding ->
|
|
||||||
LoadingIndicator.Wrap {
|
|
||||||
LazyColumn(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
|
|
||||||
item { ExplainerView() }
|
|
||||||
|
|
||||||
items(statusItems) { statusItem ->
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
ListItem(
|
|
||||||
modifier =
|
|
||||||
Modifier.focusable(
|
|
||||||
interactionSource = interactionSource)
|
|
||||||
.clickable(
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
indication = LocalIndication.current
|
|
||||||
) {},
|
|
||||||
leadingContent = {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = statusItem.icon),
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
},
|
|
||||||
headlineContent = { Text(stringResource(statusItem.title)) })
|
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
|
||||||
// Node key section
|
|
||||||
Lists.SectionDivider()
|
|
||||||
ClipboardValueView(
|
|
||||||
value = nodeKey,
|
|
||||||
title = stringResource(R.string.node_key),
|
|
||||||
subtitle = stringResource(R.string.node_key_explainer))
|
|
||||||
|
|
||||||
// Tailnet lock key section
|
|
||||||
Lists.SectionDivider()
|
|
||||||
ClipboardValueView(
|
|
||||||
value = tailnetLockTlPubKey,
|
|
||||||
title = stringResource(R.string.tailnet_lock_key),
|
|
||||||
subtitle = stringResource(R.string.tailnet_lock_key_explainer))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ExplainerView() {
|
|
||||||
val handler = LocalUriHandler.current
|
|
||||||
|
|
||||||
Lists.MultilineDescription {
|
|
||||||
ClickableText(
|
|
||||||
explainerText(),
|
|
||||||
onClick = { handler.openUri(Links.TAILNET_LOCK_KB_URL) },
|
|
||||||
style = MaterialTheme.typography.bodyMedium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun explainerText(): AnnotatedString {
|
|
||||||
return buildAnnotatedString {
|
|
||||||
withStyle(SpanStyle(color = MaterialTheme.colorScheme.defaultTextColor)) {
|
|
||||||
append(stringResource(id = R.string.tailnet_lock_explainer))
|
|
||||||
}
|
|
||||||
|
|
||||||
pushStringAnnotation(tag = "tailnetLockSupportURL", annotation = Links.TAILNET_LOCK_KB_URL)
|
|
||||||
|
|
||||||
withStyle(
|
|
||||||
style =
|
|
||||||
SpanStyle(
|
|
||||||
color = MaterialTheme.colorScheme.link,
|
|
||||||
textDecoration = TextDecoration.Underline)) {
|
|
||||||
append(stringResource(id = R.string.learn_more))
|
|
||||||
}
|
|
||||||
pop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
@Preview
|
|
||||||
fun TailnetLockSetupViewPreview() {
|
|
||||||
val vm = TailnetLockSetupViewModel()
|
|
||||||
vm.nodeKey.set("8BADF00D-EA7-1337-DEAD-BEEF")
|
|
||||||
vm.tailnetLockKey.set("C0FFEE-CAFE-50DA")
|
|
||||||
TailnetLockSetupView(backToSettings = {}, vm)
|
|
||||||
}
|
|
@ -1,154 +0,0 @@
|
|||||||
// 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)))
|
|
@ -1,12 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
@ -1,193 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
@ -1,102 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.view
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.ListItemColors
|
|
||||||
import androidx.compose.material3.ListItemDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.tailscale.ipn.R
|
|
||||||
import com.tailscale.ipn.ui.model.IpnLocal
|
|
||||||
import com.tailscale.ipn.ui.theme.minTextSize
|
|
||||||
import com.tailscale.ipn.ui.theme.short
|
|
||||||
import com.tailscale.ipn.ui.util.AutoResizingText
|
|
||||||
|
|
||||||
// Used to decorate UserViews.
|
|
||||||
// NONE indicates no decoration
|
|
||||||
// CURRENT indicates the user is the current user and will be "checked"
|
|
||||||
// SWITCHING indicates the user is being switched to and will be "loading"
|
|
||||||
// NAV will show a chevron
|
|
||||||
enum class UserActionState {
|
|
||||||
CURRENT,
|
|
||||||
SWITCHING,
|
|
||||||
NAV,
|
|
||||||
NONE
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun UserView(
|
|
||||||
profile: IpnLocal.LoginProfile?,
|
|
||||||
onClick: (() -> Unit)? = null,
|
|
||||||
colors: ListItemColors = ListItemDefaults.colors(),
|
|
||||||
actionState: UserActionState = UserActionState.NONE,
|
|
||||||
) {
|
|
||||||
Box {
|
|
||||||
var modifier: Modifier = Modifier
|
|
||||||
onClick?.let { modifier = modifier.clickable { it() } }
|
|
||||||
profile?.let {
|
|
||||||
ListItem(
|
|
||||||
modifier = modifier,
|
|
||||||
colors = colors,
|
|
||||||
leadingContent = { Avatar(profile = profile, size = 36) },
|
|
||||||
headlineContent = {
|
|
||||||
AutoResizingText(
|
|
||||||
text = profile.UserProfile.LoginName,
|
|
||||||
style = MaterialTheme.typography.titleMedium.short,
|
|
||||||
minFontSize = MaterialTheme.typography.minTextSize,
|
|
||||||
overflow = TextOverflow.Ellipsis)
|
|
||||||
},
|
|
||||||
supportingContent = {
|
|
||||||
Column {
|
|
||||||
AutoResizingText(
|
|
||||||
text = profile.NetworkProfile?.DomainName ?: "",
|
|
||||||
style = MaterialTheme.typography.bodyMedium.short,
|
|
||||||
minFontSize = MaterialTheme.typography.minTextSize,
|
|
||||||
overflow = TextOverflow.Ellipsis)
|
|
||||||
|
|
||||||
profile.customControlServerHostname()?.let {
|
|
||||||
AutoResizingText(
|
|
||||||
text = it,
|
|
||||||
style = MaterialTheme.typography.bodyMedium.short,
|
|
||||||
minFontSize = MaterialTheme.typography.minTextSize,
|
|
||||||
overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
when (actionState) {
|
|
||||||
UserActionState.CURRENT -> CheckedIndicator()
|
|
||||||
UserActionState.SWITCHING -> SimpleActivityIndicator(size = 26)
|
|
||||||
UserActionState.NAV ->
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.KeyboardArrowRight, null, Modifier.offset(x = 6.dp))
|
|
||||||
UserActionState.NONE -> Unit
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
?: run {
|
|
||||||
ListItem(
|
|
||||||
modifier = modifier,
|
|
||||||
colors = colors,
|
|
||||||
headlineContent = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.accounts),
|
|
||||||
style = MaterialTheme.typography.titleMedium)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue