Compare commits
124 Commits
1.63.93-t1
...
main
Author | SHA1 | Date |
---|---|---|
![]() |
840a31d74e | 14 hours ago |
![]() |
b6cacdfd6a | 14 hours ago |
![]() |
d702d2dab8 | 15 hours ago |
![]() |
811641f538 | 6 days ago |
![]() |
9ae30c06bf | 6 days ago |
![]() |
793a83fdc6 | 6 days ago |
![]() |
ea928ca971 | 7 days ago |
![]() |
8dc1a13f77 | 7 days ago |
![]() |
196944d168 | 1 week ago |
![]() |
0ff6be6345 | 1 week ago |
![]() |
634d51c20b | 2 weeks ago |
![]() |
864cc35bd4 | 2 weeks ago |
![]() |
23805e9d00 | 2 weeks ago |
![]() |
5b121c1876 | 2 weeks ago |
![]() |
80864fec12 | 2 weeks ago |
![]() |
ef21753763 | 3 weeks ago |
![]() |
0e82e54ffb | 3 weeks ago |
![]() |
64fca2a712 | 3 weeks ago |
![]() |
a74e30d4e2 | 3 weeks ago |
![]() |
2788cf7ee5 | 3 weeks ago |
![]() |
d7a87e868c | 4 weeks ago |
![]() |
15da8f3797 | 4 weeks ago |
![]() |
8f62f0da79 | 4 weeks ago |
![]() |
cbc47791ad | 4 weeks ago |
![]() |
a6fd8a8093 | 4 weeks ago |
![]() |
0df6c61eee | 4 weeks ago |
![]() |
75db9e64c8 | 1 month ago |
![]() |
e826a173aa | 1 month ago |
![]() |
a05829b3c0 | 1 month ago |
![]() |
72f35cd318 | 1 month ago |
![]() |
4fa86dbf03 | 1 month ago |
![]() |
77c2d924ee | 1 month ago |
![]() |
b37492a547 | 1 month ago |
![]() |
999c6f2357 | 1 month ago |
![]() |
006b1e6852 | 1 month ago |
![]() |
32e29c4efd | 1 month ago |
![]() |
9aa3a840de | 1 month ago |
![]() |
0ff47f7ab5 | 1 month ago |
![]() |
12ad295706 | 1 month ago |
![]() |
d842ccde22 | 1 month ago |
![]() |
cbcc773b98 | 1 month ago |
![]() |
cbc0035dfe | 1 month ago |
![]() |
c47ead9412 | 1 month ago |
![]() |
46cdbb7b9b | 1 month ago |
![]() |
5476288100 | 1 month ago |
![]() |
a3b356a81c | 1 month ago |
![]() |
411d7b2597 | 1 month ago |
![]() |
59a88ffbab | 1 month ago |
![]() |
f684bf696d | 2 months ago |
![]() |
698fb868a7 | 2 months ago |
![]() |
82c17a4d1d | 2 months ago |
![]() |
b615eb38b4 | 2 months ago |
![]() |
24d6cc7a08 | 2 months ago |
![]() |
ec1dc8b0be | 2 months ago |
![]() |
edb3f5b0c5 | 2 months ago |
![]() |
7f66c373ea | 2 months ago |
![]() |
2d7d6e1357 | 2 months ago |
![]() |
45fd2e0661 | 2 months ago |
![]() |
31b0ec8865 | 2 months ago |
![]() |
9703d48f1a | 2 months ago |
![]() |
17ad0c8cc0 | 2 months ago |
![]() |
a2471d38cb | 2 months ago |
![]() |
e6f6d35a99 | 2 months ago |
![]() |
5e3236260f | 2 months ago |
![]() |
d330726ba1 | 2 months ago |
![]() |
0c0853a962 | 2 months ago |
![]() |
3f864b28c7 | 2 months ago |
![]() |
22c129ee1c | 2 months ago |
![]() |
427e2d29b4 | 2 months ago |
![]() |
1c0aef5418 | 2 months ago |
![]() |
39628be8a6 | 2 months ago |
![]() |
9dda2cc470 | 2 months ago |
![]() |
a6bc2244b6 | 2 months ago |
![]() |
24dd83090c | 2 months ago |
![]() |
ad3b6a5a64 | 2 months ago |
![]() |
16fa0e9b9e | 2 months ago |
![]() |
88b0af2c9b | 2 months ago |
![]() |
7119424e32 | 2 months ago |
![]() |
b06342629f | 2 months ago |
![]() |
07d04ca750 | 2 months ago |
![]() |
057e25c23d | 2 months ago |
![]() |
a54ebf75ef | 2 months ago |
![]() |
f4d2a277a5 | 2 months ago |
![]() |
75e2d8983b | 2 months ago |
![]() |
bbb3c86fa8 | 2 months ago |
![]() |
bc8985126d | 2 months ago |
![]() |
eb8d731a04 | 2 months ago |
![]() |
81acaef5b7 | 2 months ago |
![]() |
19177df1e2 | 2 months ago |
![]() |
6197cb9576 | 2 months ago |
![]() |
253c116f9b | 2 months ago |
![]() |
1c3af6713c | 2 months ago |
![]() |
39d1d0b3c3 | 2 months ago |
![]() |
56da7b66d0 | 2 months ago |
![]() |
f95428f7fa | 2 months ago |
![]() |
0c58841350 | 2 months ago |
![]() |
8a7148c085 | 2 months ago |
![]() |
372af99c53 | 2 months ago |
![]() |
a73025b36f | 2 months ago |
![]() |
4d86c1a6f6 | 2 months ago |
![]() |
a1d97baeb0 | 2 months ago |
![]() |
9533db44b7 | 2 months ago |
![]() |
44ac22c29d | 2 months ago |
![]() |
5ad25262ad | 2 months ago |
![]() |
be6364ca95 | 2 months ago |
![]() |
3e32e97261 | 2 months ago |
![]() |
164a243b77 | 2 months ago |
![]() |
a77edc6724 | 3 months ago |
![]() |
d396fdab27 | 3 months ago |
![]() |
0ae9da385e | 3 months ago |
![]() |
9054264363 | 3 months ago |
![]() |
11f52ad96b | 3 months ago |
![]() |
482b350ce0 | 3 months ago |
![]() |
c8d1b30918 | 3 months ago |
![]() |
6a00880f61 | 3 months ago |
![]() |
a3638f9fc7 | 3 months ago |
![]() |
c59c8537cf | 3 months ago |
![]() |
cc244812a6 | 3 months ago |
![]() |
a325a90558 | 3 months ago |
![]() |
f14836a750 | 3 months ago |
![]() |
38f57b4737 | 3 months ago |
![]() |
d676dca4f4 | 3 months ago |
![]() |
32e407d06b | 3 months ago |
![]() |
9bfa839380 | 3 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,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionSha256Sum=38f66cd6eef217b4c35855bb11ea4e9fbc53594ccccb5fb82dfd317ef8c2c5a3
|
distributionSha256Sum=9631d53cf3e74bfa726893aee1f8994fee4e060c401335946dba2156f440f24c
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
@ -0,0 +1,21 @@
|
|||||||
|
# Keep all classes with native methods
|
||||||
|
-keepclasseswithmembernames class * {
|
||||||
|
native <methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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,160 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.EditText
|
||||||
|
import androidx.test.ext.junit.rules.activityScenarioRule
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.filters.LargeTest
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.test.uiautomator.By
|
||||||
|
import androidx.test.uiautomator.UiDevice
|
||||||
|
import androidx.test.uiautomator.UiSelector
|
||||||
|
import dev.turingcomplete.kotlinonetimepassword.HmacAlgorithm
|
||||||
|
import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordConfig
|
||||||
|
import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordGenerator
|
||||||
|
import org.apache.commons.codec.binary.Base32
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@LargeTest
|
||||||
|
class MainActivityTest {
|
||||||
|
companion object {
|
||||||
|
const val TAG = "MainActivityTest"
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:Rule val activityRule = activityScenarioRule<MainActivity>()
|
||||||
|
|
||||||
|
@Before fun setUp() {}
|
||||||
|
|
||||||
|
@After fun tearDown() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This test starts with a clean install, logs the user in to a tailnet using credentials provided
|
||||||
|
* through a build config, and then makes sure we can hit https://hello.ts.net.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun loginAndVisitHello() {
|
||||||
|
val githubUsername = BuildConfig.GITHUB_USERNAME
|
||||||
|
val githubPassword = BuildConfig.GITHUB_PASSWORD
|
||||||
|
val github2FASecret = Base32().decode(BuildConfig.GITHUB_2FA_SECRET)
|
||||||
|
val config =
|
||||||
|
TimeBasedOneTimePasswordConfig(
|
||||||
|
codeDigits = 6,
|
||||||
|
hmacAlgorithm = HmacAlgorithm.SHA1,
|
||||||
|
timeStep = 30,
|
||||||
|
timeStepUnit = TimeUnit.SECONDS)
|
||||||
|
val githubTOTP = TimeBasedOneTimePasswordGenerator(github2FASecret, config)
|
||||||
|
|
||||||
|
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
|
||||||
|
Log.d(TAG, "Wait for VPN permission prompt and accept")
|
||||||
|
device.find(By.text("Connection request"))
|
||||||
|
device.find(By.text("OK")).click()
|
||||||
|
|
||||||
|
Log.d(TAG, "Click through Get Started screen")
|
||||||
|
device.find(By.text("Get Started"))
|
||||||
|
device.find(By.text("Get Started")).click()
|
||||||
|
|
||||||
|
asNecessary(
|
||||||
|
timeout = 2.minutes,
|
||||||
|
{
|
||||||
|
Log.d(TAG, "Log in")
|
||||||
|
device.find(By.text("Log in")).click()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Log.d(TAG, "Accept Chrome terms and conditions (if necessary)")
|
||||||
|
device.find(By.text("Welcome to Chrome"))
|
||||||
|
val dismissIndex =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) 1 else 0
|
||||||
|
device.find(UiSelector().instance(dismissIndex).className(Button::class.java)).click()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Log.d(TAG, "Don't turn on sync")
|
||||||
|
device.find(By.text("Turn on sync?"))
|
||||||
|
device.find(By.text("No thanks")).click()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Log.d(TAG, "Log in with GitHub")
|
||||||
|
device.find(By.text("Sign in with GitHub")).click()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Log.d(TAG, "Make sure GitHub page has loaded")
|
||||||
|
device.find(By.text("New to GitHub"))
|
||||||
|
device.find(By.text("Username or email address"))
|
||||||
|
device.find(By.text("Sign in"))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Log.d(TAG, "Enter credentials")
|
||||||
|
device
|
||||||
|
.find(UiSelector().instance(0).className(EditText::class.java))
|
||||||
|
.setText(githubUsername)
|
||||||
|
device
|
||||||
|
.find(UiSelector().instance(1).className(EditText::class.java))
|
||||||
|
.setText(githubPassword)
|
||||||
|
device.find(By.text("Sign in")).click()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Log.d(TAG, "Enter 2FA")
|
||||||
|
device.find(By.text("Two-factor authentication"))
|
||||||
|
device
|
||||||
|
.find(UiSelector().instance(0).className(EditText::class.java))
|
||||||
|
.setText(githubTOTP.generate())
|
||||||
|
device.find(UiSelector().instance(0).className(Button::class.java)).click()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Log.d(TAG, "Accept Tailscale app")
|
||||||
|
device.find(By.text("Learn more about OAuth"))
|
||||||
|
// Sleep a little to give button time to activate
|
||||||
|
Thread.sleep(5.seconds.inWholeMilliseconds)
|
||||||
|
device.find(UiSelector().instance(1).className(Button::class.java)).click()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Log.d(TAG, "Connect device")
|
||||||
|
device.find(By.text("Connect device"))
|
||||||
|
device.find(UiSelector().instance(0).className(Button::class.java)).click()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "Accept Permission (Either Storage or Notifications)")
|
||||||
|
device.find(By.text("Continue")).click()
|
||||||
|
device.find(By.text("Allow")).click()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
// we're not always prompted for permissions, that's okay
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Wait for VPN to connect")
|
||||||
|
device.find(By.text("Connected"))
|
||||||
|
|
||||||
|
val helloResponse = helloTSNet
|
||||||
|
Assert.assertTrue(
|
||||||
|
"Response from hello.ts.net should show success",
|
||||||
|
helloResponse.contains("You're connected over Tailscale!"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val helloTSNet: String
|
||||||
|
get() {
|
||||||
|
return URL("https://hello.ts.net").run {
|
||||||
|
openConnection().run {
|
||||||
|
this as HttpURLConnection
|
||||||
|
connectTimeout = 30000
|
||||||
|
readTimeout = 5000
|
||||||
|
inputStream.bufferedReader().readText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,92 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.test.uiautomator.BySelector
|
||||||
|
import androidx.test.uiautomator.UiDevice
|
||||||
|
import androidx.test.uiautomator.UiObject
|
||||||
|
import androidx.test.uiautomator.UiObject2
|
||||||
|
import androidx.test.uiautomator.UiSelector
|
||||||
|
import androidx.test.uiautomator.Until
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
private val defaultTimeout = 10.seconds
|
||||||
|
|
||||||
|
private val threadLocalTimeout = ThreadLocal<Duration>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait until the specified timeout for the given selector and return the matching UiObject2.
|
||||||
|
* Timeout defaults to 10 seconds.
|
||||||
|
*
|
||||||
|
* @throws Exception if selector is not found within timeout.
|
||||||
|
*/
|
||||||
|
fun UiDevice.find(
|
||||||
|
selector: BySelector,
|
||||||
|
timeout: Duration = threadLocalTimeout.get() ?: defaultTimeout
|
||||||
|
): UiObject2 {
|
||||||
|
wait(Until.findObject(selector), timeout.inWholeMilliseconds)?.let {
|
||||||
|
return it
|
||||||
|
} ?: run { throw Exception("not found") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait until the specified timeout for the given selector and return the matching UiObject. Timeout
|
||||||
|
* defaults to 10 seconds.
|
||||||
|
*
|
||||||
|
* @throws Exception if selector is not found within timeout.
|
||||||
|
*/
|
||||||
|
fun UiDevice.find(
|
||||||
|
selector: UiSelector,
|
||||||
|
timeout: Duration = threadLocalTimeout.get() ?: defaultTimeout
|
||||||
|
): UiObject {
|
||||||
|
val obj = findObject(selector)
|
||||||
|
if (!obj.waitForExists(timeout.inWholeMilliseconds)) {
|
||||||
|
throw Exception("not found")
|
||||||
|
}
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute an ordered collection of steps as necessary. If an earlier step fails but a subsequent
|
||||||
|
* step succeeds, this skips the earlier step. This is useful for interruptible sequences like
|
||||||
|
* logging in that may resume in an intermediate state.
|
||||||
|
*/
|
||||||
|
fun asNecessary(timeout: Duration, vararg steps: () -> Unit) {
|
||||||
|
val interval = 250.milliseconds
|
||||||
|
// Use a short timeout to avoid waiting on actions that can be skipped
|
||||||
|
threadLocalTimeout.set(interval)
|
||||||
|
try {
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
|
var furthestSuccessful = -1
|
||||||
|
while (System.currentTimeMillis() - start < timeout.inWholeMilliseconds) {
|
||||||
|
for (i in furthestSuccessful + 1 ..< steps.size) {
|
||||||
|
val step = steps[i]
|
||||||
|
try {
|
||||||
|
step()
|
||||||
|
furthestSuccessful = i
|
||||||
|
Log.d("TestUtil.asNecessary", "SUCCESS!")
|
||||||
|
// Going forward, use the normal timeout on the assumption that subsequent steps will
|
||||||
|
// succeed.
|
||||||
|
threadLocalTimeout.remove()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.d("TestUtil.asNecessary", t.toString())
|
||||||
|
// Going forward, use a short timeout to avoid waiting on actions that can be skipped
|
||||||
|
threadLocalTimeout.set(interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (furthestSuccessful == steps.size - 1) {
|
||||||
|
// All steps have completed successfully
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Still some steps left to run
|
||||||
|
Thread.sleep(interval.inWholeMilliseconds)
|
||||||
|
}
|
||||||
|
throw Exception("failed to complete within timeout")
|
||||||
|
} finally {
|
||||||
|
threadLocalTimeout.remove()
|
||||||
|
}
|
||||||
|
}
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
@ -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,38 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.model
|
||||||
|
|
||||||
|
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 DependsOn: List<String>? = null, // an array of WarnableCodes this depends on
|
||||||
|
) {
|
||||||
|
fun hiddenByDependencies(currentWarnableCodes: Set<String>): Boolean {
|
||||||
|
return this.DependsOn?.let {
|
||||||
|
it.any { depWarnableCode -> currentWarnableCodes.contains(depWarnableCode) }
|
||||||
|
} == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class Severity {
|
||||||
|
high,
|
||||||
|
medium,
|
||||||
|
low
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,117 @@
|
|||||||
|
// 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 android.util.Log
|
||||||
|
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 kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
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 ->
|
||||||
|
Log.d(TAG, "Health updated: ${health?.Warnings?.keys?.sorted()}")
|
||||||
|
health?.Warnings?.let {
|
||||||
|
notifyHealthUpdated(it.values.mapNotNull { it }.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val currentWarnings: MutableSet<String> = mutableSetOf()
|
||||||
|
|
||||||
|
private fun notifyHealthUpdated(warnings: Array<UnhealthyState>) {
|
||||||
|
val warningsBeforeAdd = currentWarnings
|
||||||
|
val currentWarnableCodes = warnings.map { it.WarnableCode }.toSet()
|
||||||
|
|
||||||
|
val addedWarnings: MutableSet<String> = mutableSetOf()
|
||||||
|
for (warning in warnings) {
|
||||||
|
if (ignoredWarnableCodes.contains(warning.WarnableCode)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
addedWarnings.add(warning.WarnableCode)
|
||||||
|
|
||||||
|
if (this.currentWarnings.contains(warning.WarnableCode)) {
|
||||||
|
// Already notified, skip
|
||||||
|
continue
|
||||||
|
} else if (warning.hiddenByDependencies(currentWarnableCodes)) {
|
||||||
|
// Ignore this warning because a dependency is also unhealthy
|
||||||
|
Log.d(TAG, "Ignoring ${warning.WarnableCode} because of dependency")
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Adding health warning: ${warning.WarnableCode}")
|
||||||
|
this.currentWarnings.add(warning.WarnableCode)
|
||||||
|
this.sendNotification(warning.Title, warning.Text, warning.WarnableCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val warningsToDrop = warningsBeforeAdd.minus(addedWarnings)
|
||||||
|
if (warningsToDrop.isNotEmpty()) {
|
||||||
|
Log.d(TAG, "Dropping health warnings with codes $warningsToDrop")
|
||||||
|
this.removeNotifications(warningsToDrop)
|
||||||
|
}
|
||||||
|
currentWarnings.subtract(warningsToDrop)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendNotification(title: String, text: String, code: String) {
|
||||||
|
Log.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) {
|
||||||
|
Log.d(TAG, "Notification permission not granted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notificationManager.notify(code.hashCode(), notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeNotifications(codes: Set<String>) {
|
||||||
|
Log.d(TAG, "Removing notifications for $codes")
|
||||||
|
for (code in codes) {
|
||||||
|
notificationManager.cancel(code.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,30 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.tailscale.ipn.UninitializedApp
|
||||||
|
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
|
||||||
|
|
||||||
|
object AndroidTVUtil {
|
||||||
|
fun isAndroidTV(): Boolean {
|
||||||
|
val pm = UninitializedApp.get().packageManager
|
||||||
|
return (pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION) ||
|
||||||
|
pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies a letterbox effect iff we're running on Android TV to reduce the overall width
|
||||||
|
// of the UI.
|
||||||
|
fun Modifier.universalFit(): Modifier {
|
||||||
|
return when (isAndroidTV()) {
|
||||||
|
true -> this.padding(horizontal = 150.dp, vertical = 10.dp).clip(RoundedCornerShape(10.dp))
|
||||||
|
false -> this
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,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,153 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.theme.listItem
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import com.tailscale.ipn.ui.viewModel.LoginWithAuthKeyViewModel
|
||||||
|
import com.tailscale.ipn.ui.viewModel.LoginWithCustomControlURLViewModel
|
||||||
|
|
||||||
|
data class LoginViewStrings(
|
||||||
|
var title: String,
|
||||||
|
var explanation: String,
|
||||||
|
var inputTitle: String,
|
||||||
|
var placeholder: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginWithCustomControlURLView(
|
||||||
|
onNavigateHome: BackNavigation,
|
||||||
|
backToSettings: BackNavigation,
|
||||||
|
viewModel: LoginWithCustomControlURLViewModel = LoginWithCustomControlURLViewModel()
|
||||||
|
) {
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
Header(
|
||||||
|
R.string.add_account,
|
||||||
|
onBack = backToSettings,
|
||||||
|
)
|
||||||
|
}) { innerPadding ->
|
||||||
|
val error by viewModel.errorDialog.collectAsState()
|
||||||
|
val strings =
|
||||||
|
LoginViewStrings(
|
||||||
|
title = stringResource(id = R.string.custom_control_menu),
|
||||||
|
explanation = stringResource(id = R.string.custom_control_menu_desc),
|
||||||
|
inputTitle = stringResource(id = R.string.custom_control_url_title),
|
||||||
|
placeholder = stringResource(id = R.string.custom_control_placeholder),
|
||||||
|
)
|
||||||
|
|
||||||
|
error?.let { ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) }) }
|
||||||
|
|
||||||
|
LoginView(
|
||||||
|
innerPadding = innerPadding,
|
||||||
|
strings = strings,
|
||||||
|
onSubmitAction = { viewModel.setControlURL(it, onNavigateHome) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginWithAuthKeyView(
|
||||||
|
onNavigateHome: BackNavigation,
|
||||||
|
backToSettings: BackNavigation,
|
||||||
|
viewModel: LoginWithAuthKeyViewModel = LoginWithAuthKeyViewModel()
|
||||||
|
) {
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
Header(
|
||||||
|
R.string.add_account,
|
||||||
|
onBack = backToSettings,
|
||||||
|
)
|
||||||
|
}) { innerPadding ->
|
||||||
|
val error by viewModel.errorDialog.collectAsState()
|
||||||
|
val strings =
|
||||||
|
LoginViewStrings(
|
||||||
|
title = stringResource(id = R.string.auth_key_title),
|
||||||
|
explanation = stringResource(id = R.string.auth_key_explanation),
|
||||||
|
inputTitle = stringResource(id = R.string.auth_key_input_title),
|
||||||
|
placeholder = stringResource(id = R.string.auth_key_placeholder),
|
||||||
|
)
|
||||||
|
// Show the error overlay if need be
|
||||||
|
error?.let { ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) }) }
|
||||||
|
|
||||||
|
LoginView(
|
||||||
|
innerPadding = innerPadding,
|
||||||
|
strings = strings,
|
||||||
|
onSubmitAction = { viewModel.setAuthKey(it, onNavigateHome) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginView(
|
||||||
|
innerPadding: PaddingValues = PaddingValues(16.dp),
|
||||||
|
strings: LoginViewStrings,
|
||||||
|
onSubmitAction: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
|
||||||
|
var textVal by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier.padding(innerPadding)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)) {
|
||||||
|
ListItem(
|
||||||
|
colors = MaterialTheme.colorScheme.listItem,
|
||||||
|
headlineContent = { Text(text = strings.title) },
|
||||||
|
supportingContent = { Text(text = strings.explanation) })
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
colors = MaterialTheme.colorScheme.listItem,
|
||||||
|
headlineContent = { Text(text = strings.inputTitle) },
|
||||||
|
supportingContent = {
|
||||||
|
OutlinedTextField(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedContainerColor = Color.Transparent),
|
||||||
|
textStyle = MaterialTheme.typography.bodyMedium,
|
||||||
|
value = textVal,
|
||||||
|
onValueChange = { textVal = it },
|
||||||
|
placeholder = {
|
||||||
|
Text(strings.placeholder, style = MaterialTheme.typography.bodySmall)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
colors = MaterialTheme.colorScheme.listItem,
|
||||||
|
headlineContent = {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Button(
|
||||||
|
onClick = { onSubmitAction(textVal) },
|
||||||
|
content = { Text(stringResource(id = R.string.add_account_short)) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,80 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.theme.AppTheme
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import com.tailscale.ipn.ui.viewModel.LoginQRViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel()) {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.scrim, modifier = Modifier.fillMaxSize()) {
|
||||||
|
Dialog(onDismissRequest = onDismiss) {
|
||||||
|
val image by model.qrCode.collectAsState()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
.padding(20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.scan_to_connect_to_your_tailnet),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.size(200.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.onSurface)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center) {
|
||||||
|
image?.let {
|
||||||
|
Image(
|
||||||
|
bitmap = it,
|
||||||
|
contentDescription = "Scan to login",
|
||||||
|
modifier = Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(onClick = onDismiss) { Text(text = stringResource(R.string.dismiss)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@Preview
|
||||||
|
fun LoginQRViewPreview() {
|
||||||
|
val vm = LoginQRViewModel()
|
||||||
|
vm.qrCode.set(vm.generateQRCode("https://tailscale.com", 200, 0))
|
||||||
|
AppTheme { LoginQRView({}, vm) }
|
||||||
|
}
|
@ -0,0 +1,52 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.viewModel
|
||||||
|
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import com.tailscale.ipn.ui.view.ErrorDialogType
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
const val AUTH_KEY_LENGTH = 16
|
||||||
|
|
||||||
|
open class CustomLoginViewModel : IpnViewModel() {
|
||||||
|
val errorDialog: StateFlow<ErrorDialogType?> = MutableStateFlow(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoginWithAuthKeyViewModel : CustomLoginViewModel() {
|
||||||
|
// Sets the auth key and invokes the login flow
|
||||||
|
fun setAuthKey(authKey: String, onSuccess: () -> Unit) {
|
||||||
|
// The most basic of checks for auth key syntax
|
||||||
|
if (authKey.isEmpty()) {
|
||||||
|
errorDialog.set(ErrorDialogType.INVALID_AUTH_KEY)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loginWithAuthKey(authKey) {
|
||||||
|
it.onFailure { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) }
|
||||||
|
it.onSuccess { onSuccess() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoginWithCustomControlURLViewModel : CustomLoginViewModel() {
|
||||||
|
// Sets the custom control URL and invokes the login flow
|
||||||
|
fun setControlURL(urlStr: String, onSuccess: () -> Unit) {
|
||||||
|
// Some basic checks that the entered URL is "reasonable". The underlying
|
||||||
|
// localAPIClient will use the default server if we give it a broken URL,
|
||||||
|
// but we can make sure we can construct a URL from the input string and
|
||||||
|
// ensure it has an http/https scheme
|
||||||
|
when (urlStr.startsWith("http") && urlStr.contains("://") && urlStr.length > 7) {
|
||||||
|
false -> {
|
||||||
|
errorDialog.set(ErrorDialogType.INVALID_CUSTOM_URL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
true -> {
|
||||||
|
loginWithCustomControlURL(urlStr) {
|
||||||
|
it.onFailure { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) }
|
||||||
|
it.onSuccess { onSuccess() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,62 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.viewModel
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.google.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.EncodeHintType
|
||||||
|
import com.google.zxing.WriterException
|
||||||
|
import com.google.zxing.qrcode.QRCodeWriter
|
||||||
|
import com.tailscale.ipn.ui.notifier.Notifier
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class LoginQRViewModel : IpnViewModel() {
|
||||||
|
|
||||||
|
val qrCode: StateFlow<ImageBitmap?> = MutableStateFlow(null)
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
Notifier.browseToURL.collect { url ->
|
||||||
|
url?.let { qrCode.set(generateQRCode(url, 200, 0)) } ?: run { qrCode.set(null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateQRCode(content: String, size: Int, padding: Int): ImageBitmap? {
|
||||||
|
val qrCodeWriter = QRCodeWriter()
|
||||||
|
|
||||||
|
val encodeHints = mapOf<EncodeHintType, Any?>(EncodeHintType.MARGIN to padding)
|
||||||
|
|
||||||
|
val bitmapMatrix =
|
||||||
|
try {
|
||||||
|
qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, size, size, encodeHints)
|
||||||
|
} catch (ex: WriterException) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val qrCode =
|
||||||
|
Bitmap.createBitmap(
|
||||||
|
size,
|
||||||
|
size,
|
||||||
|
Bitmap.Config.ARGB_8888,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (x in 0 until size) {
|
||||||
|
for (y in 0 until size) {
|
||||||
|
val shouldColorPixel = bitmapMatrix?.get(x, y) ?: false
|
||||||
|
val pixelColor = if (shouldColorPixel) Color.BLACK else Color.WHITE
|
||||||
|
qrCode.setPixel(x, y, pixelColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return qrCode.asImageBitmap()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,130 @@
|
|||||||
|
// 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 android.util.Log
|
||||||
|
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 kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
class PingViewModelFactory(private val peer: Tailcfg.Node) : ViewModelProvider.Factory {
|
||||||
|
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() {
|
||||||
|
Log.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()
|
||||||
|
Log.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 { Log.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
|
|
||||||
}
|
|
||||||
}
|
|
Before Width: | Height: | Size: 641 B |
Before Width: | Height: | Size: 406 B |
Before Width: | Height: | Size: 879 B |
Before Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 1.8 KiB |
@ -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>
|
|
@ -1,36 +1,38 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="100"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="100">
|
android:viewportHeight="108">
|
||||||
<group android:translateX="20"
|
|
||||||
android:translateY="20">
|
|
||||||
<path
|
<path
|
||||||
android:pathData="M15,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
android:pathData="M34.5,39.37a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
|
||||||
android:fillColor="#FFFDFA"/>
|
android:fillColor="#ffffff"
|
||||||
|
android:fillAlpha="0.4"/>
|
||||||
<path
|
<path
|
||||||
android:pathData="M30,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
android:pathData="M49.13,39.37a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
|
||||||
android:fillColor="#FFFDFA"/>
|
android:fillColor="#ffffff"
|
||||||
|
android:fillAlpha="0.4"/>
|
||||||
<path
|
<path
|
||||||
android:pathData="M15,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
android:pathData="M63.75,39.37a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
|
||||||
android:fillColor="#54514D"/>
|
android:fillColor="#ffffff"
|
||||||
|
android:fillAlpha="0.4"/>
|
||||||
<path
|
<path
|
||||||
android:pathData="M45,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
android:pathData="M34.5,54a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
|
||||||
android:fillColor="#54514D"/>
|
android:fillColor="#ffffff"/>
|
||||||
<path
|
<path
|
||||||
android:pathData="M30,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
android:pathData="M49.13,54a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
|
||||||
android:fillColor="#FFFDFA"/>
|
android:fillColor="#ffffff"/>
|
||||||
<path
|
<path
|
||||||
android:pathData="M45,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
android:pathData="M63.75,54a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
|
||||||
android:fillColor="#FFFDFA"/>
|
android:fillColor="#ffffff"/>
|
||||||
<path
|
<path
|
||||||
android:pathData="M15,15.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
android:pathData="M34.5,68.62a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
|
||||||
android:fillColor="#54514D"/>
|
android:fillColor="#ffffff"
|
||||||
|
android:fillAlpha="0.4"/>
|
||||||
<path
|
<path
|
||||||
android:pathData="M30,14.9999m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
android:pathData="M49.13,68.62a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
|
||||||
android:fillColor="#54514D"/>
|
android:fillColor="#ffffff"/>
|
||||||
<path
|
<path
|
||||||
android:pathData="M45,14.9999m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
|
android:pathData="M63.75,68.62a4.87,4.88 0,1 0,9.75 0a4.87,4.88 0,1 0,-9.75 0z"
|
||||||
android:fillColor="#54514D"/>
|
android:fillColor="#ffffff"
|
||||||
</group>
|
android:fillAlpha="0.4"/>
|
||||||
</vector>
|
</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>
|
@ -0,0 +1,46 @@
|
|||||||
|
<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:strokeAlpha="0.4"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:fillAlpha="0.4"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M37.5,50a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||||
|
android:strokeAlpha="0.4"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:fillAlpha="0.4"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M75,50a12.5,12.5 0,1 0,25 0a12.5,12.5 0,1 0,-25 0z"
|
||||||
|
android:strokeAlpha="0.4"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:fillAlpha="0.4"/>
|
||||||
|
<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:strokeAlpha="0.4"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:fillAlpha="0.4"/>
|
||||||
|
<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>
|
@ -0,0 +1,5 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||||
|
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M3.9,12c0,-1.71 1.39,-3.1 3.1,-3.1h4L11,7L7,7c-2.76,0 -5,2.24 -5,5s2.24,5 5,5h4v-1.9L7,15.1c-1.71,0 -3.1,-1.39 -3.1,-3.1zM8,13h8v-2L8,11v2zM17,7h-4v1.9h4c1.71,0 3.1,1.39 3.1,3.1s-1.39,3.1 -3.1,3.1h-4L13,17h4c2.76,0 5,-2.24 5,-5s-2.24,-5 -5,-5z"/>
|
||||||
|
|
||||||
|
</vector>
|
@ -0,0 +1,5 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||||
|
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M17,7h-4v1.9h4c1.71,0 3.1,1.39 3.1,3.1 0,1.43 -0.98,2.63 -2.31,2.98l1.46,1.46C20.88,15.61 22,13.95 22,12c0,-2.76 -2.24,-5 -5,-5zM16,11h-2.19l2,2L16,13zM2,4.27l3.11,3.11C3.29,8.12 2,9.91 2,12c0,2.76 2.24,5 5,5h4v-1.9L7,15.1c-1.71,0 -3.1,-1.39 -3.1,-3.1 0,-1.59 1.21,-2.9 2.76,-3.07L8.73,11L8,11v2h2.73L13,15.27L13,17h1.73l4.01,4L20,19.74 3.27,3 2,4.27z"/>
|
||||||
|
|
||||||
|
</vector>
|
@ -0,0 +1,5 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
|
||||||
|
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M480,840Q406,840 340.5,811.5Q275,783 226,734Q177,685 148.5,619.5Q120,554 120,480Q120,402 150,334Q180,266 234,217Q246,206 262.5,206.5Q279,207 290,218L508,436Q519,447 519,464Q519,481 508,492Q497,503 480,503Q463,503 452,492L264,304Q234,340 217,384.5Q200,429 200,480Q200,596 282,678Q364,760 480,760Q596,760 678,678Q760,596 760,480Q760,373 691.5,295.5Q623,218 520,204L520,240Q520,257 508.5,268.5Q497,280 480,280Q463,280 451.5,268.5Q440,257 440,240L440,160Q440,143 451.5,131.5Q463,120 480,120Q554,120 619.5,148.5Q685,177 734,226Q783,275 811.5,340.5Q840,406 840,480Q840,554 811.5,619.5Q783,685 734,734Q685,783 619.5,811.5Q554,840 480,840ZM280,520Q263,520 251.5,508.5Q240,497 240,480Q240,463 251.5,451.5Q263,440 280,440Q297,440 308.5,451.5Q320,463 320,480Q320,497 308.5,508.5Q297,520 280,520ZM480,720Q463,720 451.5,708.5Q440,697 440,680Q440,663 451.5,651.5Q463,640 480,640Q497,640 508.5,651.5Q520,663 520,680Q520,697 508.5,708.5Q497,720 480,720ZM680,520Q663,520 651.5,508.5Q640,497 640,480Q640,463 651.5,451.5Q663,440 680,440Q697,440 708.5,451.5Q720,463 720,480Q720,497 708.5,508.5Q697,520 680,520Z"/>
|
||||||
|
|
||||||
|
</vector>
|
Before Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 1.9 KiB |