Compare commits
94 Commits
1.65.4-t7a
...
main
Author | SHA1 | Date |
---|---|---|
Jonathan Nobels | 0ff6be6345 | 8 hours ago |
Jonathan Nobels | 634d51c20b | 5 days ago |
Fred Silberberg | 864cc35bd4 | 5 days ago |
Jonathan Nobels | 23805e9d00 | 5 days ago |
Jonathan Nobels | 5b121c1876 | 5 days ago |
Jonathan Nobels | 80864fec12 | 6 days ago |
Jonathan Nobels | ef21753763 | 2 weeks ago |
Jonathan Nobels | 0e82e54ffb | 2 weeks ago |
Jonathan Nobels | 64fca2a712 | 2 weeks ago |
Jonathan Nobels | a74e30d4e2 | 2 weeks ago |
Jonathan Nobels | 2788cf7ee5 | 2 weeks ago |
kari-ts | d7a87e868c | 2 weeks ago |
kari-ts | 15da8f3797 | 2 weeks ago |
kari-ts | 8f62f0da79 | 3 weeks ago |
kari-ts | cbc47791ad | 3 weeks ago |
kari-ts | a6fd8a8093 | 3 weeks ago |
kari-ts | 0df6c61eee | 3 weeks ago |
Andrea Gottardo | 75db9e64c8 | 4 weeks ago |
kari-ts | e826a173aa | 4 weeks ago |
kari-ts | a05829b3c0 | 4 weeks ago |
kari-ts | 72f35cd318 | 4 weeks ago |
kari-ts | 4fa86dbf03 | 1 month ago |
Jonathan Nobels | 77c2d924ee | 1 month ago |
Jonathan Nobels | b37492a547 | 1 month ago |
kari-ts | 999c6f2357 | 1 month ago |
Andrea Gottardo | 006b1e6852 | 1 month ago |
kari-ts | 32e29c4efd | 1 month ago |
kari-ts | 9aa3a840de | 1 month ago |
kari-ts | 0ff47f7ab5 | 1 month ago |
kari-ts | 12ad295706 | 1 month ago |
kari-ts | d842ccde22 | 1 month ago |
Andrea Gottardo | cbcc773b98 | 1 month ago |
Andrea Gottardo | cbc0035dfe | 1 month ago |
kari-ts | c47ead9412 | 1 month ago |
Percy Wegmann | 46cdbb7b9b | 1 month ago |
kari-ts | 5476288100 | 1 month ago |
kari-ts | a3b356a81c | 1 month ago |
Percy Wegmann | 411d7b2597 | 1 month ago |
Percy Wegmann | 59a88ffbab | 1 month ago |
kari-ts | f684bf696d | 1 month ago |
Percy Wegmann | 698fb868a7 | 1 month ago |
Andrea Gottardo | 82c17a4d1d | 1 month ago |
Jonathan Nobels | b615eb38b4 | 1 month ago |
Andrea Gottardo | 24d6cc7a08 | 1 month ago |
kari-ts | ec1dc8b0be | 1 month ago |
Percy Wegmann | edb3f5b0c5 | 1 month ago |
kari-ts | 7f66c373ea | 1 month ago |
kari-ts | 2d7d6e1357 | 1 month ago |
Jonathan Nobels | 45fd2e0661 | 1 month ago |
Percy Wegmann | 31b0ec8865 | 1 month ago |
Will Norris | 9703d48f1a | 1 month ago |
Jonathan Nobels | 17ad0c8cc0 | 2 months ago |
Jonathan Nobels | a2471d38cb | 2 months ago |
kari-ts | e6f6d35a99 | 2 months ago |
kari-ts | 5e3236260f | 2 months ago |
kari-ts | d330726ba1 | 2 months ago |
Andrea Gottardo | 0c0853a962 | 2 months ago |
James Tucker | 3f864b28c7 | 2 months ago |
kari-ts | 22c129ee1c | 2 months ago |
Andrea Gottardo | 427e2d29b4 | 2 months ago |
kari-ts | 1c0aef5418 | 2 months ago |
kari-ts | 39628be8a6 | 2 months ago |
Brad Fitzpatrick | 9dda2cc470 | 2 months ago |
kari-ts | a6bc2244b6 | 2 months ago |
kari-ts | 24dd83090c | 2 months ago |
kari-ts | ad3b6a5a64 | 2 months ago |
Percy Wegmann | 16fa0e9b9e | 2 months ago |
Andrea Gottardo | 88b0af2c9b | 2 months ago |
Andrea Gottardo | 7119424e32 | 2 months ago |
Jonathan Nobels | b06342629f | 2 months ago |
Percy Wegmann | 07d04ca750 | 2 months ago |
Percy Wegmann | 057e25c23d | 2 months ago |
Will Norris | a54ebf75ef | 2 months ago |
Jonathan Nobels | f4d2a277a5 | 2 months ago |
kari-ts | 75e2d8983b | 2 months ago |
kari-ts | bbb3c86fa8 | 2 months ago |
Percy Wegmann | bc8985126d | 2 months ago |
Brad Fitzpatrick | eb8d731a04 | 2 months ago |
kari-ts | 81acaef5b7 | 2 months ago |
kari-ts | 19177df1e2 | 2 months ago |
Praneet Loke | 6197cb9576 | 2 months ago |
kari-ts | 253c116f9b | 2 months ago |
Jonathan Nobels | 1c3af6713c | 2 months ago |
kari-ts | 39d1d0b3c3 | 2 months ago |
Andrea Gottardo | 56da7b66d0 | 2 months ago |
kari-ts | f95428f7fa | 2 months ago |
Percy Wegmann | 0c58841350 | 2 months ago |
Andrea Gottardo | 8a7148c085 | 2 months ago |
Jonathan Nobels | 372af99c53 | 2 months ago |
Andrea Gottardo | a73025b36f | 2 months ago |
Andrea Gottardo | 4d86c1a6f6 | 2 months ago |
Andrea Gottardo | a1d97baeb0 | 2 months ago |
Matt Drollette | 9533db44b7 | 2 months ago |
Andrea Gottardo | 44ac22c29d | 2 months ago |
@ -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.**
|
@ -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,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,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,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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
|
@ -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,30 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package android.util;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a mock class for the android.util.Log class. It is used to print log messages to the console.
|
||||||
|
*/
|
||||||
|
public class Log {
|
||||||
|
public static int d(String tag, String msg) {
|
||||||
|
System.out.println("DEBUG: " + tag + ": " + msg);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int i(String tag, String msg) {
|
||||||
|
System.out.println("INFO: " + tag + ": " + msg);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int w(String tag, String msg) {
|
||||||
|
System.out.println("WARN: " + tag + ": " + msg);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int e(String tag, String msg) {
|
||||||
|
System.out.println("ERROR: " + tag + ": " + msg);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,60 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailcale.ipn.ui.util
|
||||||
|
|
||||||
|
import com.tailscale.ipn.ui.util.TimeUtil
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
import java.time.Duration
|
||||||
|
|
||||||
|
class TimeUtilTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun durationInvalidMsUnits() {
|
||||||
|
val input = "5s10ms"
|
||||||
|
val actual = TimeUtil.duration(input)
|
||||||
|
assertNull("Should return null", actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun durationInvalidUsUnits() {
|
||||||
|
val input = "5s10us"
|
||||||
|
val actual = TimeUtil.duration(input)
|
||||||
|
assertNull("Should return null", actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun durationTestHappyPath() {
|
||||||
|
val input = arrayOf("1.0y1.0w1.0d1.0h1.0m1.0s", "1s", "1m", "1h", "1d", "1w", "1y")
|
||||||
|
val expectedSeconds =
|
||||||
|
arrayOf((31536000 + 604800 + 86400 + 3600 + 60 + 1), 1, 60, 3600, 86400, 604800, 31536000)
|
||||||
|
val expected = expectedSeconds.map { Duration.ofSeconds(it.toLong()) }
|
||||||
|
val actual = input.map { TimeUtil.duration(it) }
|
||||||
|
assertEquals("Incorrect conversion", expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBadDurationString() {
|
||||||
|
val input = "1..0y1.0w1.0d1.0h1.0m1.0s"
|
||||||
|
val actual = TimeUtil.duration(input)
|
||||||
|
assertNull("Should return null", actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBadDInputString() {
|
||||||
|
val input = "1.0yy1.0w1.0d1.0h1.0m1.0s"
|
||||||
|
val actual = TimeUtil.duration(input)
|
||||||
|
assertNull("Should return null", actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testIgnoreFractionalSeconds() {
|
||||||
|
val input = "10.9s"
|
||||||
|
val expectedSeconds = 10
|
||||||
|
val expected = Duration.ofSeconds(expectedSeconds.toLong())
|
||||||
|
val actual = TimeUtil.duration(input)
|
||||||
|
assertEquals("Should return $expectedSeconds seconds", expected, actual)
|
||||||
|
}
|
||||||
|
}
|
@ -1,60 +0,0 @@
|
|||||||
|
|
||||||
buildscript {
|
|
||||||
ext.kotlin_version = "1.9.22"
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
dependencies {
|
|
||||||
classpath "com.android.tools.build:gradle:8.1.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
flatDir {
|
|
||||||
dirs 'libs'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
apply plugin: 'com.android.application'
|
|
||||||
|
|
||||||
android {
|
|
||||||
ndkVersion "23.1.7779620"
|
|
||||||
compileSdk 33
|
|
||||||
defaultConfig {
|
|
||||||
minSdkVersion 22
|
|
||||||
targetSdkVersion 33
|
|
||||||
versionCode 201
|
|
||||||
versionName "1.61.105-t7429e8912-g12210d3f26b"
|
|
||||||
}
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility JavaVersion.VERSION_17
|
|
||||||
targetCompatibility JavaVersion.VERSION_17
|
|
||||||
}
|
|
||||||
flavorDimensions "version"
|
|
||||||
productFlavors {
|
|
||||||
fdroid {
|
|
||||||
// The fdroid flavor contains only free dependencies and is suitable
|
|
||||||
// for the F-Droid app store.
|
|
||||||
}
|
|
||||||
play {
|
|
||||||
// The play flavor contains all features and is for the Play Store.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
namespace 'com.tailscale.ipn'
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation "androidx.core:core:1.9.0"
|
|
||||||
implementation "androidx.browser:browser:1.5.0"
|
|
||||||
implementation "androidx.security:security-crypto:1.1.0-alpha06"
|
|
||||||
implementation "androidx.work:work-runtime:2.8.1"
|
|
||||||
implementation ':ipn@aar'
|
|
||||||
testImplementation "junit:junit:4.12"
|
|
||||||
|
|
||||||
// Non-free dependencies.
|
|
||||||
playImplementation 'com.google.android.gms:play-services-auth:20.7.0'
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
android.defaults.buildfeatures.buildconfig=true
|
|
||||||
android.nonFinalResIds=false
|
|
||||||
android.nonTransitiveRClass=false
|
|
||||||
android.useAndroidX=true
|
|
||||||
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m
|
|
@ -1,6 +0,0 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
|
||||||
distributionPath=wrapper/dists
|
|
||||||
distributionSha256Sum=38f66cd6eef217b4c35855bb11ea4e9fbc53594ccccb5fb82dfd317ef8c2c5a3
|
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
|
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
|
||||||
zipStorePath=wrapper/dists
|
|
@ -1,183 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
|
|
||||||
#
|
|
||||||
# Copyright 2015 the original author or authors.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
#
|
|
||||||
|
|
||||||
##############################################################################
|
|
||||||
##
|
|
||||||
## Gradle start up script for UN*X
|
|
||||||
##
|
|
||||||
##############################################################################
|
|
||||||
|
|
||||||
# Attempt to set APP_HOME
|
|
||||||
# Resolve links: $0 may be a link
|
|
||||||
PRG="$0"
|
|
||||||
# Need this for relative symlinks.
|
|
||||||
while [ -h "$PRG" ] ; do
|
|
||||||
ls=`ls -ld "$PRG"`
|
|
||||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
|
||||||
if expr "$link" : '/.*' > /dev/null; then
|
|
||||||
PRG="$link"
|
|
||||||
else
|
|
||||||
PRG=`dirname "$PRG"`"/$link"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
SAVED="`pwd`"
|
|
||||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
|
||||||
APP_HOME="`pwd -P`"
|
|
||||||
cd "$SAVED" >/dev/null
|
|
||||||
|
|
||||||
APP_NAME="Gradle"
|
|
||||||
APP_BASE_NAME=`basename "$0"`
|
|
||||||
|
|
||||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
|
||||||
DEFAULT_JVM_OPTS='"-Xmx80m" "-Xms80m"'
|
|
||||||
|
|
||||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
|
||||||
MAX_FD="maximum"
|
|
||||||
|
|
||||||
warn () {
|
|
||||||
echo "$*"
|
|
||||||
}
|
|
||||||
|
|
||||||
die () {
|
|
||||||
echo
|
|
||||||
echo "$*"
|
|
||||||
echo
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# OS specific support (must be 'true' or 'false').
|
|
||||||
cygwin=false
|
|
||||||
msys=false
|
|
||||||
darwin=false
|
|
||||||
nonstop=false
|
|
||||||
case "`uname`" in
|
|
||||||
CYGWIN* )
|
|
||||||
cygwin=true
|
|
||||||
;;
|
|
||||||
Darwin* )
|
|
||||||
darwin=true
|
|
||||||
;;
|
|
||||||
MINGW* )
|
|
||||||
msys=true
|
|
||||||
;;
|
|
||||||
NONSTOP* )
|
|
||||||
nonstop=true
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
|
||||||
|
|
||||||
# Determine the Java command to use to start the JVM.
|
|
||||||
if [ -n "$JAVA_HOME" ] ; then
|
|
||||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
|
||||||
# IBM's JDK on AIX uses strange locations for the executables
|
|
||||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
|
||||||
else
|
|
||||||
JAVACMD="$JAVA_HOME/bin/java"
|
|
||||||
fi
|
|
||||||
if [ ! -x "$JAVACMD" ] ; then
|
|
||||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
|
||||||
|
|
||||||
Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
location of your Java installation."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
JAVACMD="java"
|
|
||||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
|
||||||
|
|
||||||
Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
location of your Java installation."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Increase the maximum file descriptors if we can.
|
|
||||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
|
||||||
MAX_FD_LIMIT=`ulimit -H -n`
|
|
||||||
if [ $? -eq 0 ] ; then
|
|
||||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
|
||||||
MAX_FD="$MAX_FD_LIMIT"
|
|
||||||
fi
|
|
||||||
ulimit -n $MAX_FD
|
|
||||||
if [ $? -ne 0 ] ; then
|
|
||||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# For Darwin, add options to specify how the application appears in the dock
|
|
||||||
if $darwin; then
|
|
||||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
|
||||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
|
||||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
|
||||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
|
||||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
|
||||||
|
|
||||||
# We build the pattern for arguments to be converted via cygpath
|
|
||||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
|
||||||
SEP=""
|
|
||||||
for dir in $ROOTDIRSRAW ; do
|
|
||||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
|
||||||
SEP="|"
|
|
||||||
done
|
|
||||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
|
||||||
# Add a user-defined pattern to the cygpath arguments
|
|
||||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
|
||||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
|
||||||
fi
|
|
||||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
|
||||||
i=0
|
|
||||||
for arg in "$@" ; do
|
|
||||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
|
||||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
|
||||||
|
|
||||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
|
||||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
|
||||||
else
|
|
||||||
eval `echo args$i`="\"$arg\""
|
|
||||||
fi
|
|
||||||
i=`expr $i + 1`
|
|
||||||
done
|
|
||||||
case $i in
|
|
||||||
0) set -- ;;
|
|
||||||
1) set -- "$args0" ;;
|
|
||||||
2) set -- "$args0" "$args1" ;;
|
|
||||||
3) set -- "$args0" "$args1" "$args2" ;;
|
|
||||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
|
||||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
|
||||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
|
||||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
|
||||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
|
||||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Escape application args
|
|
||||||
save () {
|
|
||||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
|
||||||
echo " "
|
|
||||||
}
|
|
||||||
APP_ARGS=`save "$@"`
|
|
||||||
|
|
||||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
|
||||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
|
||||||
|
|
||||||
exec "$JAVACMD" "$@"
|
|
@ -1,103 +0,0 @@
|
|||||||
@rem
|
|
||||||
@rem Copyright 2015 the original author or authors.
|
|
||||||
@rem
|
|
||||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
@rem you may not use this file except in compliance with the License.
|
|
||||||
@rem You may obtain a copy of the License at
|
|
||||||
@rem
|
|
||||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
@rem
|
|
||||||
@rem Unless required by applicable law or agreed to in writing, software
|
|
||||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
@rem See the License for the specific language governing permissions and
|
|
||||||
@rem limitations under the License.
|
|
||||||
@rem
|
|
||||||
|
|
||||||
@if "%DEBUG%" == "" @echo off
|
|
||||||
@rem ##########################################################################
|
|
||||||
@rem
|
|
||||||
@rem Gradle startup script for Windows
|
|
||||||
@rem
|
|
||||||
@rem ##########################################################################
|
|
||||||
|
|
||||||
@rem Set local scope for the variables with windows NT shell
|
|
||||||
if "%OS%"=="Windows_NT" setlocal
|
|
||||||
|
|
||||||
set DIRNAME=%~dp0
|
|
||||||
if "%DIRNAME%" == "" set DIRNAME=.
|
|
||||||
set APP_BASE_NAME=%~n0
|
|
||||||
set APP_HOME=%DIRNAME%
|
|
||||||
|
|
||||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
|
||||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
|
||||||
|
|
||||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
|
||||||
set DEFAULT_JVM_OPTS="-Xmx80m" "-Xms80m"
|
|
||||||
|
|
||||||
@rem Find java.exe
|
|
||||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
|
||||||
|
|
||||||
set JAVA_EXE=java.exe
|
|
||||||
%JAVA_EXE% -version >NUL 2>&1
|
|
||||||
if "%ERRORLEVEL%" == "0" goto init
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
|
||||||
echo.
|
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
echo location of your Java installation.
|
|
||||||
|
|
||||||
goto fail
|
|
||||||
|
|
||||||
:findJavaFromJavaHome
|
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
|
||||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
|
||||||
|
|
||||||
if exist "%JAVA_EXE%" goto init
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
|
||||||
echo.
|
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
echo location of your Java installation.
|
|
||||||
|
|
||||||
goto fail
|
|
||||||
|
|
||||||
:init
|
|
||||||
@rem Get command-line arguments, handling Windows variants
|
|
||||||
|
|
||||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
|
||||||
|
|
||||||
:win9xME_args
|
|
||||||
@rem Slurp the command line arguments.
|
|
||||||
set CMD_LINE_ARGS=
|
|
||||||
set _SKIP=2
|
|
||||||
|
|
||||||
:win9xME_args_slurp
|
|
||||||
if "x%~1" == "x" goto execute
|
|
||||||
|
|
||||||
set CMD_LINE_ARGS=%*
|
|
||||||
|
|
||||||
:execute
|
|
||||||
@rem Setup the command line
|
|
||||||
|
|
||||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
|
||||||
|
|
||||||
:end
|
|
||||||
@rem End local scope for the variables with windows NT shell
|
|
||||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
|
||||||
|
|
||||||
:fail
|
|
||||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
|
||||||
rem the _cmd.exe /c_ return code!
|
|
||||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
|
||||||
exit /b 1
|
|
||||||
|
|
||||||
:mainEnd
|
|
||||||
if "%OS%"=="Windows_NT" endlocal
|
|
||||||
|
|
||||||
:omega
|
|
@ -1,85 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
|
|
||||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
|
||||||
|
|
||||||
<!-- Disable input emulation on ChromeOS -->
|
|
||||||
<uses-feature android:name="android.hardware.type.pc" android:required="false"/>
|
|
||||||
|
|
||||||
<!-- Signal support for Android TV -->
|
|
||||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
|
||||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
|
||||||
|
|
||||||
<application android:label="Tailscale" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round"
|
|
||||||
android:banner="@drawable/tv_banner"
|
|
||||||
android:name=".App" android:allowBackup="false">
|
|
||||||
<activity android:name="IPNActivity"
|
|
||||||
android:label="@string/app_name"
|
|
||||||
android:theme="@style/Theme.GioApp"
|
|
||||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
|
|
||||||
android:windowSoftInputMode="adjustResize"
|
|
||||||
android:launchMode="singleTask"
|
|
||||||
android:exported="true">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.SEND" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT"/>
|
|
||||||
<data android:mimeType="application/*" />
|
|
||||||
<data android:mimeType="audio/*" />
|
|
||||||
<data android:mimeType="image/*" />
|
|
||||||
<data android:mimeType="message/*" />
|
|
||||||
<data android:mimeType="multipart/*" />
|
|
||||||
<data android:mimeType="text/*" />
|
|
||||||
<data android:mimeType="video/*" />
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<data android:mimeType="application/*" />
|
|
||||||
<data android:mimeType="audio/*" />
|
|
||||||
<data android:mimeType="image/*" />
|
|
||||||
<data android:mimeType="message/*" />
|
|
||||||
<data android:mimeType="multipart/*" />
|
|
||||||
<data android:mimeType="text/*" />
|
|
||||||
<data android:mimeType="video/*" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
<receiver android:name="IPNReceiver"
|
|
||||||
android:exported="true"
|
|
||||||
>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="com.tailscale.ipn.CONNECT_VPN" />
|
|
||||||
<action android:name="com.tailscale.ipn.DISCONNECT_VPN" />
|
|
||||||
</intent-filter>
|
|
||||||
</receiver>
|
|
||||||
<service android:name=".IPNService"
|
|
||||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
|
||||||
android:exported="false">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.net.VpnService"/>
|
|
||||||
</intent-filter>
|
|
||||||
</service>
|
|
||||||
<service
|
|
||||||
android:name=".QuickToggleService"
|
|
||||||
android:icon="@drawable/ic_tile"
|
|
||||||
android:label="@string/tile_name"
|
|
||||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
|
||||||
android:exported="true">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.service.quicksettings.action.QS_TILE"/>
|
|
||||||
</intent-filter>
|
|
||||||
</service>
|
|
||||||
</application>
|
|
||||||
</manifest>
|
|
||||||
|
|
Before Width: | Height: | Size: 17 KiB |
@ -1,416 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn;
|
|
||||||
|
|
||||||
import android.app.Application;
|
|
||||||
import android.app.Activity;
|
|
||||||
import android.app.DownloadManager;
|
|
||||||
import android.app.Fragment;
|
|
||||||
import android.app.FragmentTransaction;
|
|
||||||
import android.app.NotificationChannel;
|
|
||||||
import android.app.PendingIntent;
|
|
||||||
import android.app.UiModeManager;
|
|
||||||
import android.content.BroadcastReceiver;
|
|
||||||
import android.content.ContentResolver;
|
|
||||||
import android.content.ContentValues;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.content.IntentFilter;
|
|
||||||
import android.content.SharedPreferences;
|
|
||||||
import android.content.pm.PackageManager;
|
|
||||||
import android.content.pm.PackageInfo;
|
|
||||||
import android.content.pm.Signature;
|
|
||||||
import android.content.res.Configuration;
|
|
||||||
import android.provider.MediaStore;
|
|
||||||
import android.provider.Settings;
|
|
||||||
import android.net.ConnectivityManager;
|
|
||||||
import android.net.LinkProperties;
|
|
||||||
import android.net.Network;
|
|
||||||
import android.net.NetworkInfo;
|
|
||||||
import android.net.NetworkRequest;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.net.VpnService;
|
|
||||||
import android.view.View;
|
|
||||||
import android.os.Build;
|
|
||||||
import android.os.Environment;
|
|
||||||
import android.os.Handler;
|
|
||||||
import android.os.Looper;
|
|
||||||
|
|
||||||
import android.Manifest;
|
|
||||||
import android.webkit.MimeTypeMap;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.FileOutputStream;
|
|
||||||
|
|
||||||
import java.lang.StringBuilder;
|
|
||||||
|
|
||||||
import java.net.InetAddress;
|
|
||||||
import java.net.InterfaceAddress;
|
|
||||||
import java.net.NetworkInterface;
|
|
||||||
|
|
||||||
import java.security.GeneralSecurityException;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
import androidx.core.app.NotificationCompat;
|
|
||||||
import androidx.core.app.NotificationManagerCompat;
|
|
||||||
|
|
||||||
import androidx.core.content.ContextCompat;
|
|
||||||
|
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences;
|
|
||||||
import androidx.security.crypto.MasterKey;
|
|
||||||
|
|
||||||
import androidx.browser.customtabs.CustomTabsIntent;
|
|
||||||
|
|
||||||
import org.gioui.Gio;
|
|
||||||
|
|
||||||
public class App extends Application {
|
|
||||||
private static final String PEER_TAG = "peer";
|
|
||||||
|
|
||||||
static final String STATUS_CHANNEL_ID = "tailscale-status";
|
|
||||||
static final int STATUS_NOTIFICATION_ID = 1;
|
|
||||||
|
|
||||||
static final String NOTIFY_CHANNEL_ID = "tailscale-notify";
|
|
||||||
static final int NOTIFY_NOTIFICATION_ID = 2;
|
|
||||||
|
|
||||||
private static final String FILE_CHANNEL_ID = "tailscale-files";
|
|
||||||
private static final int FILE_NOTIFICATION_ID = 3;
|
|
||||||
|
|
||||||
private static final Handler mainHandler = new Handler(Looper.getMainLooper());
|
|
||||||
|
|
||||||
private ConnectivityManager connectivityManager;
|
|
||||||
public DnsConfig dns = new DnsConfig();
|
|
||||||
public DnsConfig getDnsConfigObj() { return this.dns; }
|
|
||||||
|
|
||||||
@Override public void onCreate() {
|
|
||||||
super.onCreate();
|
|
||||||
// Load and initialize the Go library.
|
|
||||||
Gio.init(this);
|
|
||||||
|
|
||||||
this.connectivityManager = (ConnectivityManager) this.getSystemService(Context.CONNECTIVITY_SERVICE);
|
|
||||||
setAndRegisterNetworkCallbacks();
|
|
||||||
|
|
||||||
createNotificationChannel(NOTIFY_CHANNEL_ID, "Notifications", NotificationManagerCompat.IMPORTANCE_DEFAULT);
|
|
||||||
createNotificationChannel(STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW);
|
|
||||||
createNotificationChannel(FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT);
|
|
||||||
}
|
|
||||||
|
|
||||||
// requestNetwork attempts to find the best network that matches the passed NetworkRequest. It is possible that
|
|
||||||
// this might return an unusuable network, eg a captive portal.
|
|
||||||
private void setAndRegisterNetworkCallbacks() {
|
|
||||||
connectivityManager.requestNetwork(dns.getDNSConfigNetworkRequest(), new ConnectivityManager.NetworkCallback(){
|
|
||||||
@Override
|
|
||||||
public void onAvailable(Network network){
|
|
||||||
super.onAvailable(network);
|
|
||||||
StringBuilder sb = new StringBuilder("");
|
|
||||||
LinkProperties linkProperties = connectivityManager.getLinkProperties(network);
|
|
||||||
List<InetAddress> dnsList = linkProperties.getDnsServers();
|
|
||||||
for (InetAddress ip : dnsList) {
|
|
||||||
sb.append(ip.getHostAddress()).append(" ");
|
|
||||||
}
|
|
||||||
String searchDomains = linkProperties.getDomains();
|
|
||||||
if (searchDomains != null) {
|
|
||||||
sb.append("\n");
|
|
||||||
sb.append(searchDomains);
|
|
||||||
}
|
|
||||||
|
|
||||||
dns.updateDNSFromNetwork(sb.toString());
|
|
||||||
onDnsConfigChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onLost(Network network) {
|
|
||||||
super.onLost(network);
|
|
||||||
onDnsConfigChanged();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public void startVPN() {
|
|
||||||
Intent intent = new Intent(this, IPNService.class);
|
|
||||||
intent.setAction(IPNService.ACTION_REQUEST_VPN);
|
|
||||||
startService(intent);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void stopVPN() {
|
|
||||||
Intent intent = new Intent(this, IPNService.class);
|
|
||||||
intent.setAction(IPNService.ACTION_STOP_VPN);
|
|
||||||
startService(intent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// encryptToPref a byte array of data using the Jetpack Security
|
|
||||||
// library and writes it to a global encrypted preference store.
|
|
||||||
public void encryptToPref(String prefKey, String plaintext) throws IOException, GeneralSecurityException {
|
|
||||||
getEncryptedPrefs().edit().putString(prefKey, plaintext).commit();
|
|
||||||
}
|
|
||||||
|
|
||||||
// decryptFromPref decrypts a encrypted preference using the Jetpack Security
|
|
||||||
// library and returns the plaintext.
|
|
||||||
public String decryptFromPref(String prefKey) throws IOException, GeneralSecurityException {
|
|
||||||
return getEncryptedPrefs().getString(prefKey, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private SharedPreferences getEncryptedPrefs() throws IOException, GeneralSecurityException {
|
|
||||||
MasterKey key = new MasterKey.Builder(this)
|
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
return EncryptedSharedPreferences.create(
|
|
||||||
this,
|
|
||||||
"secret_shared_prefs",
|
|
||||||
key,
|
|
||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean autoConnect = false;
|
|
||||||
public boolean vpnReady = false;
|
|
||||||
|
|
||||||
void setTileReady(boolean ready) {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
QuickToggleService.setReady(this, ready);
|
|
||||||
android.util.Log.d("App", "Set Tile Ready: " + ready + " " + autoConnect);
|
|
||||||
|
|
||||||
vpnReady = ready;
|
|
||||||
if (ready && autoConnect) {
|
|
||||||
startVPN();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void setTileStatus(boolean status) {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
QuickToggleService.setStatus(this, status);
|
|
||||||
}
|
|
||||||
|
|
||||||
String getHostname() {
|
|
||||||
String userConfiguredDeviceName = getUserConfiguredDeviceName();
|
|
||||||
if (!isEmpty(userConfiguredDeviceName)) return userConfiguredDeviceName;
|
|
||||||
|
|
||||||
return getModelName();
|
|
||||||
}
|
|
||||||
|
|
||||||
String getModelName() {
|
|
||||||
String manu = Build.MANUFACTURER;
|
|
||||||
String model = Build.MODEL;
|
|
||||||
// Strip manufacturer from model.
|
|
||||||
int idx = model.toLowerCase().indexOf(manu.toLowerCase());
|
|
||||||
if (idx != -1) {
|
|
||||||
model = model.substring(idx + manu.length());
|
|
||||||
model = model.trim();
|
|
||||||
}
|
|
||||||
return manu + " " + model;
|
|
||||||
}
|
|
||||||
|
|
||||||
String getOSVersion() {
|
|
||||||
return Build.VERSION.RELEASE;
|
|
||||||
}
|
|
||||||
|
|
||||||
// get user defined nickname from Settings
|
|
||||||
// returns null if not available
|
|
||||||
private String getUserConfiguredDeviceName() {
|
|
||||||
String nameFromSystemDevice = Settings.Secure.getString(getContentResolver(), "device_name");
|
|
||||||
if (!isEmpty(nameFromSystemDevice)) return nameFromSystemDevice;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isEmpty(String str) {
|
|
||||||
return str == null || str.length() == 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// attachPeer adds a Peer fragment for tracking the Activity
|
|
||||||
// lifecycle.
|
|
||||||
void attachPeer(Activity act) {
|
|
||||||
act.runOnUiThread(new Runnable() {
|
|
||||||
@Override public void run() {
|
|
||||||
FragmentTransaction ft = act.getFragmentManager().beginTransaction();
|
|
||||||
ft.add(new Peer(), PEER_TAG);
|
|
||||||
ft.commit();
|
|
||||||
act.getFragmentManager().executePendingTransactions();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean isChromeOS() {
|
|
||||||
return getPackageManager().hasSystemFeature("android.hardware.type.pc");
|
|
||||||
}
|
|
||||||
|
|
||||||
void prepareVPN(Activity act, int reqCode) {
|
|
||||||
act.runOnUiThread(new Runnable() {
|
|
||||||
@Override public void run() {
|
|
||||||
Intent intent = VpnService.prepare(act);
|
|
||||||
if (intent == null) {
|
|
||||||
onVPNPrepared();
|
|
||||||
} else {
|
|
||||||
startActivityForResult(act, intent, reqCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static void startActivityForResult(Activity act, Intent intent, int request) {
|
|
||||||
Fragment f = act.getFragmentManager().findFragmentByTag(PEER_TAG);
|
|
||||||
f.startActivityForResult(intent, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
void showURL(Activity act, String url) {
|
|
||||||
act.runOnUiThread(new Runnable() {
|
|
||||||
@Override public void run() {
|
|
||||||
CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
|
|
||||||
int headerColor = 0xff496495;
|
|
||||||
builder.setToolbarColor(headerColor);
|
|
||||||
CustomTabsIntent intent = builder.build();
|
|
||||||
intent.launchUrl(act, Uri.parse(url));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// getPackageSignatureFingerprint returns the first package signing certificate, if any.
|
|
||||||
byte[] getPackageCertificate() throws Exception {
|
|
||||||
PackageInfo info;
|
|
||||||
info = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES);
|
|
||||||
for (Signature signature : info.signatures) {
|
|
||||||
return signature.toByteArray();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
void requestWriteStoragePermission(Activity act) {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
|
||||||
// We can write files without permission.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ContextCompat.checkSelfPermission(act, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
act.requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, IPNActivity.WRITE_STORAGE_RESULT);
|
|
||||||
}
|
|
||||||
|
|
||||||
String insertMedia(String name, String mimeType) throws IOException {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
ContentResolver resolver = getContentResolver();
|
|
||||||
ContentValues contentValues = new ContentValues();
|
|
||||||
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name);
|
|
||||||
if (!"".equals(mimeType)) {
|
|
||||||
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
|
|
||||||
}
|
|
||||||
Uri root = MediaStore.Files.getContentUri("external");
|
|
||||||
return resolver.insert(root, contentValues).toString();
|
|
||||||
} else {
|
|
||||||
File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
|
|
||||||
dir.mkdirs();
|
|
||||||
File f = new File(dir, name);
|
|
||||||
return Uri.fromFile(f).toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int openUri(String uri, String mode) throws IOException {
|
|
||||||
ContentResolver resolver = getContentResolver();
|
|
||||||
return resolver.openFileDescriptor(Uri.parse(uri), mode).detachFd();
|
|
||||||
}
|
|
||||||
|
|
||||||
void deleteUri(String uri) {
|
|
||||||
ContentResolver resolver = getContentResolver();
|
|
||||||
resolver.delete(Uri.parse(uri), null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void notifyFile(String uri, String msg) {
|
|
||||||
Intent viewIntent;
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
viewIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));
|
|
||||||
} else {
|
|
||||||
// uri is a file:// which is not allowed to be shared outside the app.
|
|
||||||
viewIntent = new Intent(DownloadManager.ACTION_VIEW_DOWNLOADS);
|
|
||||||
}
|
|
||||||
PendingIntent pending = PendingIntent.getActivity(this, 0, viewIntent, PendingIntent.FLAG_UPDATE_CURRENT);
|
|
||||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, FILE_CHANNEL_ID)
|
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
|
||||||
.setContentTitle("File received")
|
|
||||||
.setContentText(msg)
|
|
||||||
.setContentIntent(pending)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.setOnlyAlertOnce(true)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
|
|
||||||
|
|
||||||
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
|
|
||||||
nm.notify(FILE_NOTIFICATION_ID, builder.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
public void createNotificationChannel(String id, String name, int importance) {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
NotificationChannel channel = new NotificationChannel(id, name, importance);
|
|
||||||
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
|
|
||||||
nm.createNotificationChannel(channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
static native void onVPNPrepared();
|
|
||||||
private static native void onDnsConfigChanged();
|
|
||||||
static native void onShareIntent(int nfiles, int[] types, String[] mimes, String[] items, String[] names, long[] sizes);
|
|
||||||
static native void onWriteStorageGranted();
|
|
||||||
|
|
||||||
// Returns details of the interfaces in the system, encoded as a single string for ease
|
|
||||||
// of JNI transfer over to the Go environment.
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
// rmnet_data0 10 2000 true false false false false | fe80::4059:dc16:7ed3:9c6e%rmnet_data0/64
|
|
||||||
// dummy0 3 1500 true false false false false | fe80::1450:5cff:fe13:f891%dummy0/64
|
|
||||||
// wlan0 30 1500 true true false false true | fe80::2f60:2c82:4163:8389%wlan0/64 10.1.10.131/24
|
|
||||||
// r_rmnet_data0 21 1500 true false false false false | fe80::9318:6093:d1ad:ba7f%r_rmnet_data0/64
|
|
||||||
// rmnet_data2 12 1500 true false false false false | fe80::3c8c:44dc:46a9:9907%rmnet_data2/64
|
|
||||||
// r_rmnet_data1 22 1500 true false false false false | fe80::b6cd:5cb0:8ae6:fe92%r_rmnet_data1/64
|
|
||||||
// rmnet_data1 11 1500 true false false false false | fe80::51f2:ee00:edce:d68b%rmnet_data1/64
|
|
||||||
// lo 1 65536 true false true false false | ::1/128 127.0.0.1/8
|
|
||||||
// v4-rmnet_data2 68 1472 true true false true true | 192.0.0.4/32
|
|
||||||
//
|
|
||||||
// Where the fields are:
|
|
||||||
// name ifindex mtu isUp hasBroadcast isLoopback isPointToPoint hasMulticast | ip1/N ip2/N ip3/N;
|
|
||||||
String getInterfacesAsString() {
|
|
||||||
List<NetworkInterface> interfaces;
|
|
||||||
try {
|
|
||||||
interfaces = Collections.list(NetworkInterface.getNetworkInterfaces());
|
|
||||||
} catch (Exception e) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder("");
|
|
||||||
for (NetworkInterface nif : interfaces) {
|
|
||||||
try {
|
|
||||||
// Android doesn't have a supportsBroadcast() but the Go net.Interface wants
|
|
||||||
// one, so we say the interface has broadcast if it has multicast.
|
|
||||||
sb.append(String.format(java.util.Locale.ROOT, "%s %d %d %b %b %b %b %b |", nif.getName(),
|
|
||||||
nif.getIndex(), nif.getMTU(), nif.isUp(), nif.supportsMulticast(),
|
|
||||||
nif.isLoopback(), nif.isPointToPoint(), nif.supportsMulticast()));
|
|
||||||
|
|
||||||
for (InterfaceAddress ia : nif.getInterfaceAddresses()) {
|
|
||||||
// InterfaceAddress == hostname + "/" + IP
|
|
||||||
String[] parts = ia.toString().split("/", 0);
|
|
||||||
if (parts.length > 1) {
|
|
||||||
sb.append(String.format(java.util.Locale.ROOT, "%s/%d ", parts[1], ia.getNetworkPrefixLength()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
// TODO(dgentry) should log the exception not silently suppress it.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
sb.append("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean isTV() {
|
|
||||||
UiModeManager mm = (UiModeManager)getSystemService(UI_MODE_SERVICE);
|
|
||||||
return mm.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,63 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn;
|
|
||||||
|
|
||||||
import android.net.NetworkCapabilities;
|
|
||||||
import android.net.NetworkRequest;
|
|
||||||
|
|
||||||
import java.lang.reflect.Method;
|
|
||||||
|
|
||||||
import java.net.InetAddress;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.concurrent.locks.Lock;
|
|
||||||
import java.util.concurrent.locks.ReentrantLock;
|
|
||||||
|
|
||||||
// Tailscale DNS Config retrieval
|
|
||||||
//
|
|
||||||
// Tailscale's DNS support can either override the local DNS servers with a set of servers
|
|
||||||
// configured in the admin panel, or supplement the local DNS servers with additional
|
|
||||||
// servers for specific domains like example.com.beta.tailscale.net. In the non-override mode,
|
|
||||||
// we need to retrieve the current set of DNS servers from the platform. These will typically
|
|
||||||
// be the DNS servers received from DHCP.
|
|
||||||
//
|
|
||||||
// Importantly, after the Tailscale VPN comes up it will set a DNS server of 100.100.100.100
|
|
||||||
// but we still want to retrieve the underlying DNS servers received from DHCP. If we roam
|
|
||||||
// from Wi-Fi to LTE, we want the DNS servers received from LTE.
|
|
||||||
|
|
||||||
public class DnsConfig {
|
|
||||||
private String dnsConfigs;
|
|
||||||
|
|
||||||
// getDnsConfigAsString returns the current DNS configuration as a multiline string:
|
|
||||||
// line[0] DNS server addresses separated by spaces
|
|
||||||
// line[1] search domains separated by spaces
|
|
||||||
//
|
|
||||||
// For example:
|
|
||||||
// 8.8.8.8 8.8.4.4
|
|
||||||
// example.com
|
|
||||||
//
|
|
||||||
// an empty string means the current DNS configuration could not be retrieved.
|
|
||||||
String getDnsConfigAsString() {
|
|
||||||
return getDnsConfigs().trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String getDnsConfigs(){
|
|
||||||
synchronized(this) {
|
|
||||||
return this.dnsConfigs;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void updateDNSFromNetwork(String dnsConfigs){
|
|
||||||
synchronized(this) {
|
|
||||||
this.dnsConfigs = dnsConfigs;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
NetworkRequest getDNSConfigNetworkRequest(){
|
|
||||||
// Request networks that are able to reach the Internet.
|
|
||||||
return new NetworkRequest.Builder().addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,132 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn;
|
|
||||||
|
|
||||||
import android.app.Activity;
|
|
||||||
import android.content.res.AssetFileDescriptor;
|
|
||||||
import android.content.res.Configuration;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.database.Cursor;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.provider.OpenableColumns;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.content.pm.PackageManager;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
|
|
||||||
import org.gioui.GioView;
|
|
||||||
|
|
||||||
public final class IPNActivity extends Activity {
|
|
||||||
final static int WRITE_STORAGE_RESULT = 1000;
|
|
||||||
|
|
||||||
private GioView view;
|
|
||||||
|
|
||||||
@Override public void onCreate(Bundle state) {
|
|
||||||
super.onCreate(state);
|
|
||||||
view = new GioView(this);
|
|
||||||
setContentView(view);
|
|
||||||
handleIntent();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onNewIntent(Intent i) {
|
|
||||||
setIntent(i);
|
|
||||||
handleIntent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleIntent() {
|
|
||||||
Intent it = getIntent();
|
|
||||||
String act = it.getAction();
|
|
||||||
String[] texts;
|
|
||||||
Uri[] uris;
|
|
||||||
if (Intent.ACTION_SEND.equals(act)) {
|
|
||||||
uris = new Uri[]{it.getParcelableExtra(Intent.EXTRA_STREAM)};
|
|
||||||
texts = new String[]{it.getStringExtra(Intent.EXTRA_TEXT)};
|
|
||||||
} else if (Intent.ACTION_SEND_MULTIPLE.equals(act)) {
|
|
||||||
List<Uri> extraUris = it.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
|
|
||||||
uris = extraUris.toArray(new Uri[0]);
|
|
||||||
texts = new String[uris.length];
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String mime = it.getType();
|
|
||||||
int nitems = uris.length;
|
|
||||||
String[] items = new String[nitems];
|
|
||||||
String[] mimes = new String[nitems];
|
|
||||||
int[] types = new int[nitems];
|
|
||||||
String[] names = new String[nitems];
|
|
||||||
long[] sizes = new long[nitems];
|
|
||||||
int nfiles = 0;
|
|
||||||
for (int i = 0; i < uris.length; i++) {
|
|
||||||
String text = texts[i];
|
|
||||||
Uri uri = uris[i];
|
|
||||||
if (text != null) {
|
|
||||||
types[nfiles] = 1; // FileTypeText
|
|
||||||
names[nfiles] = "file.txt";
|
|
||||||
mimes[nfiles] = mime;
|
|
||||||
items[nfiles] = text;
|
|
||||||
// Determined by len(text) in Go to eliminate UTF-8 encoding differences.
|
|
||||||
sizes[nfiles] = 0;
|
|
||||||
nfiles++;
|
|
||||||
} else if (uri != null) {
|
|
||||||
Cursor c = getContentResolver().query(uri, null, null, null, null);
|
|
||||||
if (c == null) {
|
|
||||||
// Ignore files we have no permission to access.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
int nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
|
||||||
int sizeCol = c.getColumnIndex(OpenableColumns.SIZE);
|
|
||||||
c.moveToFirst();
|
|
||||||
String name = c.getString(nameCol);
|
|
||||||
long size = c.getLong(sizeCol);
|
|
||||||
types[nfiles] = 2; // FileTypeURI
|
|
||||||
mimes[nfiles] = mime;
|
|
||||||
items[nfiles] = uri.toString();
|
|
||||||
names[nfiles] = name;
|
|
||||||
sizes[nfiles] = size;
|
|
||||||
nfiles++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
App.onShareIntent(nfiles, types, mimes, items, names, sizes);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onRequestPermissionsResult(int reqCode, String[] perms, int[] grants) {
|
|
||||||
switch (reqCode) {
|
|
||||||
case WRITE_STORAGE_RESULT:
|
|
||||||
if (grants.length > 0 && grants[0] == PackageManager.PERMISSION_GRANTED) {
|
|
||||||
App.onWriteStorageGranted();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onDestroy() {
|
|
||||||
view.destroy();
|
|
||||||
super.onDestroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onStart() {
|
|
||||||
super.onStart();
|
|
||||||
view.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onStop() {
|
|
||||||
view.stop();
|
|
||||||
super.onStop();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onConfigurationChanged(Configuration c) {
|
|
||||||
super.onConfigurationChanged(c);
|
|
||||||
view.configurationChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onLowMemory() {
|
|
||||||
super.onLowMemory();
|
|
||||||
view.onLowMemory();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onBackPressed() {
|
|
||||||
if (!view.backPressed())
|
|
||||||
super.onBackPressed();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,28 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn;
|
|
||||||
|
|
||||||
import android.content.BroadcastReceiver;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.Intent;
|
|
||||||
import androidx.work.WorkManager;
|
|
||||||
import androidx.work.OneTimeWorkRequest;
|
|
||||||
|
|
||||||
public class IPNReceiver extends BroadcastReceiver {
|
|
||||||
|
|
||||||
public static final String INTENT_CONNECT_VPN = "com.tailscale.ipn.CONNECT_VPN";
|
|
||||||
public static final String INTENT_DISCONNECT_VPN = "com.tailscale.ipn.DISCONNECT_VPN";
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onReceive(Context context, Intent intent) {
|
|
||||||
WorkManager workManager = WorkManager.getInstance(context);
|
|
||||||
|
|
||||||
// On the relevant action, start the relevant worker, which can stay active for longer than this receiver can.
|
|
||||||
if (intent.getAction() == INTENT_CONNECT_VPN) {
|
|
||||||
workManager.enqueue(new OneTimeWorkRequest.Builder(StartVPNWorker.class).build());
|
|
||||||
} else if (intent.getAction() == INTENT_DISCONNECT_VPN) {
|
|
||||||
workManager.enqueue(new OneTimeWorkRequest.Builder(StopVPNWorker.class).build());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,137 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn;
|
|
||||||
|
|
||||||
import android.os.Build;
|
|
||||||
import android.app.PendingIntent;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.content.pm.PackageManager;
|
|
||||||
import android.net.VpnService;
|
|
||||||
import android.system.OsConstants;
|
|
||||||
import androidx.work.WorkManager;
|
|
||||||
import androidx.work.OneTimeWorkRequest;
|
|
||||||
|
|
||||||
import org.gioui.GioActivity;
|
|
||||||
|
|
||||||
import androidx.core.app.NotificationCompat;
|
|
||||||
import androidx.core.app.NotificationManagerCompat;
|
|
||||||
|
|
||||||
public class IPNService extends VpnService {
|
|
||||||
public static final String ACTION_REQUEST_VPN = "com.tailscale.ipn.REQUEST_VPN";
|
|
||||||
public static final String ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN";
|
|
||||||
|
|
||||||
@Override public int onStartCommand(Intent intent, int flags, int startId) {
|
|
||||||
if (intent != null && ACTION_STOP_VPN.equals(intent.getAction())) {
|
|
||||||
((App)getApplicationContext()).autoConnect = false;
|
|
||||||
close();
|
|
||||||
return START_NOT_STICKY;
|
|
||||||
}
|
|
||||||
if (intent != null && "android.net.VpnService".equals(intent.getAction())) {
|
|
||||||
// Start VPN and connect to it due to Always-on VPN
|
|
||||||
Intent i = new Intent(IPNReceiver.INTENT_CONNECT_VPN);
|
|
||||||
i.setPackage(getPackageName());
|
|
||||||
i.setClass(getApplicationContext(), com.tailscale.ipn.IPNReceiver.class);
|
|
||||||
sendBroadcast(i);
|
|
||||||
requestVPN();
|
|
||||||
connect();
|
|
||||||
return START_STICKY;
|
|
||||||
}
|
|
||||||
requestVPN();
|
|
||||||
App app = ((App)getApplicationContext());
|
|
||||||
if (app.vpnReady && app.autoConnect) {
|
|
||||||
connect();
|
|
||||||
}
|
|
||||||
return START_STICKY;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void close() {
|
|
||||||
stopForeground(true);
|
|
||||||
disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onDestroy() {
|
|
||||||
close();
|
|
||||||
super.onDestroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onRevoke() {
|
|
||||||
close();
|
|
||||||
super.onRevoke();
|
|
||||||
}
|
|
||||||
|
|
||||||
private PendingIntent configIntent() {
|
|
||||||
return PendingIntent.getActivity(this, 0, new Intent(this, IPNActivity.class),
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void disallowApp(VpnService.Builder b, String name) {
|
|
||||||
try {
|
|
||||||
b.addDisallowedApplication(name);
|
|
||||||
} catch (PackageManager.NameNotFoundException e) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected VpnService.Builder newBuilder() {
|
|
||||||
VpnService.Builder b = new VpnService.Builder()
|
|
||||||
.setConfigureIntent(configIntent())
|
|
||||||
.allowFamily(OsConstants.AF_INET)
|
|
||||||
.allowFamily(OsConstants.AF_INET6);
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
|
||||||
b.setMetered(false); // Inherit the metered status from the underlying networks.
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
|
||||||
b.setUnderlyingNetworks(null); // Use all available networks.
|
|
||||||
|
|
||||||
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
|
|
||||||
this.disallowApp(b, "com.google.android.apps.messaging");
|
|
||||||
|
|
||||||
// Stadia https://github.com/tailscale/tailscale/issues/3460
|
|
||||||
this.disallowApp(b, "com.google.stadia.android");
|
|
||||||
|
|
||||||
// Android Auto https://github.com/tailscale/tailscale/issues/3828
|
|
||||||
this.disallowApp(b, "com.google.android.projection.gearhead");
|
|
||||||
|
|
||||||
// GoPro https://github.com/tailscale/tailscale/issues/2554
|
|
||||||
this.disallowApp(b, "com.gopro.smarty");
|
|
||||||
|
|
||||||
// Sonos https://github.com/tailscale/tailscale/issues/2548
|
|
||||||
this.disallowApp(b, "com.sonos.acr");
|
|
||||||
this.disallowApp(b, "com.sonos.acr2");
|
|
||||||
|
|
||||||
// Google Chromecast https://github.com/tailscale/tailscale/issues/3636
|
|
||||||
this.disallowApp(b, "com.google.android.apps.chromecast.app");
|
|
||||||
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void notify(String title, String message) {
|
|
||||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.NOTIFY_CHANNEL_ID)
|
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setContentText(message)
|
|
||||||
.setContentIntent(configIntent())
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.setOnlyAlertOnce(true)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
|
|
||||||
|
|
||||||
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
|
|
||||||
nm.notify(App.NOTIFY_NOTIFICATION_ID, builder.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
public void updateStatusNotification(String title, String message) {
|
|
||||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.STATUS_CHANNEL_ID)
|
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setContentText(message)
|
|
||||||
.setContentIntent(configIntent())
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW);
|
|
||||||
|
|
||||||
startForeground(App.STATUS_NOTIFICATION_ID, builder.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
private native void requestVPN();
|
|
||||||
|
|
||||||
private native void disconnect();
|
|
||||||
private native void connect();
|
|
||||||
}
|
|
@ -1,16 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn;
|
|
||||||
|
|
||||||
import android.app.Activity;
|
|
||||||
import android.app.Fragment;
|
|
||||||
import android.content.Intent;
|
|
||||||
|
|
||||||
public class Peer extends Fragment {
|
|
||||||
@Override public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
|
||||||
onActivityResult0(getActivity(), requestCode, resultCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static native void onActivityResult0(Activity act, int reqCode, int resCode);
|
|
||||||
}
|
|
@ -1,87 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.service.quicksettings.Tile;
|
|
||||||
import android.service.quicksettings.TileService;
|
|
||||||
|
|
||||||
public class QuickToggleService extends TileService {
|
|
||||||
// lock protects the static fields below it.
|
|
||||||
private static Object lock = new Object();
|
|
||||||
// Active tracks whether the VPN is active.
|
|
||||||
private static boolean active;
|
|
||||||
// Ready tracks whether the tailscale backend is
|
|
||||||
// ready to switch on/off.
|
|
||||||
private static boolean ready;
|
|
||||||
// currentTile tracks getQsTile while service is listening.
|
|
||||||
private static Tile currentTile;
|
|
||||||
|
|
||||||
@Override public void onStartListening() {
|
|
||||||
synchronized (lock) {
|
|
||||||
currentTile = getQsTile();
|
|
||||||
}
|
|
||||||
updateTile();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onStopListening() {
|
|
||||||
synchronized (lock) {
|
|
||||||
currentTile = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onClick() {
|
|
||||||
boolean r;
|
|
||||||
synchronized (lock) {
|
|
||||||
r = ready;
|
|
||||||
}
|
|
||||||
if (r) {
|
|
||||||
onTileClick();
|
|
||||||
} else {
|
|
||||||
// Start main activity.
|
|
||||||
Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName());
|
|
||||||
startActivityAndCollapse(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void updateTile() {
|
|
||||||
Tile t;
|
|
||||||
boolean act;
|
|
||||||
synchronized (lock) {
|
|
||||||
t = currentTile;
|
|
||||||
act = active && ready;
|
|
||||||
}
|
|
||||||
if (t == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
t.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
|
|
||||||
t.updateTile();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void setReady(Context ctx, boolean rdy) {
|
|
||||||
synchronized (lock) {
|
|
||||||
ready = rdy;
|
|
||||||
}
|
|
||||||
updateTile();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void setStatus(Context ctx, boolean act) {
|
|
||||||
synchronized (lock) {
|
|
||||||
active = act;
|
|
||||||
}
|
|
||||||
updateTile();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onTileClick() {
|
|
||||||
boolean act;
|
|
||||||
synchronized (lock) {
|
|
||||||
act = active && ready;
|
|
||||||
}
|
|
||||||
Intent i = new Intent(act ? IPNReceiver.INTENT_DISCONNECT_VPN : IPNReceiver.INTENT_CONNECT_VPN);
|
|
||||||
i.setPackage(getPackageName());
|
|
||||||
i.setClass(getApplicationContext(), com.tailscale.ipn.IPNReceiver.class);
|
|
||||||
sendBroadcast(i);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,65 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn;
|
|
||||||
|
|
||||||
import android.app.Notification;
|
|
||||||
import android.app.NotificationChannel;
|
|
||||||
import android.app.NotificationManager;
|
|
||||||
import android.app.PendingIntent;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.net.VpnService;
|
|
||||||
import android.os.Build;
|
|
||||||
import androidx.work.Worker;
|
|
||||||
import androidx.work.WorkerParameters;
|
|
||||||
|
|
||||||
public final class StartVPNWorker extends Worker {
|
|
||||||
|
|
||||||
public StartVPNWorker(
|
|
||||||
Context appContext,
|
|
||||||
WorkerParameters workerParams) {
|
|
||||||
super(appContext, workerParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public Result doWork() {
|
|
||||||
App app = ((App)getApplicationContext());
|
|
||||||
|
|
||||||
// We will start the VPN from the background
|
|
||||||
app.autoConnect = true;
|
|
||||||
// We need to make sure we prepare the VPN Service, just in case it isn't prepared.
|
|
||||||
|
|
||||||
Intent intent = VpnService.prepare(app);
|
|
||||||
if (intent == null) {
|
|
||||||
// If null then the VPN is already prepared and/or it's just been prepared because we have permission
|
|
||||||
app.startVPN();
|
|
||||||
return Result.success();
|
|
||||||
} else {
|
|
||||||
// This VPN possibly doesn't have permission, we need to display a notification which when clicked launches the intent provided.
|
|
||||||
android.util.Log.e("StartVPNWorker", "Tailscale doesn't have permission from the system to start VPN. Launching the intent provided.");
|
|
||||||
|
|
||||||
// Send notification
|
|
||||||
NotificationManager notificationManager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE);
|
|
||||||
String channelId = "start_vpn_channel";
|
|
||||||
|
|
||||||
// Use createNotificationChannel method from App.java
|
|
||||||
app.createNotificationChannel(channelId, "Start VPN Channel", NotificationManager.IMPORTANCE_DEFAULT);
|
|
||||||
|
|
||||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
|
||||||
int pendingIntentFlags = PendingIntent.FLAG_ONE_SHOT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_IMMUTABLE : 0);
|
|
||||||
PendingIntent pendingIntent = PendingIntent.getActivity(app, 0, intent, pendingIntentFlags);
|
|
||||||
|
|
||||||
Notification notification = new Notification.Builder(app, channelId)
|
|
||||||
.setContentTitle("Tailscale Connection Failed")
|
|
||||||
.setContentText("Tap here to renew permission.")
|
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
|
||||||
.setContentIntent(pendingIntent)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
notificationManager.notify(1, notification);
|
|
||||||
|
|
||||||
return Result.failure();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,24 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn;
|
|
||||||
|
|
||||||
import androidx.work.Worker;
|
|
||||||
import android.content.Context;
|
|
||||||
import androidx.work.WorkerParameters;
|
|
||||||
|
|
||||||
public final class StopVPNWorker extends Worker {
|
|
||||||
|
|
||||||
public StopVPNWorker(
|
|
||||||
Context appContext,
|
|
||||||
WorkerParameters workerParams) {
|
|
||||||
super(appContext, workerParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public Result doWork() {
|
|
||||||
disconnect();
|
|
||||||
return Result.success();
|
|
||||||
}
|
|
||||||
|
|
||||||
private native void disconnect();
|
|
||||||
}
|
|
@ -1,149 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.localapi
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.tailscale.ipn.ui.model.*
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
|
|
||||||
// A response from the echo endpoint.
|
|
||||||
typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit
|
|
||||||
|
|
||||||
typealias BugReportIdHandler = (Result<BugReportID>) -> Unit
|
|
||||||
|
|
||||||
typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
|
|
||||||
|
|
||||||
class LocalApiClient {
|
|
||||||
constructor() {
|
|
||||||
Log.d("LocalApiClient", "LocalApiClient created")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform a request to the local API in the go backend. This is
|
|
||||||
// the primary JNI method for servicing a localAPI call. This
|
|
||||||
// is GUARANTEED to call back into onResponse with the response
|
|
||||||
// from the backend with a matching cookie.
|
|
||||||
// @see cmd/localapiclient/localapishim.go
|
|
||||||
//
|
|
||||||
// request: The path to the localAPI endpoint.
|
|
||||||
// method: The HTTP method to use.
|
|
||||||
// body: The body of the request.
|
|
||||||
// cookie: A unique identifier for this request. This is used map responses to
|
|
||||||
// the corresponding request. Cookies must be unique for each request.
|
|
||||||
external fun doRequest(request: String, method: String, body: String, cookie: String)
|
|
||||||
|
|
||||||
fun <T> executeRequest(request: LocalAPIRequest<T>) {
|
|
||||||
Log.d("LocalApiClient", "Executing request:${request.method}:${request.path}")
|
|
||||||
addRequest(request)
|
|
||||||
// The jni handler will treat the empty string in the body as null.
|
|
||||||
val body = request.body ?: ""
|
|
||||||
doRequest(request.path, request.method, body, request.cookie)
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is called from the JNI layer to publish localAPIResponses. This should execute on the
|
|
||||||
// same thread that called doRequest.
|
|
||||||
fun onResponse(response: String, cookie: String) {
|
|
||||||
val request = requests[cookie]
|
|
||||||
if (request != null) {
|
|
||||||
Log.d("LocalApiClient", "Reponse for request:${request.path} cookie:${request.cookie}")
|
|
||||||
// The response handler will invoked internally by the request parser
|
|
||||||
request.parser(response)
|
|
||||||
removeRequest(cookie)
|
|
||||||
} else {
|
|
||||||
Log.e("LocalApiClient", "Received response for unknown request: ${cookie}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tracks in-flight requests and their callback handlers by cookie. This should
|
|
||||||
// always be manipulated via the addRequest and removeRequest methods.
|
|
||||||
private var requests = HashMap<String, LocalAPIRequest<*>>()
|
|
||||||
private var requestLock = Any()
|
|
||||||
|
|
||||||
fun addRequest(request: LocalAPIRequest<*>) {
|
|
||||||
synchronized(requestLock) { requests[request.cookie] = request }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeRequest(cookie: String) {
|
|
||||||
synchronized(requestLock) { requests.remove(cookie) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// localapi Invocations
|
|
||||||
|
|
||||||
fun getStatus(responseHandler: StatusResponseHandler) {
|
|
||||||
val req = LocalAPIRequest.status(responseHandler)
|
|
||||||
executeRequest<IpnState.Status>(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getBugReportId(responseHandler: BugReportIdHandler) {
|
|
||||||
val req = LocalAPIRequest.bugReportId(responseHandler)
|
|
||||||
executeRequest<BugReportID>(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getPrefs(responseHandler: PrefsHandler) {
|
|
||||||
val req = LocalAPIRequest.prefs(responseHandler)
|
|
||||||
executeRequest<Ipn.Prefs>(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
// (jonathan) TODO: A (likely) exhaustive list of localapi endpoints required for
|
|
||||||
// a fully functioning client. This is a work in progress and will be updated
|
|
||||||
// See: corp/xcode/Shared/LocalAPIClient.swift for the various verbs, parameters,
|
|
||||||
// and body contents for each endpoint. Endpoints are defined in LocalAPIEndpoint
|
|
||||||
//
|
|
||||||
// fetchFileTargets
|
|
||||||
// sendFiles
|
|
||||||
// getWaitingFiles
|
|
||||||
// recieveWaitingFile
|
|
||||||
// inidicateFileRecieved
|
|
||||||
// debug
|
|
||||||
// debugLog
|
|
||||||
// uploadClientMetrics
|
|
||||||
// start
|
|
||||||
// startLoginInteractive
|
|
||||||
// logout
|
|
||||||
// profiles
|
|
||||||
// currentProfile
|
|
||||||
// addProfile
|
|
||||||
// switchProfile
|
|
||||||
// deleteProfile
|
|
||||||
// tailnetLocalStatus
|
|
||||||
// signNode
|
|
||||||
// verifyDeepling
|
|
||||||
// ping
|
|
||||||
// setTailFSFileServerAddress
|
|
||||||
|
|
||||||
// Run some tests to validate the APIs work before we have anything
|
|
||||||
// that calls them. This runs after a short delay to avoid not-ready
|
|
||||||
// errors
|
|
||||||
// (jonathan) TODO: Do we need some kind of "onReady" callback?
|
|
||||||
// (jonathan) TODO: Remove these we're further along
|
|
||||||
|
|
||||||
fun runAPITests() = runBlocking {
|
|
||||||
delay(5000L)
|
|
||||||
getStatus { result ->
|
|
||||||
if (result.failed) {
|
|
||||||
Log.e("LocalApiClient", "Error getting status: ${result.error}")
|
|
||||||
} else {
|
|
||||||
val status = result.success
|
|
||||||
Log.d("LocalApiClient", "Got status: ${status}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getBugReportId { result ->
|
|
||||||
if (result.failed) {
|
|
||||||
Log.e("LocalApiClient", "Error getting bug report id: ${result.error}")
|
|
||||||
} else {
|
|
||||||
val bugReportId = result.success
|
|
||||||
Log.d("LocalApiClient", "Got bug report id: ${bugReportId}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getPrefs { result ->
|
|
||||||
if (result.failed) {
|
|
||||||
Log.e("LocalApiClient", "Error getting prefs: ${result.error}")
|
|
||||||
} else {
|
|
||||||
val prefs = result.success
|
|
||||||
Log.d("LocalApiClient", "Got prefs: ${prefs}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,128 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.localapi
|
|
||||||
|
|
||||||
import com.tailscale.ipn.ui.model.*
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
|
|
||||||
enum class LocalAPIEndpoint(val rawValue: String) {
|
|
||||||
Debug("debug"),
|
|
||||||
Debug_Log("debug-log"),
|
|
||||||
BugReport("bugreport"),
|
|
||||||
Prefs("prefs"),
|
|
||||||
FileTargets("file-targets"),
|
|
||||||
UploadMetrics("upload-client-metrics"),
|
|
||||||
Start("start"),
|
|
||||||
LoginInteractive("login-interactive"),
|
|
||||||
ResetAuth("reset-auth"),
|
|
||||||
Logout("logout"),
|
|
||||||
Profiles("profiles"),
|
|
||||||
ProfilesCurrent("profiles/current"),
|
|
||||||
Status("status"),
|
|
||||||
TKAStatus("tka/status"),
|
|
||||||
TKASitng("tka/sign"),
|
|
||||||
TKAVerifyDeeplink("tka/verify-deeplink"),
|
|
||||||
Ping("ping"),
|
|
||||||
Files("files"),
|
|
||||||
FilePut("file-put"),
|
|
||||||
TailFSServerAddress("tailfs/fileserver-address");
|
|
||||||
|
|
||||||
val prefix = "/localapi/v0/"
|
|
||||||
|
|
||||||
fun path(): String {
|
|
||||||
return prefix + rawValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Potential local and upstream errors. Error handling in localapi in the go layer
|
|
||||||
// is inconsistent but different clients already deal with that inconsistency so
|
|
||||||
// 'fixing' it will likely break other things.
|
|
||||||
//
|
|
||||||
// For now, anything that isn't an { error: "message" } will be passed along
|
|
||||||
// as UNPARSEABLE_RESPONSE. We can add additional error translation in the parseError
|
|
||||||
// method as needed.
|
|
||||||
//
|
|
||||||
// (jonathan) TODO: Audit local API for all of the possible error results and clean
|
|
||||||
// it up if possible.
|
|
||||||
enum class APIErrorVals(val rawValue: String) {
|
|
||||||
UNPARSEABLE_RESPONSE("Unparseable localAPI response"),
|
|
||||||
NOT_READY("Not Ready");
|
|
||||||
|
|
||||||
fun toError(): Error {
|
|
||||||
return Error(rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class LocalAPIRequest<T>(
|
|
||||||
val path: String,
|
|
||||||
val method: String,
|
|
||||||
val body: String? = null,
|
|
||||||
val responseHandler: (Result<T>) -> Unit,
|
|
||||||
val parser: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
val cookieLock = Any()
|
|
||||||
var cookieCounter: Int = 0
|
|
||||||
val decoder = Json { ignoreUnknownKeys = true }
|
|
||||||
|
|
||||||
fun getCookie(): String {
|
|
||||||
synchronized(cookieLock) {
|
|
||||||
cookieCounter += 1
|
|
||||||
return cookieCounter.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun status(responseHandler: StatusResponseHandler): LocalAPIRequest<IpnState.Status> {
|
|
||||||
val path = LocalAPIEndpoint.Status.path()
|
|
||||||
return LocalAPIRequest<IpnState.Status>(path, "GET", null, responseHandler) { resp ->
|
|
||||||
responseHandler(decode<IpnState.Status>(resp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun bugReportId(responseHandler: BugReportIdHandler): LocalAPIRequest<BugReportID> {
|
|
||||||
val path = LocalAPIEndpoint.BugReport.path()
|
|
||||||
return LocalAPIRequest<BugReportID>(path, "POST", null, responseHandler) { resp ->
|
|
||||||
responseHandler(parseString(resp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun prefs(responseHandler: PrefsHandler): LocalAPIRequest<Ipn.Prefs> {
|
|
||||||
val path = LocalAPIEndpoint.Prefs.path()
|
|
||||||
return LocalAPIRequest<Ipn.Prefs>(path, "GET", null, responseHandler) { resp ->
|
|
||||||
responseHandler(decode<Ipn.Prefs>(resp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the response was a generic error
|
|
||||||
fun parseError(respData: String): Error {
|
|
||||||
try {
|
|
||||||
val err = Json.decodeFromString<Errors.GenericError>(respData)
|
|
||||||
return Error(err.error)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
return Error(APIErrorVals.UNPARSEABLE_RESPONSE.toError())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handles responses that are raw strings. Returns an error result if the string
|
|
||||||
// is empty
|
|
||||||
fun parseString(respData: String): Result<String> {
|
|
||||||
return if (respData.length > 0) Result(respData)
|
|
||||||
else Result(APIErrorVals.UNPARSEABLE_RESPONSE.toError())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt to decode the response into the expected type. If that fails, then try
|
|
||||||
// parsing as an error.
|
|
||||||
inline fun <reified T> decode(respData: String): Result<T> {
|
|
||||||
try {
|
|
||||||
val message = decoder.decodeFromString<T>(respData)
|
|
||||||
return Result(message)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
return Result(parseError(respData))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val cookie: String = getCookie()
|
|
||||||
}
|
|
@ -1,32 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & AUTHORS
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
package com.tailscale.ipn.ui.localapi
|
|
||||||
|
|
||||||
// Go-like result type with an optional value and an optional Error
|
|
||||||
// This guarantees that only one of the two is non-null
|
|
||||||
class Result<T> {
|
|
||||||
val success: T?
|
|
||||||
val error: Error?
|
|
||||||
|
|
||||||
constructor(success: T?, error: Error?) {
|
|
||||||
if (success != null && error != null) {
|
|
||||||
throw IllegalArgumentException("Result cannot have both a success and an error")
|
|
||||||
}
|
|
||||||
if (success == null && error == null) {
|
|
||||||
throw IllegalArgumentException("Result must have either a success or an error")
|
|
||||||
}
|
|
||||||
|
|
||||||
this.success = success
|
|
||||||
this.error = error
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(success: T) : this(success, null) {}
|
|
||||||
constructor(error: Error) : this(null, error) {}
|
|
||||||
|
|
||||||
var successful: Boolean = false
|
|
||||||
get() = success != null
|
|
||||||
|
|
||||||
var failed: Boolean = false
|
|
||||||
get() = error != null
|
|
||||||
}
|
|
Before Width: | Height: | Size: 641 B |
Before Width: | Height: | Size: 406 B |
Before Width: | Height: | Size: 879 B |
Before Width: | Height: | Size: 1.7 KiB |