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
|
||||
# tailscale-android.
|
||||
|
||||
FROM --platform=linux/amd64 eclipse-temurin:20-jdk
|
||||
FROM openjdk:8-jdk
|
||||
|
||||
# To enable running android tools such as aapt
|
||||
RUN apt-get update && apt-get -y upgrade
|
||||
RUN apt-get install -y libz1 libstdc++6 unzip
|
||||
RUN apt-get install -y lib32z1 lib32stdc++6
|
||||
# For Go:
|
||||
RUN apt-get -y --no-install-recommends install curl gcc
|
||||
RUN apt-get -y --no-install-recommends install ca-certificates libc6-dev git
|
||||
|
||||
RUN apt-get -y install make
|
||||
|
||||
RUN mkdir -p build
|
||||
RUN mkdir -p 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
|
||||
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 ANDROID_SDK_ROOT /build/android-sdk
|
||||
|
||||
# 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
|
||||
# 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 mkdir -p $HOME/tailscale-android
|
||||
RUN git config --global --add safe.directory $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
|
||||
COPY android/gradlew android/gradlew
|
||||
COPY android/gradle android/gradle
|
||||
RUN ./android/gradlew
|
||||
|
||||
# Run a shell
|
||||
CMD /bin/bash
|
||||
|
||||
|
@ -1,195 +1,57 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = "1.9.22"
|
||||
ext.compose_version = "1.5.10"
|
||||
ext.accompanist_version = "0.34.0"
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven {
|
||||
url = uri("https://plugins.gradle.org/m2/")
|
||||
}
|
||||
jcenter()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.6.1'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
|
||||
classpath("com.ncorti.ktfmt.gradle:plugin:0.17.0")
|
||||
classpath 'com.android.tools.build:gradle:4.2.0'
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
jcenter()
|
||||
flatDir {
|
||||
dirs 'libs'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'org.jetbrains.kotlin.plugin.serialization'
|
||||
apply plugin: 'com.ncorti.ktfmt.gradle'
|
||||
|
||||
android {
|
||||
ndkVersion "23.1.7779620"
|
||||
compileSdkVersion 34
|
||||
compileSdkVersion 30
|
||||
defaultConfig {
|
||||
minSdkVersion 26
|
||||
targetSdkVersion 34
|
||||
versionCode 242
|
||||
versionName getVersionProperty("VERSION_LONG")
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
// This setting, which defaults to 'true', will cause Tailscale to fall
|
||||
// back to the Google DNS servers if it cannot determine what the
|
||||
// operating system's DNS configuration is.
|
||||
//
|
||||
// Set it to false either here or in your local.properties file to
|
||||
// disable this behaviour.
|
||||
buildConfigField "boolean", "USE_GOOGLE_DNS_FALLBACK", getLocalProperty("tailscale.useGoogleDnsFallback", "true")
|
||||
minSdkVersion 22
|
||||
targetSdkVersion 31
|
||||
versionCode 144
|
||||
versionName "1.35.80-t237f030cd-gfd874ed58e9"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
warningsAsErrors true
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig true
|
||||
compose true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "$compose_version"
|
||||
sourceCompatibility 1.8
|
||||
targetCompatibility 1.8
|
||||
}
|
||||
flavorDimensions "version"
|
||||
namespace 'com.tailscale.ipn'
|
||||
|
||||
buildTypes {
|
||||
applicationTest {
|
||||
initWith debug
|
||||
manifestPlaceholders.leanbackRequired = false
|
||||
buildConfigField "String", "GITHUB_USERNAME", "\"" + getLocalProperty("githubUsername", "")+"\""
|
||||
buildConfigField "String", "GITHUB_PASSWORD", "\"" + getLocalProperty("githubPassword", "")+"\""
|
||||
buildConfigField "String", "GITHUB_2FA_SECRET", "\"" + getLocalProperty("github2FASecret", "")+"\""
|
||||
productFlavors {
|
||||
fdroid {
|
||||
// The fdroid flavor contains only free dependencies and is suitable
|
||||
// for the F-Droid app store.
|
||||
}
|
||||
debug {
|
||||
manifestPlaceholders.leanbackRequired = false
|
||||
}
|
||||
release {
|
||||
manifestPlaceholders.leanbackRequired = false
|
||||
|
||||
minifyEnabled true
|
||||
|
||||
shrinkResources true
|
||||
|
||||
proguardFiles getDefaultProguardFile(
|
||||
'proguard-android-optimize.txt'),
|
||||
'proguard-rules.pro'
|
||||
play {
|
||||
// The play flavor contains all features and is for the Play Store.
|
||||
}
|
||||
release_tv {
|
||||
initWith release
|
||||
manifestPlaceholders.leanbackRequired = true
|
||||
}
|
||||
}
|
||||
|
||||
testBuildType "applicationTest"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Android dependencies.
|
||||
implementation "androidx.core:core:1.13.1"
|
||||
implementation 'androidx.core:core-ktx:1.13.1'
|
||||
implementation "androidx.browser:browser:1.8.0"
|
||||
implementation "androidx.security:security-crypto:1.1.0-alpha06"
|
||||
implementation "androidx.work:work-runtime:2.9.1"
|
||||
|
||||
// Kotlin dependencies.
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1"
|
||||
implementation 'junit:junit:4.13.2'
|
||||
runtimeOnly "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
|
||||
|
||||
// Compose dependencies.
|
||||
def composeBom = platform('androidx.compose:compose-bom:2024.09.03')
|
||||
implementation composeBom
|
||||
implementation 'androidx.compose.material3:material3:1.3.0'
|
||||
implementation 'androidx.compose.material:material-icons-core:1.7.3'
|
||||
implementation "androidx.compose.ui:ui:1.7.3"
|
||||
implementation "androidx.compose.ui:ui-tooling:1.7.3"
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6'
|
||||
implementation 'androidx.activity:activity-compose:1.9.2'
|
||||
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
|
||||
implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version"
|
||||
implementation "androidx.core:core-splashscreen:1.1.0-rc01"
|
||||
implementation "androidx.compose.animation:animation:1.7.4"
|
||||
|
||||
// Navigation dependencies.
|
||||
def nav_version = "2.8.2"
|
||||
implementation "androidx.navigation:navigation-compose:$nav_version"
|
||||
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
|
||||
|
||||
// Supporting libraries.
|
||||
implementation("io.coil-kt:coil-compose:2.6.0")
|
||||
implementation("com.google.zxing:core:3.5.1")
|
||||
implementation("com.patrykandpatrick.vico:compose:1.15.0")
|
||||
implementation("com.patrykandpatrick.vico:compose-m3:1.15.0")
|
||||
|
||||
// Tailscale dependencies.
|
||||
implementation ':libtailscale@aar'
|
||||
|
||||
// Integration Tests
|
||||
androidTestImplementation composeBom
|
||||
androidTestImplementation 'androidx.test:runner:1.6.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
|
||||
implementation 'androidx.test.uiautomator:uiautomator:2.3.0'
|
||||
|
||||
|
||||
// Authentication only for tests
|
||||
androidTestImplementation 'dev.turingcomplete:kotlin-onetimepassword:2.4.0'
|
||||
androidTestImplementation 'commons-codec:commons-codec:1.16.1'
|
||||
|
||||
// Unit Tests
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.mockito:mockito-core:5.12.0'
|
||||
testImplementation 'org.mockito:mockito-inline:5.2.0'
|
||||
testImplementation 'org.mockito.kotlin:mockito-kotlin:5.4.0'
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
}
|
||||
|
||||
def getLocalProperty(key, defaultValue) {
|
||||
try {
|
||||
Properties properties = new Properties()
|
||||
properties.load(project.file('local.properties').newDataInputStream())
|
||||
return properties.getProperty(key) ?: defaultValue
|
||||
} catch(Throwable ignored) {
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def getVersionProperty(key) {
|
||||
// tailscale.version is created / updated by the makefile, it is in a loosely
|
||||
// Makfile/envfile format, which is also loosely a properties file format.
|
||||
// make tailscale.version
|
||||
def versionProps = new Properties()
|
||||
versionProps.load(project.file('../tailscale.version').newDataInputStream())
|
||||
return versionProps.getProperty(key).replaceAll('^\"|\"$', '')
|
||||
implementation "androidx.core:core:1.2.0"
|
||||
implementation "androidx.browser:browser:1.2.0"
|
||||
implementation "androidx.security:security-crypto:1.1.0-alpha03"
|
||||
implementation ':ipn@aar'
|
||||
testCompile "junit:junit:4.12"
|
||||
|
||||
// Non-free dependencies.
|
||||
playImplementation 'com.google.android.gms:play-services-auth:18.0.0'
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionSha256Sum=544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||
distributionSha256Sum=3239b5ed86c3838a37d983ac100573f64c1f3fd8e1eb6c89fa5f9529b5ec091d
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
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
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// 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.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.service.quicksettings.Tile;
|
||||
import android.service.quicksettings.TileService;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public class QuickToggleService extends TileService {
|
||||
// lock protects the static fields below it.
|
||||
private static final Object lock = new Object();
|
||||
|
||||
// isRunning tracks whether the VPN is running.
|
||||
private static boolean isRunning;
|
||||
|
||||
private static Object lock = new Object();
|
||||
// Active tracks whether the VPN is active.
|
||||
private static boolean active;
|
||||
// Ready tracks whether the tailscale backend is
|
||||
// ready to switch on/off.
|
||||
private static boolean ready;
|
||||
// currentTile tracks getQsTile while service is listening.
|
||||
private static Tile currentTile;
|
||||
|
||||
public static void updateTile() {
|
||||
var app = UninitializedApp.get();
|
||||
Tile t;
|
||||
boolean act;
|
||||
synchronized (lock) {
|
||||
t = currentTile;
|
||||
act = isRunning && app.isAbleToStartVPN();
|
||||
}
|
||||
if (t == null) {
|
||||
return;
|
||||
}
|
||||
t.setLabel("Tailscale");
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
t.setSubtitle(act ? app.getString(R.string.connected) : app.getString(R.string.not_connected));
|
||||
}
|
||||
t.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
|
||||
t.updateTile();
|
||||
}
|
||||
|
||||
static void setVPNRunning(boolean running) {
|
||||
synchronized (lock) {
|
||||
isRunning = running;
|
||||
}
|
||||
updateTile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStartListening() {
|
||||
@Override public void onStartListening() {
|
||||
synchronized (lock) {
|
||||
currentTile = getQsTile();
|
||||
}
|
||||
updateTile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStopListening() {
|
||||
@Override public void onStopListening() {
|
||||
synchronized (lock) {
|
||||
currentTile = null;
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
@Override
|
||||
public void onClick() {
|
||||
@Override public void onClick() {
|
||||
boolean r;
|
||||
synchronized (lock) {
|
||||
r = UninitializedApp.get().isAbleToStartVPN();
|
||||
r = ready;
|
||||
}
|
||||
if (r) {
|
||||
// Get the application to make sure it initializes
|
||||
App.get();
|
||||
onTileClick();
|
||||
} else {
|
||||
// Start main activity.
|
||||
Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName());
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
// Request code for opening activity.
|
||||
startActivityAndCollapse(PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE));
|
||||
} else {
|
||||
// Deprecated, but still required for older versions.
|
||||
startActivityAndCollapse(i);
|
||||
}
|
||||
}
|
||||
|
||||
private static 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() {
|
||||
UninitializedApp app = UninitializedApp.get();
|
||||
boolean needsToStop;
|
||||
static void setReady(Context ctx, boolean rdy) {
|
||||
synchronized (lock) {
|
||||
needsToStop = app.isAbleToStartVPN() && isRunning;
|
||||
ready = rdy;
|
||||
}
|
||||
if (needsToStop) {
|
||||
app.stopVPN();
|
||||
} else {
|
||||
app.startVPN();
|
||||
updateTile();
|
||||
}
|
||||
|
||||
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