Compare commits
187 Commits
1.64.0-t78
...
main
Author | SHA1 | Date |
---|---|---|
Jonathan Nobels | f8f2ee029a | 3 days ago |
kari-ts | 08ae018468 | 4 days ago |
Brad Fitzpatrick | f26a828cbd | 1 week ago |
kari-ts | 9731afd44c | 1 week ago |
kari-ts | 9654bb5d9d | 1 week ago |
kari-ts | 2ec7304092 | 1 week ago |
kari-ts | 22de0cdb7e | 1 week ago |
kari-ts | fc8ccc0057 | 1 week ago |
Jonathan Nobels | 0b2a04b475 | 2 weeks ago |
kari-ts | 9987dbc592 | 2 weeks ago |
kari-ts | 8b91b0ff0a | 2 weeks ago |
James Tucker | 2fcb080aa6 | 2 weeks ago |
James Tucker | 9e09fad087 | 2 weeks ago |
James Tucker | 204173d10c | 2 weeks ago |
James Tucker | b3a7f7f2ae | 2 weeks ago |
James Tucker | 209045d4f7 | 2 weeks ago |
James Tucker | 7888447f3f | 2 weeks ago |
James Tucker | 72c410465c | 2 weeks ago |
Andrea Gottardo | 001e79546c | 2 weeks ago |
Andrea Gottardo | ffbc556cde | 2 weeks ago |
Jonathan Nobels | e195def5e2 | 2 weeks ago |
Andrew Dunham | aaecc62e1c | 2 weeks ago |
Brad Fitzpatrick | 33f79deb3a | 2 weeks ago |
kari-ts | 28712da8d0 | 2 weeks ago |
Andrew Dunham | 45567146f4 | 2 weeks ago |
kari-ts | 283e1ebcd8 | 2 weeks ago |
Jonathan Nobels | 9f87446ab6 | 3 weeks ago |
Jonathan Nobels | ab7ab73736 | 3 weeks ago |
Anton Tolchanov | fb8a4f51dc | 3 weeks ago |
Anton Tolchanov | 095dae1195 | 4 weeks ago |
Andrea Gottardo | 19581721cf | 4 weeks ago |
kari-ts | 18e4b176c6 | 4 weeks ago |
kari-ts | 77eaadb360 | 4 weeks ago |
yin kaisheng | a9ff204ae4 | 4 weeks ago |
kari-ts | b4ca226eb7 | 4 weeks ago |
kari-ts | d94125e767 | 4 weeks ago |
kari-ts | eae8789628 | 1 month ago |
kari-ts | 29e3c187c2 | 1 month ago |
Josh Vocal | 40090f179b | 1 month ago |
Jonathan Nobels | 502eada21a | 1 month ago |
Josh Vocal | cdbd062426 | 1 month ago |
Josh Vocal | 26e5e796fa | 1 month ago |
Andrea Gottardo | 8648c2ef27 | 1 month ago |
kari-ts | 1a41ab3b66 | 1 month ago |
kari-ts | 10a4350c02 | 1 month ago |
Andrea Gottardo | 4830d8826e | 1 month ago |
Jonathan Nobels | 20a5beab3e | 1 month ago |
Andrea Gottardo | a843c93669 | 1 month ago |
kari-ts | fcfb997fde | 1 month ago |
kari-ts | c6f3239b1b | 1 month ago |
Jonathan Nobels | e6fc832494 | 1 month ago |
Andrea Gottardo | 7e5e0f25cf | 1 month ago |
Percy Wegmann | c1b957cc5f | 1 month ago |
Jonathan Nobels | 716152b57d | 2 months ago |
Andrea Gottardo | 338c13b6b5 | 2 months ago |
Andrea Gottardo | 403aa092c4 | 2 months ago |
Nick Khyl | 2a32ed1f30 | 2 months ago |
Nick Khyl | 8767fbd8d8 | 2 months ago |
Nick Khyl | 946afb6c33 | 2 months ago |
Nick Khyl | 101c9dd121 | 2 months ago |
Andrea Gottardo | ea0c1e960d | 2 months ago |
Jonathan Nobels | 76ab7eab92 | 2 months ago |
Jonathan Nobels | cb916676a4 | 2 months ago |
Jonathan Nobels | 32e48dc78e | 2 months ago |
Jonathan Nobels | 23454e9bc6 | 2 months ago |
Andrea Gottardo | 1465b2a67f | 2 months ago |
Andrea Gottardo | b9917c8647 | 2 months ago |
Jonathan Nobels | 6deb61a20e | 2 months ago |
Jonathan Nobels | b9477c64a8 | 2 months ago |
Jonathan Nobels | 2f59feef20 | 2 months ago |
Andrea Gottardo | c4a1dec8eb | 2 months ago |
Jonathan Nobels | 65a025007f | 2 months ago |
Andrea Gottardo | ca91191cc6 | 3 months ago |
Jonathan Nobels | 26b4635c11 | 3 months ago |
Jonathan Nobels | 66fa3c41a6 | 3 months ago |
Jonathan Nobels | dfda774dc0 | 3 months ago |
Jonathan Nobels | 2a8d07c5f6 | 3 months ago |
Andrea Gottardo | 9b24888c4c | 3 months ago |
Andrea Gottardo | a120eb2fe1 | 3 months ago |
Andrea Gottardo | b3a74986ac | 3 months ago |
Andrea Gottardo | 840a31d74e | 3 months ago |
Andrea Gottardo | b6cacdfd6a | 3 months ago |
Andrea Gottardo | d702d2dab8 | 3 months ago |
Jonathan Nobels | 811641f538 | 3 months ago |
Andrea Gottardo | 9ae30c06bf | 3 months ago |
Andrea Gottardo | 793a83fdc6 | 3 months ago |
Andrea Gottardo | ea928ca971 | 3 months ago |
Andrea Gottardo | 8dc1a13f77 | 3 months ago |
Jonathan Nobels | 196944d168 | 3 months ago |
Jonathan Nobels | 0ff6be6345 | 3 months ago |
Jonathan Nobels | 634d51c20b | 4 months ago |
Fred Silberberg | 864cc35bd4 | 4 months ago |
Jonathan Nobels | 23805e9d00 | 4 months ago |
Jonathan Nobels | 5b121c1876 | 4 months ago |
Jonathan Nobels | 80864fec12 | 4 months ago |
Jonathan Nobels | ef21753763 | 4 months ago |
Jonathan Nobels | 0e82e54ffb | 4 months ago |
Jonathan Nobels | 64fca2a712 | 4 months ago |
Jonathan Nobels | a74e30d4e2 | 4 months ago |
Jonathan Nobels | 2788cf7ee5 | 4 months ago |
kari-ts | d7a87e868c | 4 months ago |
kari-ts | 15da8f3797 | 4 months ago |
kari-ts | 8f62f0da79 | 4 months ago |
kari-ts | cbc47791ad | 4 months ago |
kari-ts | a6fd8a8093 | 4 months ago |
kari-ts | 0df6c61eee | 4 months ago |
Andrea Gottardo | 75db9e64c8 | 4 months ago |
kari-ts | e826a173aa | 4 months ago |
kari-ts | a05829b3c0 | 4 months ago |
kari-ts | 72f35cd318 | 4 months ago |
kari-ts | 4fa86dbf03 | 4 months ago |
Jonathan Nobels | 77c2d924ee | 4 months ago |
Jonathan Nobels | b37492a547 | 4 months ago |
kari-ts | 999c6f2357 | 4 months ago |
Andrea Gottardo | 006b1e6852 | 4 months ago |
kari-ts | 32e29c4efd | 4 months ago |
kari-ts | 9aa3a840de | 4 months ago |
kari-ts | 0ff47f7ab5 | 4 months ago |
kari-ts | 12ad295706 | 4 months ago |
kari-ts | d842ccde22 | 4 months ago |
Andrea Gottardo | cbcc773b98 | 5 months ago |
Andrea Gottardo | cbc0035dfe | 5 months ago |
kari-ts | c47ead9412 | 5 months ago |
Percy Wegmann | 46cdbb7b9b | 5 months ago |
kari-ts | 5476288100 | 5 months ago |
kari-ts | a3b356a81c | 5 months ago |
Percy Wegmann | 411d7b2597 | 5 months ago |
Percy Wegmann | 59a88ffbab | 5 months ago |
kari-ts | f684bf696d | 5 months ago |
Percy Wegmann | 698fb868a7 | 5 months ago |
Andrea Gottardo | 82c17a4d1d | 5 months ago |
Jonathan Nobels | b615eb38b4 | 5 months ago |
Andrea Gottardo | 24d6cc7a08 | 5 months ago |
kari-ts | ec1dc8b0be | 5 months ago |
Percy Wegmann | edb3f5b0c5 | 5 months ago |
kari-ts | 7f66c373ea | 5 months ago |
kari-ts | 2d7d6e1357 | 5 months ago |
Jonathan Nobels | 45fd2e0661 | 5 months ago |
Percy Wegmann | 31b0ec8865 | 5 months ago |
Will Norris | 9703d48f1a | 5 months ago |
Jonathan Nobels | 17ad0c8cc0 | 5 months ago |
Jonathan Nobels | a2471d38cb | 5 months ago |
kari-ts | e6f6d35a99 | 5 months ago |
kari-ts | 5e3236260f | 5 months ago |
kari-ts | d330726ba1 | 5 months ago |
Andrea Gottardo | 0c0853a962 | 5 months ago |
James Tucker | 3f864b28c7 | 5 months ago |
kari-ts | 22c129ee1c | 5 months ago |
Andrea Gottardo | 427e2d29b4 | 5 months ago |
kari-ts | 1c0aef5418 | 5 months ago |
kari-ts | 39628be8a6 | 5 months ago |
Brad Fitzpatrick | 9dda2cc470 | 5 months ago |
kari-ts | a6bc2244b6 | 5 months ago |
kari-ts | 24dd83090c | 5 months ago |
kari-ts | ad3b6a5a64 | 5 months ago |
Percy Wegmann | 16fa0e9b9e | 5 months ago |
Andrea Gottardo | 88b0af2c9b | 5 months ago |
Andrea Gottardo | 7119424e32 | 5 months ago |
Jonathan Nobels | b06342629f | 5 months ago |
Percy Wegmann | 07d04ca750 | 5 months ago |
Percy Wegmann | 057e25c23d | 5 months ago |
Will Norris | a54ebf75ef | 5 months ago |
Jonathan Nobels | f4d2a277a5 | 5 months ago |
kari-ts | 75e2d8983b | 5 months ago |
kari-ts | bbb3c86fa8 | 5 months ago |
Percy Wegmann | bc8985126d | 5 months ago |
Brad Fitzpatrick | eb8d731a04 | 5 months ago |
kari-ts | 81acaef5b7 | 5 months ago |
kari-ts | 19177df1e2 | 5 months ago |
Praneet Loke | 6197cb9576 | 5 months ago |
kari-ts | 253c116f9b | 5 months ago |
Jonathan Nobels | 1c3af6713c | 5 months ago |
kari-ts | 39d1d0b3c3 | 6 months ago |
Andrea Gottardo | 56da7b66d0 | 6 months ago |
kari-ts | f95428f7fa | 6 months ago |
Percy Wegmann | 0c58841350 | 6 months ago |
Andrea Gottardo | 8a7148c085 | 6 months ago |
Jonathan Nobels | 372af99c53 | 6 months ago |
Andrea Gottardo | a73025b36f | 6 months ago |
Andrea Gottardo | 4d86c1a6f6 | 6 months ago |
Andrea Gottardo | a1d97baeb0 | 6 months ago |
Matt Drollette | 9533db44b7 | 6 months ago |
Andrea Gottardo | 44ac22c29d | 6 months ago |
kari-ts | 5ad25262ad | 6 months ago |
Jonathan Nobels | be6364ca95 | 6 months ago |
kari-ts | 3e32e97261 | 6 months ago |
Andrea Gottardo | 164a243b77 | 6 months ago |
@ -1,31 +0,0 @@
|
|||||||
name: Build Legacy 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
|
|
||||||
- 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'
|
|
||||||
|
|
||||||
- name: Build Legacy APK
|
|
||||||
run: make tailscale-debug.apk
|
|
@ -0,0 +1,36 @@
|
|||||||
|
name: go mod tidy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- "release-branch/*"
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-go-mod-tidy:
|
||||||
|
runs-on: [ubuntu-latest]
|
||||||
|
timeout-minutes: 8
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
cache: false
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Check 'go mod tidy' is clean
|
||||||
|
run: |
|
||||||
|
./tool/go mod tidy
|
||||||
|
echo
|
||||||
|
echo
|
||||||
|
git diff --name-only --exit-code || (echo "The files above need updating. Please run 'go mod tidy'."; exit 1)
|
@ -1,5 +1,5 @@
|
|||||||
android.defaults.buildfeatures.buildconfig=true
|
android.defaults.buildfeatures.buildconfig=true
|
||||||
android.nonFinalResIds=false
|
android.nonFinalResIds=false
|
||||||
android.nonTransitiveRClass=false
|
android.nonTransitiveRClass=true
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m
|
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionSha256Sum=38f66cd6eef217b4c35855bb11ea4e9fbc53594ccccb5fb82dfd317ef8c2c5a3
|
distributionSha256Sum=544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
@ -0,0 +1,25 @@
|
|||||||
|
# Keep all classes with native methods
|
||||||
|
-keepclasseswithmembernames class * {
|
||||||
|
native <methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep the classes with syspolicy MDM keys, some of which
|
||||||
|
# get used only by the Go backend.
|
||||||
|
-keep class com.tailscale.ipn.mdm.** { *; }
|
||||||
|
|
||||||
|
# Keep specific classes from Tink library
|
||||||
|
-keep class com.google.crypto.tink.** { *; }
|
||||||
|
|
||||||
|
# Ignore warnings about missing Error Prone annotations
|
||||||
|
-dontwarn com.google.errorprone.annotations.**
|
||||||
|
|
||||||
|
# Keep Error Prone annotations if referenced
|
||||||
|
-keep class com.google.errorprone.annotations.** { *; }
|
||||||
|
|
||||||
|
# Keep Google HTTP Client classes
|
||||||
|
-keep class com.google.api.client.http.** { *; }
|
||||||
|
-dontwarn com.google.api.client.http.**
|
||||||
|
|
||||||
|
# Keep Joda-Time classes
|
||||||
|
-keep class org.joda.time.** { *; }
|
||||||
|
-dontwarn org.joda.time.**
|
@ -0,0 +1,35 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
package com.tailscale.ipn
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
object AppSourceChecker {
|
||||||
|
|
||||||
|
const val TAG = "AppSourceChecker"
|
||||||
|
|
||||||
|
fun getInstallSource(context: Context): String {
|
||||||
|
val packageManager = context.packageManager
|
||||||
|
val packageName = context.packageName
|
||||||
|
Log.d(TAG, "Package name: $packageName")
|
||||||
|
|
||||||
|
val installerPackageName =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
packageManager.getInstallSourceInfo(packageName).installingPackageName
|
||||||
|
} else {
|
||||||
|
@Suppress("deprecation") packageManager.getInstallerPackageName(packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Installer package name: $installerPackageName")
|
||||||
|
|
||||||
|
return when (installerPackageName) {
|
||||||
|
"com.android.vending" -> "googleplay"
|
||||||
|
"org.fdroid.fdroid" -> "fdroid"
|
||||||
|
"com.amazon.venezia" -> "amazon"
|
||||||
|
null -> "unknown"
|
||||||
|
else -> "unknown($installerPackageName)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,35 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn;
|
|
||||||
|
|
||||||
import android.app.Activity;
|
|
||||||
|
|
||||||
import java.lang.reflect.Method;
|
|
||||||
|
|
||||||
public class MaybeGoogle {
|
|
||||||
static boolean isGoogle() {
|
|
||||||
return getGoogle() != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static String getIdTokenForActivity(Activity act) {
|
|
||||||
Class<?> google = getGoogle();
|
|
||||||
if (google == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
Method method = google.getMethod("getIdTokenForActivity", Activity.class);
|
|
||||||
return (String) method.invoke(null, act);
|
|
||||||
} catch (Exception e) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Class getGoogle() {
|
|
||||||
try {
|
|
||||||
return Class.forName("com.tailscale.ipn.Google");
|
|
||||||
} catch (ClassNotFoundException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,168 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
package com.tailscale.ipn
|
||||||
|
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.LinkProperties
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.NetworkRequest
|
||||||
|
import android.util.Log
|
||||||
|
import com.tailscale.ipn.util.TSLog
|
||||||
|
import libtailscale.Libtailscale
|
||||||
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
|
import kotlin.concurrent.withLock
|
||||||
|
|
||||||
|
object NetworkChangeCallback {
|
||||||
|
|
||||||
|
private const val TAG = "NetworkChangeCallback"
|
||||||
|
|
||||||
|
private data class NetworkInfo(var caps: NetworkCapabilities, var linkProps: LinkProperties)
|
||||||
|
|
||||||
|
private val lock = ReentrantLock()
|
||||||
|
|
||||||
|
private val activeNetworks = mutableMapOf<Network, NetworkInfo>() // keyed by Network
|
||||||
|
|
||||||
|
// monitorDnsChanges sets up a network callback to monitor changes to the
|
||||||
|
// system's network state and update the DNS configuration when interfaces
|
||||||
|
// become available or properties of those interfaces change.
|
||||||
|
fun monitorDnsChanges(connectivityManager: ConnectivityManager, dns: DnsConfig) {
|
||||||
|
val networkConnectivityRequest =
|
||||||
|
NetworkRequest.Builder()
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Use registerNetworkCallback to listen for updates from all networks, and
|
||||||
|
// then update DNS configs for the best network when LinkProperties are changed.
|
||||||
|
// Per
|
||||||
|
// https://developer.android.com/reference/android/net/ConnectivityManager.NetworkCallback#onAvailable(android.net.Network), this happens after all other updates.
|
||||||
|
//
|
||||||
|
// Note that we can't use registerDefaultNetworkCallback because the
|
||||||
|
// default network used by Tailscale will always show up with capability
|
||||||
|
// NOT_VPN=false, and we must filter out NOT_VPN networks to avoid routing
|
||||||
|
// loops.
|
||||||
|
connectivityManager.registerNetworkCallback(
|
||||||
|
networkConnectivityRequest,
|
||||||
|
object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(network: Network) {
|
||||||
|
super.onAvailable(network)
|
||||||
|
|
||||||
|
TSLog.d(TAG, "onAvailable: network ${network}")
|
||||||
|
lock.withLock {
|
||||||
|
activeNetworks[network] = NetworkInfo(NetworkCapabilities(), LinkProperties())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
|
||||||
|
super.onCapabilitiesChanged(network, capabilities)
|
||||||
|
lock.withLock { activeNetworks[network]?.caps = capabilities }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
|
||||||
|
super.onLinkPropertiesChanged(network, linkProperties)
|
||||||
|
lock.withLock {
|
||||||
|
activeNetworks[network]?.linkProps = linkProperties
|
||||||
|
maybeUpdateDNSConfig("onLinkPropertiesChanged", dns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLost(network: Network) {
|
||||||
|
super.onLost(network)
|
||||||
|
|
||||||
|
TSLog.d(TAG, "onLost: network ${network}")
|
||||||
|
lock.withLock {
|
||||||
|
activeNetworks.remove(network)
|
||||||
|
maybeUpdateDNSConfig("onLost", dns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickNonMetered returns the first non-metered network in the list of
|
||||||
|
// networks, or the first network if none are non-metered.
|
||||||
|
private fun pickNonMetered(networks: Map<Network, NetworkInfo>): Network? {
|
||||||
|
for ((network, info) in networks) {
|
||||||
|
if (info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)) {
|
||||||
|
return network
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return networks.keys.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickDefaultNetwork returns a non-VPN network to use as the 'default'
|
||||||
|
// network; one that is used as a gateway to the internet and from which we
|
||||||
|
// obtain our DNS servers.
|
||||||
|
private fun pickDefaultNetwork(): Network? {
|
||||||
|
// Filter the list of all networks to those that have the INTERNET
|
||||||
|
// capability, are not VPNs, and have a non-zero number of DNS servers
|
||||||
|
// available.
|
||||||
|
val networks =
|
||||||
|
activeNetworks.filter { (_, info) ->
|
||||||
|
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||||
|
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) &&
|
||||||
|
info.linkProps.dnsServers.isNotEmpty() == true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have one; just return it; otherwise, prefer networks that are also
|
||||||
|
// not metered (i.e. cell modems).
|
||||||
|
val nonMeteredNetwork = pickNonMetered(networks)
|
||||||
|
if (nonMeteredNetwork != null) {
|
||||||
|
return nonMeteredNetwork
|
||||||
|
}
|
||||||
|
|
||||||
|
// Okay, less good; just return the first network that has the INTERNET and
|
||||||
|
// NOT_VPN capabilities; even though this interface doesn't have any DNS
|
||||||
|
// servers set, we'll use our DNS fallback servers to make queries. It's
|
||||||
|
// strictly better to return an interface + use the DNS fallback servers
|
||||||
|
// than to return nothing and not be able to route traffic.
|
||||||
|
for ((network, info) in activeNetworks) {
|
||||||
|
if (info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||||
|
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"no networks available that also have DNS servers set; falling back to first network ${network}")
|
||||||
|
return network
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, return nothing; we don't want to return a VPN network since
|
||||||
|
// it could result in a routing loop, and a non-INTERNET network isn't
|
||||||
|
// helpful.
|
||||||
|
Log.w(TAG, "no networks available to pick a default network")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeUpdateDNSConfig will maybe update our DNS configuration based on the
|
||||||
|
// current set of active Networks.
|
||||||
|
private fun maybeUpdateDNSConfig(why: String, dns: DnsConfig) {
|
||||||
|
val defaultNetwork = pickDefaultNetwork()
|
||||||
|
if (defaultNetwork == null) {
|
||||||
|
TSLog.d(TAG, "${why}: no default network available; not updating DNS config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val info = activeNetworks[defaultNetwork]
|
||||||
|
if (info == null) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"${why}: [unexpected] no info available for default network; not updating DNS config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val sb = StringBuilder()
|
||||||
|
for (ip in info.linkProps.dnsServers) {
|
||||||
|
sb.append(ip.hostAddress).append(" ")
|
||||||
|
}
|
||||||
|
val searchDomains: String? = info.linkProps.domains
|
||||||
|
if (searchDomains != null) {
|
||||||
|
sb.append("\n")
|
||||||
|
sb.append(searchDomains)
|
||||||
|
}
|
||||||
|
if (dns.updateDNSFromNetwork(sb.toString())) {
|
||||||
|
TSLog.d(
|
||||||
|
TAG,
|
||||||
|
"${why}: updated DNS config for network ${defaultNetwork} (${info.linkProps.interfaceName})")
|
||||||
|
Libtailscale.onDNSConfigChanged(info.linkProps.interfaceName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,28 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn;
|
|
||||||
|
|
||||||
import android.app.Fragment;
|
|
||||||
import android.content.Intent;
|
|
||||||
|
|
||||||
public class Peer extends Fragment {
|
|
||||||
|
|
||||||
private static int resultOK = -1;
|
|
||||||
|
|
||||||
public class RequestCodes {
|
|
||||||
public static final int requestPrepareVPN = 1001;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
|
||||||
if (requestCode == RequestCodes.requestPrepareVPN) {
|
|
||||||
if (resultCode == resultOK) {
|
|
||||||
App.getApplication().startVPN();
|
|
||||||
} else {
|
|
||||||
App.getApplication().setWantRunning(false);
|
|
||||||
// notify VPN revoked
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,112 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
package com.tailscale.ipn
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.Data
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import com.tailscale.ipn.UninitializedApp.Companion.STATUS_CHANNEL_ID
|
||||||
|
import com.tailscale.ipn.UninitializedApp.Companion.STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID
|
||||||
|
import com.tailscale.ipn.ui.localapi.Client
|
||||||
|
import com.tailscale.ipn.ui.model.Ipn
|
||||||
|
import com.tailscale.ipn.ui.notifier.Notifier
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
|
||||||
|
class UseExitNodeWorker(
|
||||||
|
appContext: Context,
|
||||||
|
workerParams: WorkerParameters
|
||||||
|
) : CoroutineWorker(appContext, workerParams) {
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val app = UninitializedApp.get()
|
||||||
|
suspend fun runAndGetResult(): String? {
|
||||||
|
val exitNodeName = inputData.getString(EXIT_NODE_NAME)
|
||||||
|
|
||||||
|
val exitNodeId = if (exitNodeName.isNullOrEmpty()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
if (!app.isAbleToStartVPN()) {
|
||||||
|
return app.getString(R.string.vpn_is_not_ready_to_start)
|
||||||
|
}
|
||||||
|
|
||||||
|
val peers =
|
||||||
|
(Notifier.netmap.value
|
||||||
|
?: run { return@runAndGetResult app.getString(R.string.tailscale_is_not_setup) })
|
||||||
|
.Peers ?: run { return@runAndGetResult app.getString(R.string.no_peers_found) }
|
||||||
|
|
||||||
|
val filteredPeers = peers.filter {
|
||||||
|
it.displayName == exitNodeName
|
||||||
|
}.toList()
|
||||||
|
|
||||||
|
if (filteredPeers.isEmpty()) {
|
||||||
|
return app.getString(R.string.no_peers_with_name_found, exitNodeName)
|
||||||
|
} else if (filteredPeers.size > 1) {
|
||||||
|
return app.getString(R.string.multiple_peers_with_name_found, exitNodeName)
|
||||||
|
} else if (!filteredPeers[0].isExitNode) {
|
||||||
|
return app.getString(
|
||||||
|
R.string.peer_with_name_is_not_an_exit_node,
|
||||||
|
exitNodeName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredPeers[0].StableID
|
||||||
|
}
|
||||||
|
|
||||||
|
val allowLanAccess = inputData.getBoolean(ALLOW_LAN_ACCESS, false)
|
||||||
|
val prefsOut = Ipn.MaskedPrefs()
|
||||||
|
prefsOut.ExitNodeID = exitNodeId
|
||||||
|
prefsOut.ExitNodeAllowLANAccess = allowLanAccess
|
||||||
|
|
||||||
|
val scope = CoroutineScope(Dispatchers.Default + Job())
|
||||||
|
var result: String? = null
|
||||||
|
Client(scope).editPrefs(prefsOut) {
|
||||||
|
result = if (it.isFailure) {
|
||||||
|
it.exceptionOrNull()?.message
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.coroutineContext[Job]?.join()
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = runAndGetResult()
|
||||||
|
|
||||||
|
return if (result != null) {
|
||||||
|
val intent =
|
||||||
|
Intent(app, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
|
}
|
||||||
|
val pendingIntent: PendingIntent =
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
app, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(app, STATUS_CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(app.getString(R.string.use_exit_node_intent_failed))
|
||||||
|
.setContentText(result)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
app.notifyStatus(notification)
|
||||||
|
|
||||||
|
Result.failure(Data.Builder().putString(ERROR_KEY, result).build())
|
||||||
|
} else {
|
||||||
|
Result.success()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXIT_NODE_NAME = "EXIT_NODE_NAME"
|
||||||
|
const val ALLOW_LAN_ACCESS = "ALLOW_LAN_ACCESS"
|
||||||
|
const val ERROR_KEY = "error"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,85 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.model
|
||||||
|
|
||||||
|
import androidx.compose.material3.ListItemColors
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.tailscale.ipn.ui.theme.warning
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
class Health {
|
||||||
|
@Serializable
|
||||||
|
data class State(
|
||||||
|
// WarnableCode -> UnhealthyState or null
|
||||||
|
var Warnings: Map<String, UnhealthyState?>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UnhealthyState(
|
||||||
|
var WarnableCode: String,
|
||||||
|
var Severity: Severity,
|
||||||
|
var Title: String,
|
||||||
|
var Text: String,
|
||||||
|
var BrokenSince: String? = null,
|
||||||
|
var Args: Map<String, String>? = null,
|
||||||
|
var ImpactsConnectivity: Boolean? = false,
|
||||||
|
var DependsOn: List<String>? = null, // an array of WarnableCodes this depends on
|
||||||
|
) : Comparable<UnhealthyState> {
|
||||||
|
fun hiddenByDependencies(currentWarnableCodes: Set<String>): Boolean {
|
||||||
|
return this.DependsOn?.let {
|
||||||
|
it.any { depWarnableCode -> currentWarnableCodes.contains(depWarnableCode) }
|
||||||
|
} == true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun compareTo(other: UnhealthyState): Int {
|
||||||
|
// Compare by severity first
|
||||||
|
val severityComparison = Severity.compareTo(other.Severity)
|
||||||
|
if (severityComparison != 0) {
|
||||||
|
return severityComparison
|
||||||
|
}
|
||||||
|
|
||||||
|
// If severities are equal, compare by warnableCode
|
||||||
|
return WarnableCode.compareTo(other.WarnableCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class Severity : Comparable<Severity> {
|
||||||
|
low,
|
||||||
|
medium,
|
||||||
|
high;
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun listItemColors(): ListItemColors {
|
||||||
|
val default = ListItemDefaults.colors()
|
||||||
|
return when (this) {
|
||||||
|
Severity.low ->
|
||||||
|
ListItemColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
headlineColor = MaterialTheme.colorScheme.secondary,
|
||||||
|
leadingIconColor = MaterialTheme.colorScheme.secondary,
|
||||||
|
overlineColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.8f),
|
||||||
|
supportingTextColor = MaterialTheme.colorScheme.secondary,
|
||||||
|
trailingIconColor = MaterialTheme.colorScheme.secondary,
|
||||||
|
disabledHeadlineColor = default.disabledHeadlineColor,
|
||||||
|
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
||||||
|
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
||||||
|
Severity.medium,
|
||||||
|
Severity.high ->
|
||||||
|
ListItemColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.warning,
|
||||||
|
headlineColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f),
|
||||||
|
supportingTextColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
disabledHeadlineColor = default.disabledHeadlineColor,
|
||||||
|
disabledLeadingIconColor = default.disabledLeadingIconColor,
|
||||||
|
disabledTrailingIconColor = default.disabledTrailingIconColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,140 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.notifier
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.tailscale.ipn.App
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.UninitializedApp.Companion.notificationManager
|
||||||
|
import com.tailscale.ipn.ui.model.Health
|
||||||
|
import com.tailscale.ipn.ui.model.Health.UnhealthyState
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import com.tailscale.ipn.util.TSLog
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
class HealthNotifier(
|
||||||
|
healthStateFlow: StateFlow<Health.State?>,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val HEALTH_CHANNEL_ID = "tailscale-health"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val TAG = "Health"
|
||||||
|
private val ignoredWarnableCodes: Set<String> =
|
||||||
|
setOf(
|
||||||
|
// Ignored on Android because installing unstable takes quite some effort
|
||||||
|
"is-using-unstable-version",
|
||||||
|
|
||||||
|
// Ignored on Android because we already have a dedicated connected/not connected
|
||||||
|
// notification
|
||||||
|
"wantrunning-false")
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
healthStateFlow
|
||||||
|
.distinctUntilChanged { old, new -> old?.Warnings?.count() == new?.Warnings?.count() }
|
||||||
|
.debounce(5000)
|
||||||
|
.collect { health ->
|
||||||
|
TSLog.d(TAG, "Health updated: ${health?.Warnings?.keys?.sorted()}")
|
||||||
|
health?.Warnings?.let {
|
||||||
|
notifyHealthUpdated(it.values.mapNotNull { it }.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentWarnings: StateFlow<Set<UnhealthyState>> = MutableStateFlow(setOf())
|
||||||
|
val currentIcon: StateFlow<Int?> = MutableStateFlow(null)
|
||||||
|
|
||||||
|
private fun notifyHealthUpdated(warnings: Array<UnhealthyState>) {
|
||||||
|
val warningsBeforeAdd = currentWarnings.value
|
||||||
|
val currentWarnableCodes = warnings.map { it.WarnableCode }.toSet()
|
||||||
|
val addedWarnings: MutableSet<UnhealthyState> = mutableSetOf()
|
||||||
|
val isWarmingUp = warnings.any { it.WarnableCode == "warming-up" }
|
||||||
|
|
||||||
|
for (warning in warnings) {
|
||||||
|
if (ignoredWarnableCodes.contains(warning.WarnableCode)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
addedWarnings.add(warning)
|
||||||
|
|
||||||
|
if (this.currentWarnings.value.contains(warning)) {
|
||||||
|
// Already notified, skip
|
||||||
|
continue
|
||||||
|
} else if (warning.hiddenByDependencies(currentWarnableCodes)) {
|
||||||
|
// Ignore this warning because a dependency is also unhealthy
|
||||||
|
TSLog.d(TAG, "Ignoring ${warning.WarnableCode} because of dependency")
|
||||||
|
continue
|
||||||
|
} else if (!isWarmingUp) {
|
||||||
|
TSLog.d(TAG, "Adding health warning: ${warning.WarnableCode}")
|
||||||
|
this.currentWarnings.set(this.currentWarnings.value + warning)
|
||||||
|
if (warning.Severity == Health.Severity.high) {
|
||||||
|
this.sendNotification(warning.Title, warning.Text, warning.WarnableCode)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TSLog.d(TAG, "Ignoring ${warning.WarnableCode} because warming up")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val warningsToDrop = warningsBeforeAdd.minus(addedWarnings)
|
||||||
|
if (warningsToDrop.isNotEmpty()) {
|
||||||
|
TSLog.d(TAG, "Dropping health warnings with codes $warningsToDrop")
|
||||||
|
this.removeNotifications(warningsToDrop)
|
||||||
|
}
|
||||||
|
currentWarnings.set(this.currentWarnings.value.subtract(warningsToDrop))
|
||||||
|
this.updateIcon()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateIcon() {
|
||||||
|
if (currentWarnings.value.isEmpty()) {
|
||||||
|
this.currentIcon.set(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentWarnings.value.any {
|
||||||
|
(it.Severity == Health.Severity.high || it.ImpactsConnectivity == true)
|
||||||
|
}) {
|
||||||
|
this.currentIcon.set(R.drawable.warning_rounded)
|
||||||
|
} else {
|
||||||
|
this.currentIcon.set(R.drawable.info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendNotification(title: String, text: String, code: String) {
|
||||||
|
TSLog.d(TAG, "Sending notification for $code")
|
||||||
|
val notification =
|
||||||
|
NotificationCompat.Builder(App.get().applicationContext, HEALTH_CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.build()
|
||||||
|
if (ActivityCompat.checkSelfPermission(
|
||||||
|
App.get().applicationContext, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||||
|
PackageManager.PERMISSION_GRANTED) {
|
||||||
|
TSLog.d(TAG, "Notification permission not granted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notificationManager.notify(code.hashCode(), notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeNotifications(warnings: Set<UnhealthyState>) {
|
||||||
|
TSLog.d(TAG, "Removing notifications for $warnings")
|
||||||
|
for (warning in warnings) {
|
||||||
|
notificationManager.cancel(warning.WarnableCode.hashCode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
import com.tailscale.ipn.ui.model.Ipn
|
||||||
|
|
||||||
|
class AdvertisedRoutesHelper {
|
||||||
|
companion object {
|
||||||
|
fun exitNodeOnFromPrefs(prefs: Ipn.Prefs): Boolean {
|
||||||
|
var v4 = false
|
||||||
|
var v6 = false
|
||||||
|
prefs.AdvertiseRoutes?.forEach {
|
||||||
|
if (it == "0.0.0.0/0") {
|
||||||
|
v4 = true
|
||||||
|
}
|
||||||
|
if (it == "::/0") {
|
||||||
|
v6 = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v4 && v6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,53 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.theme.on
|
||||||
|
|
||||||
|
sealed class ConnectionMode {
|
||||||
|
class NotConnected : ConnectionMode()
|
||||||
|
|
||||||
|
class Derp(val relayName: String) : ConnectionMode()
|
||||||
|
|
||||||
|
class Direct : ConnectionMode()
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun titleString(): String {
|
||||||
|
return when (this) {
|
||||||
|
is NotConnected -> stringResource(id = R.string.not_connected)
|
||||||
|
is Derp -> stringResource(R.string.relayed_connection, relayName)
|
||||||
|
is Direct -> stringResource(R.string.direct_connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun contentKey(): String {
|
||||||
|
return when (this) {
|
||||||
|
is NotConnected -> "NotConnected"
|
||||||
|
is Derp -> "Derp($relayName)"
|
||||||
|
is Direct -> "Direct"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun iconDrawable(): Int {
|
||||||
|
return when (this) {
|
||||||
|
is NotConnected -> R.drawable.xmark_circle
|
||||||
|
is Derp -> R.drawable.link_off
|
||||||
|
is Direct -> R.drawable.link
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun color(): Color {
|
||||||
|
return when (this) {
|
||||||
|
is NotConnected -> MaterialTheme.colorScheme.onPrimary
|
||||||
|
is Derp -> MaterialTheme.colorScheme.error
|
||||||
|
is Direct -> MaterialTheme.colorScheme.on
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,34 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
|
||||||
|
data class InstalledApp(val name: String, val packageName: String)
|
||||||
|
|
||||||
|
class InstalledAppsManager(
|
||||||
|
val packageManager: PackageManager,
|
||||||
|
) {
|
||||||
|
fun fetchInstalledApps(): List<InstalledApp> {
|
||||||
|
return packageManager
|
||||||
|
.getInstalledApplications(PackageManager.GET_META_DATA)
|
||||||
|
.filter(appIsIncluded)
|
||||||
|
.map {
|
||||||
|
InstalledApp(
|
||||||
|
name = it.loadLabel(packageManager).toString(),
|
||||||
|
packageName = it.packageName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sortedBy { it.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val appIsIncluded: (ApplicationInfo) -> Boolean = { app ->
|
||||||
|
app.packageName != "com.tailscale.ipn" &&
|
||||||
|
// Only show apps that can access the Internet
|
||||||
|
packageManager.checkPermission(Manifest.permission.INTERNET, app.packageName) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,157 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.theme.listItem
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import com.tailscale.ipn.ui.viewModel.LoginWithAuthKeyViewModel
|
||||||
|
import com.tailscale.ipn.ui.viewModel.LoginWithCustomControlURLViewModel
|
||||||
|
|
||||||
|
data class LoginViewStrings(
|
||||||
|
var title: String,
|
||||||
|
var explanation: String,
|
||||||
|
var inputTitle: String,
|
||||||
|
var placeholder: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginWithCustomControlURLView(
|
||||||
|
onNavigateHome: BackNavigation,
|
||||||
|
backToSettings: BackNavigation,
|
||||||
|
viewModel: LoginWithCustomControlURLViewModel = LoginWithCustomControlURLViewModel()
|
||||||
|
) {
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
Header(
|
||||||
|
R.string.add_account,
|
||||||
|
onBack = backToSettings,
|
||||||
|
)
|
||||||
|
}) { innerPadding ->
|
||||||
|
val error by viewModel.errorDialog.collectAsState()
|
||||||
|
val strings =
|
||||||
|
LoginViewStrings(
|
||||||
|
title = stringResource(id = R.string.custom_control_menu),
|
||||||
|
explanation = stringResource(id = R.string.custom_control_menu_desc),
|
||||||
|
inputTitle = stringResource(id = R.string.custom_control_url_title),
|
||||||
|
placeholder = stringResource(id = R.string.custom_control_placeholder),
|
||||||
|
)
|
||||||
|
|
||||||
|
error?.let { ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) }) }
|
||||||
|
|
||||||
|
LoginView(
|
||||||
|
innerPadding = innerPadding,
|
||||||
|
strings = strings,
|
||||||
|
onSubmitAction = { viewModel.setControlURL(it, onNavigateHome) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginWithAuthKeyView(
|
||||||
|
onNavigateHome: BackNavigation,
|
||||||
|
backToSettings: BackNavigation,
|
||||||
|
viewModel: LoginWithAuthKeyViewModel = LoginWithAuthKeyViewModel()
|
||||||
|
) {
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
Header(
|
||||||
|
R.string.add_account,
|
||||||
|
onBack = backToSettings,
|
||||||
|
)
|
||||||
|
}) { innerPadding ->
|
||||||
|
val error by viewModel.errorDialog.collectAsState()
|
||||||
|
val strings =
|
||||||
|
LoginViewStrings(
|
||||||
|
title = stringResource(id = R.string.auth_key_title),
|
||||||
|
explanation = stringResource(id = R.string.auth_key_explanation),
|
||||||
|
inputTitle = stringResource(id = R.string.auth_key_input_title),
|
||||||
|
placeholder = stringResource(id = R.string.auth_key_placeholder),
|
||||||
|
)
|
||||||
|
// Show the error overlay if need be
|
||||||
|
error?.let { ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) }) }
|
||||||
|
|
||||||
|
LoginView(
|
||||||
|
innerPadding = innerPadding,
|
||||||
|
strings = strings,
|
||||||
|
onSubmitAction = { viewModel.setAuthKey(it, onNavigateHome) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginView(
|
||||||
|
innerPadding: PaddingValues = PaddingValues(16.dp),
|
||||||
|
strings: LoginViewStrings,
|
||||||
|
onSubmitAction: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
|
||||||
|
var textVal by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier.padding(innerPadding)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)) {
|
||||||
|
ListItem(
|
||||||
|
colors = MaterialTheme.colorScheme.listItem,
|
||||||
|
headlineContent = { Text(text = strings.title) },
|
||||||
|
supportingContent = { Text(text = strings.explanation) })
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
colors = MaterialTheme.colorScheme.listItem,
|
||||||
|
headlineContent = { Text(text = strings.inputTitle) },
|
||||||
|
supportingContent = {
|
||||||
|
OutlinedTextField(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedContainerColor = Color.Transparent),
|
||||||
|
textStyle = MaterialTheme.typography.bodyMedium,
|
||||||
|
value = textVal,
|
||||||
|
onValueChange = { textVal = it },
|
||||||
|
placeholder = {
|
||||||
|
Text(strings.placeholder, style = MaterialTheme.typography.bodySmall)
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
colors = MaterialTheme.colorScheme.listItem,
|
||||||
|
headlineContent = {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Button(
|
||||||
|
onClick = { onSubmitAction(textVal) },
|
||||||
|
content = { Text(stringResource(id = R.string.add_account_short)) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,99 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.model.Health
|
||||||
|
import com.tailscale.ipn.ui.theme.success
|
||||||
|
import com.tailscale.ipn.ui.viewModel.HealthViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HealthView(backToSettings: BackNavigation, model: HealthViewModel = viewModel()) {
|
||||||
|
val warnings by model.warnings.collectAsState()
|
||||||
|
|
||||||
|
Scaffold(topBar = { Header(titleRes = R.string.health_warnings, onBack = backToSettings) }) {
|
||||||
|
innerPadding ->
|
||||||
|
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||||
|
if (warnings.isEmpty()) {
|
||||||
|
item("allGood") {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.Top),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.check_circle),
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
contentDescription = "A green checkmark",
|
||||||
|
tint = MaterialTheme.colorScheme.success)
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement =
|
||||||
|
Arrangement.spacedBy(2.dp, alignment = Alignment.CenterVertically),
|
||||||
|
modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.no_issues_found),
|
||||||
|
fontSize = MaterialTheme.typography.titleMedium.fontSize,
|
||||||
|
fontWeight = MaterialTheme.typography.titleMedium.fontWeight)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.tailscale_is_operating_normally),
|
||||||
|
color = MaterialTheme.colorScheme.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(warnings) { HealthWarningView(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HealthWarningView(warning: Health.UnhealthyState) {
|
||||||
|
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainerLow)) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
|
||||||
|
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
|
||||||
|
.fillMaxWidth()) {
|
||||||
|
ListItem(
|
||||||
|
colors = warning.Severity.listItemColors(),
|
||||||
|
headlineContent = {
|
||||||
|
if (warning.Title.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
warning.Title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(warning.Text, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,56 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MullvadInfoView(nav: ExitNodePickerNav) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
Header(R.string.choose_mullvad_exit_node, onBack = nav.onNavigateBackToExitNodes)
|
||||||
|
}) { innerPadding ->
|
||||||
|
LazyColumn(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 48.dp),
|
||||||
|
modifier = Modifier.padding(innerPadding)) {
|
||||||
|
item {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.mullvad_logo),
|
||||||
|
contentDescription = stringResource(R.string.the_mullvad_vpn_logo))
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.mullvad_info_title),
|
||||||
|
fontFamily = MaterialTheme.typography.titleLarge.fontFamily,
|
||||||
|
fontSize = MaterialTheme.typography.titleLarge.fontSize,
|
||||||
|
fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.mullvad_info_explainer),
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,111 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.tailscale.ipn.App
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.util.Lists
|
||||||
|
import com.tailscale.ipn.ui.viewModel.SplitTunnelAppPickerViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SplitTunnelAppPickerView(
|
||||||
|
backToSettings: BackNavigation,
|
||||||
|
model: SplitTunnelAppPickerViewModel = viewModel()
|
||||||
|
) {
|
||||||
|
val installedApps by model.installedApps.collectAsState()
|
||||||
|
val excludedPackageNames by model.excludedPackageNames.collectAsState()
|
||||||
|
val builtInDisallowedPackageNames: List<String> = App.get().builtInDisallowedPackageNames
|
||||||
|
val mdmIncludedPackages by model.mdmIncludedPackages.collectAsState()
|
||||||
|
val mdmExcludedPackages by model.mdmExcludedPackages.collectAsState()
|
||||||
|
|
||||||
|
Scaffold(topBar = { Header(titleRes = R.string.split_tunneling, onBack = backToSettings) }) {
|
||||||
|
innerPadding ->
|
||||||
|
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||||
|
item(key = "header") {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string
|
||||||
|
.selected_apps_will_access_the_internet_directly_without_using_tailscale))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (mdmExcludedPackages.value?.isNotEmpty() == true) {
|
||||||
|
item("mdmExcludedNotice") {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(stringResource(R.string.certain_apps_are_not_routed_via_tailscale))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (mdmIncludedPackages.value?.isNotEmpty() == true) {
|
||||||
|
item("mdmIncludedNotice") {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(stringResource(R.string.only_specific_apps_are_routed_via_tailscale))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
item("resolversHeader") {
|
||||||
|
Lists.SectionDivider(
|
||||||
|
stringResource(R.string.count_excluded_apps, excludedPackageNames.count()))
|
||||||
|
}
|
||||||
|
items(installedApps) { app ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(app.name, fontWeight = FontWeight.SemiBold) },
|
||||||
|
leadingContent = {
|
||||||
|
Image(
|
||||||
|
bitmap =
|
||||||
|
model.installedAppsManager.packageManager
|
||||||
|
.getApplicationIcon(app.packageName)
|
||||||
|
.toBitmap()
|
||||||
|
.asImageBitmap(),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.width(40.dp).height(40.dp))
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
app.packageName,
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||||
|
letterSpacing = MaterialTheme.typography.bodySmall.letterSpacing)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Checkbox(
|
||||||
|
checked = excludedPackageNames.contains(app.packageName),
|
||||||
|
enabled = !builtInDisallowedPackageNames.contains(app.packageName),
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
if (checked) {
|
||||||
|
model.exclude(packageName = app.packageName)
|
||||||
|
} else {
|
||||||
|
model.unexclude(packageName = app.packageName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
Lists.ItemDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,54 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.viewModel
|
||||||
|
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import com.tailscale.ipn.ui.view.ErrorDialogType
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
const val AUTH_KEY_LENGTH = 16
|
||||||
|
|
||||||
|
open class CustomLoginViewModel : IpnViewModel() {
|
||||||
|
val errorDialog: StateFlow<ErrorDialogType?> = MutableStateFlow(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoginWithAuthKeyViewModel : CustomLoginViewModel() {
|
||||||
|
// Sets the auth key and invokes the login flow
|
||||||
|
fun setAuthKey(authKey: String, onSuccess: () -> Unit) {
|
||||||
|
// The most basic of checks for auth key syntax
|
||||||
|
if (authKey.isEmpty()) {
|
||||||
|
errorDialog.set(ErrorDialogType.INVALID_AUTH_KEY)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loginWithAuthKey(authKey) {
|
||||||
|
it.onFailure { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) }
|
||||||
|
it.onSuccess { onSuccess() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoginWithCustomControlURLViewModel : CustomLoginViewModel() {
|
||||||
|
// Sets the custom control URL and invokes the login flow
|
||||||
|
fun setControlURL(urlStr: String, onSuccess: () -> Unit) {
|
||||||
|
// Some basic checks that the entered URL is "reasonable". The underlying
|
||||||
|
// localAPIClient will use the default server if we give it a broken URL,
|
||||||
|
// but we can make sure we can construct a URL from the input string and
|
||||||
|
// ensure it has an http/https scheme
|
||||||
|
when (urlStr.startsWith("http", ignoreCase = true) &&
|
||||||
|
urlStr.contains("://") &&
|
||||||
|
urlStr.length > 7) {
|
||||||
|
false -> {
|
||||||
|
errorDialog.set(ErrorDialogType.INVALID_CUSTOM_URL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
true -> {
|
||||||
|
loginWithCustomControlURL(urlStr) {
|
||||||
|
it.onFailure { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) }
|
||||||
|
it.onSuccess { onSuccess() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.viewModel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.tailscale.ipn.App
|
||||||
|
import com.tailscale.ipn.ui.model.Health
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class HealthViewModel : ViewModel() {
|
||||||
|
val warnings: StateFlow<List<Health.UnhealthyState>> = MutableStateFlow(listOf())
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
App.get().healthNotifier?.currentWarnings?.collect { set -> warnings.set(set.sorted()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,131 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.viewModel
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.CountDownTimer
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.tailscale.ipn.App
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.localapi.Client
|
||||||
|
import com.tailscale.ipn.ui.model.Tailcfg
|
||||||
|
import com.tailscale.ipn.ui.util.ConnectionMode
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import com.tailscale.ipn.ui.view.roundedString
|
||||||
|
import com.tailscale.ipn.util.TSLog
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
class PingViewModelFactory(private val peer: Tailcfg.Node) : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
return PingViewModel() as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PingViewModel : ViewModel() {
|
||||||
|
private val TAG = PingViewModel::class.simpleName
|
||||||
|
|
||||||
|
// The timer ticks every second, for a maximum of 10 seconds, hence triggering 10 ping
|
||||||
|
// requests.
|
||||||
|
private val timer =
|
||||||
|
object : CountDownTimer(1000 * 10, 1000) {
|
||||||
|
override fun onTick(millisUntilFinished: Long) {
|
||||||
|
sendPing()
|
||||||
|
fetchStatusAndUpdateConnectionMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFinish() {
|
||||||
|
TSLog.d(TAG, "Ping timer terminated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The peer to ping.
|
||||||
|
var peer: StateFlow<Tailcfg.Node?> = MutableStateFlow(null)
|
||||||
|
// Whether we are using a relayed or direct connection. Will be NotConnected until the first
|
||||||
|
// PeerStatus value has been fetched. NotConnected is not surfaced to the user.
|
||||||
|
val connectionMode: StateFlow<ConnectionMode> = MutableStateFlow(ConnectionMode.NotConnected())
|
||||||
|
// An error message to display if any request fails. Non-null if an error message must be surfaced
|
||||||
|
// to the user. If a subsequent request succeeds, this property should be set to null again.
|
||||||
|
val errorMessage: StateFlow<String?> = MutableStateFlow(null)
|
||||||
|
// The last latency value in a human-readable format (e.g. "14.5 ms").
|
||||||
|
val lastLatencyValue: StateFlow<String> = MutableStateFlow("")
|
||||||
|
// A list of latency values over time in milliseconds. These are used to plot the latency
|
||||||
|
// values in the chart.
|
||||||
|
var latencyValues: StateFlow<List<Double>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
|
fun startPing(peer: Tailcfg.Node) {
|
||||||
|
this.peer.set(peer)
|
||||||
|
timer.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleDismissal() {
|
||||||
|
timer.cancel()
|
||||||
|
this.peer.set(null)
|
||||||
|
this.connectionMode.set(ConnectionMode.NotConnected())
|
||||||
|
this.lastLatencyValue.set("")
|
||||||
|
this.latencyValues.set(emptyList())
|
||||||
|
this.errorMessage.set(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendPing asks the backend to send one ping to the peer and handles the response.
|
||||||
|
// It checks for any errors in the response Err field. If an error is present, it sets the
|
||||||
|
// errorMessage property to a non-null value and returns. If there is no error, it updates the
|
||||||
|
// lastLatencyValue property with the formatted latency, and adds the latency value to the
|
||||||
|
// latencyValues list.
|
||||||
|
private fun sendPing() {
|
||||||
|
peer.value?.let { peer ->
|
||||||
|
Client(viewModelScope).ping(peer) { response ->
|
||||||
|
response.onSuccess { pingResult ->
|
||||||
|
val error = pingResult.Err
|
||||||
|
if (error.isNotEmpty()) {
|
||||||
|
this.errorMessage.set(error.replaceFirstChar { it.uppercase() })
|
||||||
|
return@onSuccess
|
||||||
|
} else {
|
||||||
|
this.errorMessage.set(null)
|
||||||
|
val latency: Double = pingResult.LatencySeconds * 1000
|
||||||
|
this.lastLatencyValue.set("${latency.roundedString(1)} ms")
|
||||||
|
this.latencyValues.set(this.latencyValues.value + latency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.onFailure { error ->
|
||||||
|
val context: Context = App.get().applicationContext
|
||||||
|
val stringError = error.toString()
|
||||||
|
TSLog.d(TAG, "Ping request failed: $stringError")
|
||||||
|
if (stringError.contains("timeout")) {
|
||||||
|
this.errorMessage.set(
|
||||||
|
context.getString(
|
||||||
|
R.string.request_timed_out_make_sure_that_is_online, peer.ComputedName))
|
||||||
|
} else {
|
||||||
|
this.errorMessage.set(
|
||||||
|
context.getString(R.string.an_unknown_error_occurred_please_try_again))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchStatusAndUpdateConnectionMode fetches the PeerStatus for the peer and updates the
|
||||||
|
// connectionMode property as soon as a direct connection is finally established.
|
||||||
|
private fun fetchStatusAndUpdateConnectionMode() {
|
||||||
|
Client(viewModelScope).status { statusResult ->
|
||||||
|
statusResult.onSuccess { result ->
|
||||||
|
result.Peer?.let { map ->
|
||||||
|
map[peer.value?.Key]?.let { peerStatus ->
|
||||||
|
val curAddr = peerStatus.CurAddr.orEmpty()
|
||||||
|
val relay = peerStatus.Relay.orEmpty()
|
||||||
|
if (curAddr.isNotEmpty()) {
|
||||||
|
this.connectionMode.set(ConnectionMode.Direct())
|
||||||
|
} else if (relay.isNotEmpty()) {
|
||||||
|
this.connectionMode.set(ConnectionMode.Derp(relayName = relay.uppercase()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statusResult.onFailure { TSLog.d(TAG, "Failed to fetch status: $it") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,97 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.viewModel
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.ViewModelProvider
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.tailscale.ipn.ui.localapi.Client
|
|
||||||
import com.tailscale.ipn.ui.model.Ipn
|
|
||||||
import com.tailscale.ipn.ui.notifier.Notifier
|
|
||||||
import com.tailscale.ipn.ui.util.LoadingIndicator
|
|
||||||
import com.tailscale.ipn.ui.util.set
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class RunExitNodeViewModelFactory() : ViewModelProvider.Factory {
|
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
|
||||||
return RunExitNodeViewModel() as T
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AdvertisedRoutesHelper() {
|
|
||||||
companion object {
|
|
||||||
fun exitNodeOnFromPrefs(prefs: Ipn.Prefs): Boolean {
|
|
||||||
var v4 = false
|
|
||||||
var v6 = false
|
|
||||||
prefs.AdvertiseRoutes?.forEach {
|
|
||||||
if (it == "0.0.0.0/0") {
|
|
||||||
v4 = true
|
|
||||||
}
|
|
||||||
if (it == "::/0") {
|
|
||||||
v6 = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return v4 && v6
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RunExitNodeViewModel() : IpnViewModel() {
|
|
||||||
|
|
||||||
val isRunningExitNode: StateFlow<Boolean> = MutableStateFlow(false)
|
|
||||||
var lastPrefs: Ipn.Prefs? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
viewModelScope.launch {
|
|
||||||
Notifier.prefs.stateIn(viewModelScope).collect { prefs ->
|
|
||||||
Log.d("RunExitNode", "prefs: AdvertiseRoutes=" + prefs?.AdvertiseRoutes.toString())
|
|
||||||
prefs?.let {
|
|
||||||
lastPrefs = it
|
|
||||||
isRunningExitNode.set(AdvertisedRoutesHelper.exitNodeOnFromPrefs(it))
|
|
||||||
} ?: run { isRunningExitNode.set(false) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setRunningExitNode(isOn: Boolean) {
|
|
||||||
LoadingIndicator.start()
|
|
||||||
lastPrefs?.let { currentPrefs ->
|
|
||||||
val newPrefs: Ipn.MaskedPrefs
|
|
||||||
if (isOn) {
|
|
||||||
newPrefs = setZeroRoutes(currentPrefs)
|
|
||||||
} else {
|
|
||||||
newPrefs = removeAllZeroRoutes(currentPrefs)
|
|
||||||
}
|
|
||||||
Client(viewModelScope).editPrefs(newPrefs) { result ->
|
|
||||||
LoadingIndicator.stop()
|
|
||||||
Log.d("RunExitNodeViewModel", "Edited prefs: $result")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setZeroRoutes(prefs: Ipn.Prefs): Ipn.MaskedPrefs {
|
|
||||||
val newRoutes = (removeAllZeroRoutes(prefs).AdvertiseRoutes ?: emptyList()).toMutableList()
|
|
||||||
newRoutes.add("0.0.0.0/0")
|
|
||||||
newRoutes.add("::/0")
|
|
||||||
val newPrefs = Ipn.MaskedPrefs()
|
|
||||||
newPrefs.AdvertiseRoutes = newRoutes
|
|
||||||
return newPrefs
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun removeAllZeroRoutes(prefs: Ipn.Prefs): Ipn.MaskedPrefs {
|
|
||||||
val newRoutes = emptyList<String>().toMutableList()
|
|
||||||
(prefs.AdvertiseRoutes ?: emptyList()).forEach {
|
|
||||||
if (it != "0.0.0.0/0" && it != "::/0") {
|
|
||||||
newRoutes.add(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val newPrefs = Ipn.MaskedPrefs()
|
|
||||||
newPrefs.AdvertiseRoutes = newRoutes
|
|
||||||
return newPrefs
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,44 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.viewModel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.tailscale.ipn.App
|
||||||
|
import com.tailscale.ipn.mdm.MDMSettings
|
||||||
|
import com.tailscale.ipn.mdm.SettingState
|
||||||
|
import com.tailscale.ipn.ui.util.InstalledApp
|
||||||
|
import com.tailscale.ipn.ui.util.InstalledAppsManager
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
class SplitTunnelAppPickerViewModel : ViewModel() {
|
||||||
|
val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager)
|
||||||
|
val excludedPackageNames: StateFlow<List<String>> = MutableStateFlow(listOf())
|
||||||
|
val installedApps: StateFlow<List<InstalledApp>> = MutableStateFlow(listOf())
|
||||||
|
val mdmExcludedPackages: StateFlow<SettingState<String?>> = MDMSettings.excludedPackages.flow
|
||||||
|
val mdmIncludedPackages: StateFlow<SettingState<String?>> = MDMSettings.includedPackages.flow
|
||||||
|
|
||||||
|
init {
|
||||||
|
installedApps.set(installedAppsManager.fetchInstalledApps())
|
||||||
|
excludedPackageNames.set(
|
||||||
|
App.get()
|
||||||
|
.disallowedPackageNames()
|
||||||
|
.intersect(installedApps.value.map { it.packageName }.toSet())
|
||||||
|
.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exclude(packageName: String) {
|
||||||
|
if (excludedPackageNames.value.contains(packageName)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
excludedPackageNames.set(excludedPackageNames.value + packageName)
|
||||||
|
App.get().addUserDisallowedPackageName(packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unexclude(packageName: String) {
|
||||||
|
excludedPackageNames.set(excludedPackageNames.value - packageName)
|
||||||
|
App.get().removeUserDisallowedPackageName(packageName)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,65 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.viewModel
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.net.VpnService
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
class VpnViewModelFactory(private val application: Application) : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
if (modelClass.isAssignableFrom(VpnViewModel::class.java)) {
|
||||||
|
return VpnViewModel(application) as T
|
||||||
|
}
|
||||||
|
throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Application context aware view model that tracks whether the VPN has been prepared. This must be
|
||||||
|
// application scoped because Tailscale might be toggled on and off outside of the activity
|
||||||
|
// lifecycle.
|
||||||
|
class VpnViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
// Whether the VPN is prepared. This is set to true if the VPN application is already prepared, or
|
||||||
|
// if the user has previously consented to the VPN application. This is used to determine whether
|
||||||
|
// a VPN permission launcher needs to be shown.
|
||||||
|
val _vpnPrepared = MutableStateFlow(false)
|
||||||
|
val vpnPrepared: StateFlow<Boolean> = _vpnPrepared
|
||||||
|
// Whether a VPN interface has been established. This is set by net.updateTUN upon
|
||||||
|
// VpnServiceBuilder.establish, and consumed by UI to reflect VPN state.
|
||||||
|
val _vpnActive = MutableStateFlow(false)
|
||||||
|
val vpnActive: StateFlow<Boolean> = _vpnActive
|
||||||
|
val TAG = "VpnViewModel"
|
||||||
|
|
||||||
|
init {
|
||||||
|
prepareVpn()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prepareVpn() {
|
||||||
|
// Check if the user has granted permission yet.
|
||||||
|
if (!vpnPrepared.value) {
|
||||||
|
val vpnIntent = VpnService.prepare(getApplication())
|
||||||
|
if (vpnIntent != null) {
|
||||||
|
setVpnPrepared(false)
|
||||||
|
Log.d(TAG, "VpnService.prepare returned non-null intent")
|
||||||
|
} else {
|
||||||
|
setVpnPrepared(true)
|
||||||
|
Log.d(TAG, "VpnService.prepare returned null intent, VPN is already prepared")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setVpnActive(isActive: Boolean) {
|
||||||
|
_vpnActive.value = isActive
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setVpnPrepared(isPrepared: Boolean) {
|
||||||
|
_vpnPrepared.value = isPrepared
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,44 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
package com.tailscale.ipn.util
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import libtailscale.Libtailscale
|
||||||
|
|
||||||
|
object TSLog {
|
||||||
|
var libtailscaleWrapper = LibtailscaleWrapper()
|
||||||
|
|
||||||
|
fun d(tag: String?, message: String) {
|
||||||
|
Log.d(tag, message)
|
||||||
|
libtailscaleWrapper.sendLog(tag, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun w(tag: String, message: String) {
|
||||||
|
Log.w(tag, message)
|
||||||
|
libtailscaleWrapper.sendLog(tag, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overloaded function without Throwable because Java does not support default parameters
|
||||||
|
@JvmStatic
|
||||||
|
fun e(tag: String?, message: String) {
|
||||||
|
Log.e(tag, message)
|
||||||
|
libtailscaleWrapper.sendLog(tag, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun e(tag: String?, message: String, throwable: Throwable? = null) {
|
||||||
|
if (throwable == null) {
|
||||||
|
Log.e(tag, message)
|
||||||
|
libtailscaleWrapper.sendLog(tag, message)
|
||||||
|
} else {
|
||||||
|
Log.e(tag, message, throwable)
|
||||||
|
libtailscaleWrapper.sendLog(tag, "$message ${throwable?.localizedMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LibtailscaleWrapper {
|
||||||
|
public fun sendLog(tag: String?, message: String) {
|
||||||
|
val logTag = tag ?: ""
|
||||||
|
Libtailscale.sendLog((logTag + ": " + message).toByteArray(Charsets.UTF_8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
Before Width: | Height: | Size: 641 B |
Binary file not shown.
Before Width: | Height: | Size: 406 B |
Binary file not shown.
Before Width: | Height: | Size: 879 B |
Binary file not shown.
Before Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
Before Width: | Height: | Size: 1.8 KiB |
@ -1,41 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="200dp"
|
|
||||||
android:height="200dp"
|
|
||||||
android:viewportWidth="200"
|
|
||||||
android:viewportHeight="200">
|
|
||||||
<path
|
|
||||||
android:pathData="M0,0h200v200h-200z"
|
|
||||||
android:fillColor="#1F1E1E"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M50,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:fillAlpha="0.4"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M87.5,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:fillAlpha="0.4"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M125,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:fillAlpha="0.4"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M50,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
|
||||||
android:fillColor="#ffffff"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M87.5,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
|
||||||
android:fillColor="#ffffff"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M125,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
|
||||||
android:fillColor="#ffffff"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M50,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:fillAlpha="0.4"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M87.5,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
|
||||||
android:fillColor="#ffffff"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M125,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
|
||||||
android:fillColor="#ffffff"
|
|
||||||
android:fillAlpha="0.4"/>
|
|
||||||
</vector>
|
|
@ -1,34 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="200dp"
|
|
||||||
android:height="200dp"
|
|
||||||
android:viewportWidth="200"
|
|
||||||
android:viewportHeight="200">
|
|
||||||
|
|
||||||
<path
|
|
||||||
android:pathData="M50,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
|
||||||
android:fillColor="#cccccc"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M87.5,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
|
||||||
android:fillColor="#cccccc"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M125,62.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
|
||||||
android:fillColor="#cccccc"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M50,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
|
||||||
android:fillColor="#222222"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M87.5,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
|
||||||
android:fillColor="#222222"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M125,100a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
|
||||||
android:fillColor="#222222"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M50,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
|
||||||
android:fillColor="#cccccc"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M87.5,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
|
||||||
android:fillColor="#222222"/>
|
|
||||||
<path
|
|
||||||
android:pathData="M125,137.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
|
||||||
android:fillColor="#cccccc"/>
|
|
||||||
</vector>
|
|
@ -0,0 +1,38 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="100dp"
|
||||||
|
android:height="100dp"
|
||||||
|
android:viewportWidth="100"
|
||||||
|
android:viewportHeight="100">
|
||||||
|
<path
|
||||||
|
android:pathData="M0,12.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:fillAlpha="0.4"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M37.5,12.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:fillAlpha="0.4"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M75,12.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:fillAlpha="0.4"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M-0,50a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||||
|
android:fillColor="#000000"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M37.5,50a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||||
|
android:fillColor="#000000"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M75,50a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||||
|
android:fillColor="#000000"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M-0,87.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:fillAlpha="0.4"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M37.5,87.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||||
|
android:fillColor="#000000"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M75,87.5a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:fillAlpha="0.4"/>
|
||||||
|
</vector>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue