android: restructure app

Adds a product flavor for the new app
Moves existing code out of main/ and into app/ - main/ contains shared code, app/ is for the existing app, and newapp/ is where all the new stuff will live

Next:
Add tasks (either in Gradle or in Makefile) to build the native library and use it for building the newapp
Add in barebones main activity for newapp that launches Tailscale

Updates tailscale/tailscale#10992

Signed-off-by: kari-ts <kari@tailscale.com>
kari/newapp
kari-ts 2 months ago
parent 9492b01946
commit a4d1a89580

@ -1,4 +1,3 @@
buildscript {
ext.kotlin_version = "1.9.22"
@ -29,6 +28,7 @@ android {
ndkVersion "23.1.7779620"
compileSdkVersion 33
defaultConfig {
applicationId "com.tailscale.ipn"
minSdkVersion 22
targetSdkVersion 33
versionCode 198
@ -47,8 +47,37 @@ android {
play {
// The play flavor contains all features and is for the Play Store.
}
newapp {
// In progress!
}
}
namespace 'com.tailscale.ipn'
sourceSets {
main {
res.srcDirs = ['src/main/res']
java.srcDirs = ['src/main/java']
manifest.srcFile 'src/main/AndroidManifest.xml'
}
fdroid {
manifest.srcFile 'src/app/AndroidManifest.xml'
java{
srcDir 'src/app'
}
}
play {
manifest.srcFile 'src/app/AndroidManifest.xml'
java{
srcDir 'src/app'
}
}
newapp{
java{
srcDir 'src/newapp/java'
}
jniLibs.srcDirs = ['src/main/jniLibs']
manifest.srcFile 'src/newapp/AndroidManifest.xml'
}
}
}
dependencies {

@ -0,0 +1,68 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<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>

@ -1,6 +1,7 @@
<?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" />
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.tailscale.ipn">
<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" />
@ -14,72 +15,10 @@
<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"
<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>
<!-- Common components (like Services, BroadcastReceivers) can be declared here -->
</application>
</manifest>

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity android:name="MainActivity"
android:label="@string/app_name"
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>
</application>
</manifest>

@ -0,0 +1,94 @@
// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn
import android.util.Log
import com.tailscale.ipn.ui.model.*
import kotlinx.coroutines.*
import android.app.Application
import java.net.NetworkInterface
import java.net.InterfaceAddress
class MyAppApplication : Application() {
companion object {
init {
System.loadLibrary("repository")
}
}
override fun onCreate() {
super.onCreate()
// Perform application-wide initialization here.
// Example: Initialize a logging library
initializeLogging()
// Example: Setup global error handling
setupGlobalErrorHandler()
}
private fun initializeLogging() {
// Assuming a hypothetical logging setup
Log.d("MyAppApplication", "Logging initialized.")
}
private fun setupGlobalErrorHandler() {
// Setup a custom uncaught exception handler for the application
Thread.setDefaultUncaughtExceptionHandler { thread, e ->
Log.e("MyAppApplication", "Uncaught exception in thread: ${thread.name}", e)
// Here, you could report fatal errors to an analytics server, for example.
}
}
// 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;
fun getInterfacesAsString(): String {
val interfaces: List<NetworkInterface> = try {
NetworkInterface.getNetworkInterfaces().toList()
} catch (e: Exception) {
return ""
}
val sb = StringBuilder()
for (nif in 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.name,
nif.index, nif.mtu, nif.isUp, nif.supportsMulticast(),
nif.isLoopback, nif.isPointToPoint, nif.supportsMulticast()))
for (ia in nif.interfaceAddresses) {
// InterfaceAddress == hostname + "/" + IP
val parts = ia.toString().split("/", limit = 0)
if (parts.size > 1) {
sb.append(String.format(java.util.Locale.ROOT, "%s/%d ", parts[1], ia.networkPrefixLength))
}
}
} catch (e: Exception) {
// TODO: should log the exception not silently suppress it.
continue
}
sb.append("\n")
}
return sb.toString()
}
}

@ -0,0 +1,150 @@
// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn.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}")
}
}
}
}

@ -0,0 +1,129 @@
// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn.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()
}

@ -0,0 +1,33 @@
// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn.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
}

@ -0,0 +1,30 @@
// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn.ui.model
import kotlinx.serialization.*
class Dns {
@Serializable data class HostEntry(val addr: Addr?, val hosts: List<String>?)
@Serializable
data class OSConfig(
val hosts: List<HostEntry>? = null,
val nameservers: List<Addr>? = null,
val searchDomains: List<String>? = null,
val matchDomains: List<String>? = null,
) {
val isEmpty: Boolean
get() = (hosts.isNullOrEmpty()) &&
(nameservers.isNullOrEmpty()) &&
(searchDomains.isNullOrEmpty()) &&
(matchDomains.isNullOrEmpty())
}
}
class DnsType {
@Serializable
data class Resolver(var Addr: String? = null, var BootstrapResolution: List<Addr>? = null)
}

@ -0,0 +1,168 @@
// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn.ui.model
import kotlinx.serialization.*
class Ipn {
// Represents the overall state of the Tailscale engine.
enum class State(val value: Int) {
NoState(0),
InUseOtherUser(1),
NeedsLogin(2),
NeedsMachineAuth(3),
Stopped(4),
Starting(5),
Running(6),
}
// NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which
// what we want to see on the Noitfy bus
enum class NotifyWatchOpt(val value: Int) {
engineUpdates(0),
initialState(1 shl 1),
prefs(1 shl 2),
netmap(1 shl 3),
noPrivateKeys(1 shl 4),
initialTailFSShares(1 shl 5)
}
// A nofitication message recieved on the Notify bus. Fields will be populated based
// on which NotifyWatchOpts were set when the Notifier was created.
@Serializable
data class Notify(
val Version: String? = null,
val ErrMessage: String? = null,
val LoginFinished: Empty.Message? = null,
val FilesWaiting: Empty.Message? = null,
val State: State? = null,
var Prefs: Prefs? = null,
var NetMap: Netmap.NetworkMap? = null,
var Engine: EngineStatus? = null,
var BrowseToURL: String? = null,
var BackendLogId: String? = null,
var LocalTCPPort: Int? = null,
var IncomingFiles: List<PartialFile>? = null,
var ClientVersion: Tailcfg.ClientVersion? = null,
var TailFSShares: Map<String, String>? = null,
)
@Serializable
data class Prefs(
var ControlURL: String = "",
var RouteAll: Boolean = false,
var AllowsSingleHosts: Boolean = false,
var CorpDNS: Boolean = false,
var WantRunning: Boolean = false,
var LoggedOut: Boolean = false,
var ShieldsUp: Boolean = false,
var AdvertiseRoutes: List<String>? = null,
var AdvertiseTags: List<String>? = null,
var ExitNodeId: StableNodeID? = null,
var ExitNodeAllowLanAccess: Boolean = false,
var Config: Persist.Persist? = null,
var ForceDaemon: Boolean = false,
var HostName: String = "",
var AutoUpdate: AutoUpdatePrefs? = AutoUpdatePrefs(true, true),
)
@Serializable
data class MaskedPrefs(
var RouteAllSet: Boolean? = null,
var CorpDNSSet: Boolean? = null,
var ExitNodeIDSet: Boolean? = null,
var ExitNodeAllowLANAccessSet: Boolean? = null,
var WantRunningSet: Boolean? = null,
var ShieldsUpSet: Boolean? = null,
var AdvertiseRoutesSet: Boolean? = null,
var ForceDaemonSet: Boolean? = null,
var HostnameSet: Boolean? = null,
) {
var RouteAll: Boolean? = null
set(value) {
field = value
RouteAllSet = true
}
var CorpDNS: Boolean? = null
set(value) {
field = value
CorpDNSSet = true
}
var ExitNodeId: StableNodeID? = null
set(value) {
field = value
ExitNodeIDSet = true
}
var ExitNodeAllowLanAccess: Boolean? = null
set(value) {
field = value
ExitNodeAllowLANAccessSet = true
}
var WantRunning: Boolean? = null
set(value) {
field = value
WantRunningSet = true
}
var ShieldsUp: Boolean? = null
set(value) {
field = value
ShieldsUpSet = true
}
var AdvertiseRoutes: Boolean? = null
set(value) {
field = value
AdvertiseRoutesSet = true
}
var ForceDaemon: Boolean? = null
set(value) {
field = value
ForceDaemonSet = true
}
var Hostname: Boolean? = null
set(value) {
field = value
HostnameSet = true
}
}
@Serializable
data class AutoUpdatePrefs(
var Check: Boolean? = null,
var Apply: Boolean? = null,
)
@Serializable
data class EngineStatus(
val RBytes: Long,
val WBytes: Long,
val NumLive: Int,
val LivePeers: Map<String, IpnState.PeerStatusLite>,
)
@Serializable
data class PartialFile(
val Name: String,
val Started: String,
val DeclaredSize: Long,
val Received: Long,
val PartialPath: String? = null,
var FinalPath: String? = null,
val Done: Boolean? = null,
)
}
class Persist {
@Serializable
data class Persist(
var PrivateMachineKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
var PrivateNodeKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
var OldPrivateNodeKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
var Provider: String = "",
)
}

@ -0,0 +1,117 @@
// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn.ui.model
import kotlinx.serialization.*
class IpnState {
@Serializable
data class PeerStatusLite(
val RxBytes: Long,
val TxBytes: Long,
val LastHandshake: String,
val NodeKey: String,
)
@Serializable
data class PeerStatus(
val ID: StableNodeID,
val HostName: String,
val DNSName: String,
val TailscaleIPs: List<Addr>? = null,
val Tags: List<String>? = null,
val PrimaryRoutes: List<String>? = null,
val Addrs: List<String>? = null,
val Online: Boolean,
val ExitNode: Boolean,
val ExitNodeOption: Boolean,
val PeerAPIURL: List<String>? = null,
val Capabilities: List<String>? = null,
val SSH_HostKeys: List<String>? = null,
val ShareeNode: Boolean? = null,
val Expired: Boolean? = null,
val Location: Tailcfg.Location? = null,
) {
fun computedName(status: Status): String {
val name = DNSName
val suffix = status.CurrentTailnet?.MagicDNSSuffix
suffix ?: return name
if (!(name.endsWith("." + suffix + "."))) {
return name
}
return name.dropLast(suffix.count() + 2)
}
}
@Serializable
data class ExitNodeStatus(
val ID: StableNodeID,
val Online: Boolean,
val TailscaleIPs: List<Prefix>? = null,
)
@Serializable
data class TailnetStatus(
val Name: String,
val MagicDNSSuffix: String,
val MagicDNSEnabled: Boolean,
)
@Serializable
data class Status(
val Version: String,
val TUN: Boolean,
val BackendState: String,
val AuthURL: String,
val TailscaleIPs: List<Addr>? = null,
val Self: PeerStatus? = null,
val ExitNodeStatus: ExitNodeStatus? = null,
val Health: List<String>? = null,
val CurrentTailnet: TailnetStatus? = null,
val CertDomains: List<String>? = null,
val Peer: Map<String, PeerStatus>? = null,
val User: Map<String, Tailcfg.UserProfile>? = null,
val ClientVersion: Tailcfg.ClientVersion? = null,
)
@Serializable
data class NetworkLockStatus(
var Enabled: Boolean,
var PublicKey: String,
var NodeKey: String,
var NodeKeySigned: Boolean,
var FilteredPeers: List<TKAFilteredPeer>? = null,
var StateID: ULong? = null,
)
@Serializable
data class TKAFilteredPeer(
var Name: String,
var TailscaleIPs: List<Addr>,
var NodeKey: String,
)
@Serializable
data class PingResult(
var IP: Addr,
var Err: String,
var LatencySeconds: Double,
)
}
class IpnLocal {
@Serializable
data class LoginProfile(
var ID: String,
val Name: String,
val Key: String,
val UserProfile: Tailcfg.UserProfile,
val NetworkProfile: Tailcfg.NetworkProfile? = null,
val LocalUserID: String,
)
}

@ -0,0 +1,49 @@
// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn.ui.model
import kotlinx.serialization.*
class Netmap {
@Serializable
data class NetworkMap(
var SelfNode: Tailcfg.Node,
var NodeKey: KeyNodePublic,
var Peers: List<Tailcfg.Node>? = null,
var Expiry: Time,
var Domain: String,
var UserProfiles: Map<String, Tailcfg.UserProfile>,
var TKAEnabled: Boolean,
var DNS: Tailcfg.DNSConfig? = null
) {
// Keys are tailcfg.UserIDs thet get stringified
// Helpers
fun currentUserProfile(): Tailcfg.UserProfile? {
return userProfile(User())
}
fun User(): UserID {
return SelfNode.User
}
fun userProfile(id: Long): Tailcfg.UserProfile? {
return UserProfiles[id.toString()]
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is NetworkMap) return false
return SelfNode == other.SelfNode &&
NodeKey == other.NodeKey &&
Peers == other.Peers &&
Expiry == other.Expiry &&
User() == other.User() &&
Domain == other.Domain &&
UserProfiles == other.UserProfiles &&
TKAEnabled == other.TKAEnabled
}
}
}

@ -0,0 +1,100 @@
// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn.ui.model
import kotlinx.serialization.*
class Tailcfg {
@Serializable
data class ClientVersion(
var RunningLatest: Boolean? = null,
var LatestVersion: String? = null,
var UrgentSecurityUpdate: Boolean? = null,
var Notify: Boolean? = null,
var NotifyURL: String? = null,
var NotifyText: String? = null
)
@Serializable
data class UserProfile(
val ID: Long,
val DisplayName: String,
val LoginName: String,
val ProfilePicURL: String? = null,
) {
fun isTaggedDevice(): Boolean {
return LoginName == "tagged-devices"
}
}
@Serializable
data class Hostinfo(
var IPNVersion: String? = null,
var FrontendLogID: String? = null,
var BackendLogID: String? = null,
var OS: String? = null,
var OSVersion: String? = null,
var Env: String? = null,
var Distro: String? = null,
var DistroVersion: String? = null,
var DistroCodeName: String? = null,
var Desktop: Boolean? = null,
var Package: String? = null,
var DeviceModel: String? = null,
var ShareeNode: Boolean? = null,
var Hostname: String? = null,
var ShieldsUp: Boolean? = null,
var NoLogsNoSupport: Boolean? = null,
var Machine: String? = null,
var RoutableIPs: List<Prefix>? = null,
var Services: List<Service>? = null,
)
@Serializable
data class Node(
var ID: NodeID,
var StableID: StableNodeID,
var Name: String,
var User: UserID,
var Sharer: UserID? = null,
var Key: KeyNodePublic,
var KeyExpiry: String,
var Machine: MachineKey,
var Addresses: List<Prefix>? = null,
var AllowedIPs: List<Prefix>? = null,
var Endpoints: List<String>? = null,
var Hostinfo: Hostinfo,
var Created: Time,
var LastSeen: Time? = null,
var Online: Boolean? = null,
var Capabilities: List<String>? = null,
var ComputedName: String,
var ComputedNameWithHost: String
)
@Serializable
data class Service(var Proto: String, var Port: Int, var Description: String? = null)
@Serializable
data class NetworkProfile(var MagicDNSName: String? = null, var DomainName: String? = null)
@Serializable
data class Location(
var Country: String? = null,
var CountryCode: String? = null,
var City: String? = null,
var CityCode: String? = null,
var Priority: Int? = null
)
@Serializable
data class DNSConfig(
var Resolvers: List<DnsType.Resolver>? = null,
var Routes: Map<String, List<DnsType.Resolver>?>? = null,
var FallbackResolvers: List<DnsType.Resolver>? = null,
var Domains: List<String>? = null,
var Nameservers: List<Addr>? = null
)
}

@ -0,0 +1,35 @@
// Copyright (c) 2024 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package com.tailscale.ipn.ui.model
import kotlinx.serialization.*
typealias Addr = String
typealias Prefix = String
typealias NodeID = Long
typealias KeyNodePublic = String
typealias MachineKey = String
typealias UserID = Long
typealias Time = String
typealias StableNodeID = String
typealias BugReportID = String
// Represents and empty message with a single 'property' field.
class Empty {
@Serializable
data class Message(val property: String)
}
// Parsable errors returned by localApiService
class Errors {
@Serializable
data class GenericError(val error: String)
}
// Returned on successful operations with no explicit response body
class Success {
@Serializable
data class GenericSuccess(val message: String)
}

@ -0,0 +1,665 @@
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"context"
"errors"
"fmt"
"log"
"net"
"net/http"
"net/netip"
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"time"
"gioui.org/app"
"github.com/tailscale/tailscale-android/cmd/jni"
"github.com/tailscale/tailscale-android/cmd/localapiservice"
"golang.org/x/sys/unix"
"inet.af/netaddr"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/localapi"
"tailscale.com/logpolicy"
"tailscale.com/logtail"
"tailscale.com/logtail/filch"
"tailscale.com/net/dns"
"tailscale.com/net/interfaces"
"tailscale.com/net/netmon"
"tailscale.com/smallzstd"
"tailscale.com/util/clientmetric"
"tailscale.com/util/dnsname"
"tailscale.com/util/must"
"github.com/tailscale/wireguard-go/tun"
"tailscale.com/net/tsdial"
"tailscale.com/paths"
"tailscale.com/tsd"
"tailscale.com/types/logger"
"tailscale.com/types/logid"
"tailscale.com/wgengine"
"tailscale.com/wgengine/netstack"
"tailscale.com/wgengine/router"
)
/*
#cgo CFLAGS: -Wall
#include <jni.h>
#include <stdlib.h>
static jint jni_GetJavaVM(JNIEnv *env, JavaVM **jvm) {
return (*env)->GetJavaVM(env, jvm);
}
static jobject jni_NewGlobalRef(JNIEnv *env, jobject obj) {
return (*env)->NewGlobalRef(env, obj);
}
*/
import "C"
type App struct {
jvm *jni.JVM
// appCtx is a global reference to the com.tailscale.ipn.TailscaleApp instance.
appCtx jni.Object
store *stateStore
logIDPublicAtomic atomic.Pointer[logid.PublicID]
localAPI *localapiservice.LocalAPIService
backend *ipnlocal.LocalBackend
}
type stateStore struct {
jvm *jni.JVM
// appCtx is the global Android app context.
appCtx jni.Object
// Cached method ids on appCtx.
encrypt jni.MethodID
decrypt jni.MethodID
}
type backend struct {
engine wgengine.Engine
backend *ipnlocal.LocalBackend
sys *tsd.System
devices *multiTUN
settings settingsFunc
lastCfg *router.Config
lastDNSCfg *dns.OSConfig
netMon *netmon.Monitor
logIDPublic logid.PublicID
logger *logtail.Logger
// avoidEmptyDNS controls whether to use fallback nameservers
// when no nameservers are provided by Tailscale.
avoidEmptyDNS bool
jvm *jni.JVM
appCtx jni.Object
}
type settingsFunc func(*router.Config, *dns.OSConfig) error
const defaultMTU = 1280 // minimalMTU from wgengine/userspace.go
// googleDNSServers are used on ChromeOS, where an empty VpnBuilder DNS setting results
// in erasing the platform DNS servers. The developer docs say this is not supposed to happen,
// but nonetheless it does.
var googleDNSServers = []netip.Addr{
netip.MustParseAddr("8.8.8.8"),
netip.MustParseAddr("8.8.4.4"),
netip.MustParseAddr("2001:4860:4860::8888"),
netip.MustParseAddr("2001:4860:4860::8844"),
}
func init() {
a := App{}
err := localapiservice.ConfigureLocalApiJNIHandler(a.jvm, a.appCtx)
if err != nil {
fatalErr(err)
}
interfaces.RegisterInterfaceGetter(a.getInterfaces)
go func() {
ctx := context.Background()
if err := a.runBackend(ctx); err != nil {
fatalErr(err)
}
}()
app.Main()
}
func fatalErr(err error) {
log.Printf("fatal error: %v", err)
}
func (a *App) runBackend(ctx context.Context) error {
appDir, err := app.DataDir()
if err != nil {
fatalErr(err)
}
paths.AppSharedDir.Store(appDir)
type configPair struct {
rcfg *router.Config
dcfg *dns.OSConfig
}
configs := make(chan configPair)
configErrs := make(chan error)
b, err := newBackend(appDir, a.jvm, a.appCtx, a.store, func(rcfg *router.Config, dcfg *dns.OSConfig) error {
if rcfg == nil {
return nil
}
configs <- configPair{rcfg, dcfg}
return <-configErrs
})
if err != nil {
return err
}
a.logIDPublicAtomic.Store(&b.logIDPublic)
a.backend = b.backend
defer b.CloseTUNs()
h := localapi.NewHandler(b.backend, log.Printf, b.sys.NetMon.Get(), *a.logIDPublicAtomic.Load())
h.PermitRead = true
h.PermitWrite = true
a.localAPI = localapiservice.New(h)
// Share the localAPI with the JNI shim
localapiservice.SetLocalAPIService(a.localAPI)
startErr := make(chan error)
// Start from a goroutine to avoid deadlock when Start
// calls the callback.
go func() {
b.backend.Start(ipn.Options{})
}()
var (
cfg configPair
service jni.Object // of IPNService
)
for {
select {
case err := <-startErr:
if err != nil {
return err
}
case s := <-configs:
cfg = s
if b == nil || service == 0 || cfg.rcfg == nil {
configErrs <- nil
break
}
configErrs <- b.updateTUN(service, cfg.rcfg, cfg.dcfg)
}
}
}
// errVPNNotPrepared is used when VPNService.Builder.establish returns
// null, either because the VPNService is not yet prepared or because
// VPN status was revoked.
var errVPNNotPrepared = errors.New("VPN service not prepared or was revoked")
// errMultipleUsers is used when we get a "INTERACT_ACROSS_USERS" error, which
// happens due to a bug in Android. See:
//
// https://github.com/tailscale/tailscale/issues/2180
var errMultipleUsers = errors.New("VPN cannot be created on this device due to an Android bug with multiple users")
func newBackend(dataDir string, jvm *jni.JVM, appCtx jni.Object, store *stateStore,
settings settingsFunc) (*backend, error) {
sys := new(tsd.System)
sys.Set(store)
logf := logger.RusagePrefixLog(log.Printf)
b := &backend{
jvm: jvm,
devices: newTUNDevices(),
settings: settings,
appCtx: appCtx,
}
var logID logid.PrivateID
logID.UnmarshalText([]byte("dead0000dead0000dead0000dead0000dead0000dead0000dead0000dead0000"))
storedLogID, err := store.read(logPrefKey)
// In all failure cases we ignore any errors and continue with the dead value above.
if err != nil || storedLogID == nil {
// Read failed or there was no previous log id.
newLogID, err := logid.NewPrivateID()
if err == nil {
logID = newLogID
enc, err := newLogID.MarshalText()
if err == nil {
store.write(logPrefKey, enc)
}
}
} else {
logID.UnmarshalText([]byte(storedLogID))
}
netMon, err := netmon.New(logf)
if err != nil {
log.Printf("netmon.New: %w", err)
}
b.netMon = netMon
b.SetupLogs(dataDir, logID, logf)
dialer := new(tsdial.Dialer)
cb := &router.CallbackRouter{
SetBoth: b.setCfg,
SplitDNS: false,
GetBaseConfigFunc: b.getDNSBaseConfig,
}
engine, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
Tun: b.devices,
Router: cb,
DNS: cb,
Dialer: dialer,
SetSubsystem: sys.Set,
NetMon: b.netMon,
})
if err != nil {
return nil, fmt.Errorf("runBackend: NewUserspaceEngine: %v", err)
}
sys.Set(engine)
b.logIDPublic = logID.Public()
ns, err := netstack.Create(logf, sys.Tun.Get(), engine, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper(), nil)
if err != nil {
return nil, fmt.Errorf("netstack.Create: %w", err)
}
sys.Set(ns)
ns.ProcessLocalIPs = false // let Android kernel handle it; VpnBuilder sets this up
ns.ProcessSubnets = true // for Android-being-an-exit-node support
sys.NetstackRouter.Set(true)
if w, ok := sys.Tun.GetOK(); ok {
w.Start()
}
lb, err := ipnlocal.NewLocalBackend(logf, logID.Public(), sys, 0)
if err != nil {
engine.Close()
return nil, fmt.Errorf("runBackend: NewLocalBackend: %v", err)
}
if err := ns.Start(lb); err != nil {
return nil, fmt.Errorf("startNetstack: %w", err)
}
if b.logger != nil {
lb.SetLogFlusher(b.logger.StartFlush)
}
b.engine = engine
b.backend = lb
b.sys = sys
return b, nil
}
// SetupLogs sets up remote logging.
func (b *backend) SetupLogs(logDir string, logID logid.PrivateID, logf logger.Logf) {
if b.netMon == nil {
panic("netMon must be created prior to SetupLogs")
}
transport := logpolicy.NewLogtailTransport(logtail.DefaultHost, b.netMon, log.Printf)
logcfg := logtail.Config{
Collection: logtail.CollectionNode,
PrivateID: logID,
Stderr: log.Writer(),
MetricsDelta: clientmetric.EncodeLogTailMetricsDelta,
IncludeProcID: true,
IncludeProcSequence: true,
NewZstdEncoder: func() logtail.Encoder {
return must.Get(smallzstd.NewEncoder(nil))
},
HTTPC: &http.Client{Transport: transport},
}
logcfg.FlushDelayFn = func() time.Duration { return 2 * time.Minute }
filchOpts := filch.Options{
ReplaceStderr: true,
}
var filchErr error
if logDir != "" {
logPath := filepath.Join(logDir, "ipn.log.")
logcfg.Buffer, filchErr = filch.New(logPath, filchOpts)
}
b.logger = logtail.NewLogger(logcfg, logf)
log.SetFlags(0)
log.SetOutput(b.logger)
log.Printf("goSetupLogs: success")
if logDir == "" {
log.Printf("SetupLogs: no logDir, storing logs in memory")
}
if filchErr != nil {
log.Printf("SetupLogs: filch setup failed: %v", filchErr)
}
}
func (b *backend) NetworkChanged() {
if b.sys != nil {
if nm, ok := b.sys.NetMon.GetOK(); ok {
nm.InjectEvent()
}
}
}
func (b *backend) setCfg(rcfg *router.Config, dcfg *dns.OSConfig) error {
return b.settings(rcfg, dcfg)
}
func (b *backend) updateTUN(service jni.Object, rcfg *router.Config, dcfg *dns.OSConfig) error {
if reflect.DeepEqual(rcfg, b.lastCfg) && reflect.DeepEqual(dcfg, b.lastDNSCfg) {
return nil
}
// Close previous tunnel(s).
// This is necessary for ChromeOS, native Android devices
// seem to handle seamless handover between tunnels correctly.
//
// TODO(eliasnaur): If seamless handover becomes a desirable feature, skip
// the closing on ChromeOS.
b.CloseTUNs()
if len(rcfg.LocalAddrs) == 0 {
return nil
}
err := jni.Do(b.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, service)
// Construct a VPNService.Builder. IPNService.newBuilder calls
// setConfigureIntent, and allowFamily for both IPv4 and IPv6.
m := jni.GetMethodID(env, cls, "newBuilder", "()Landroid/net/VpnService$Builder;")
builder, err := jni.CallObjectMethod(env, service, m)
if err != nil {
return fmt.Errorf("IPNService.newBuilder: %v", err)
}
bcls := jni.GetObjectClass(env, builder)
// builder.setMtu.
setMtu := jni.GetMethodID(env, bcls, "setMtu", "(I)Landroid/net/VpnService$Builder;")
const mtu = defaultMTU
if _, err := jni.CallObjectMethod(env, builder, setMtu, jni.Value(mtu)); err != nil {
return fmt.Errorf("VpnService.Builder.setMtu: %v", err)
}
// builder.addDnsServer
addDnsServer := jni.GetMethodID(env, bcls, "addDnsServer", "(Ljava/lang/String;)Landroid/net/VpnService$Builder;")
// builder.addSearchDomain.
addSearchDomain := jni.GetMethodID(env, bcls, "addSearchDomain", "(Ljava/lang/String;)Landroid/net/VpnService$Builder;")
if dcfg != nil {
nameservers := dcfg.Nameservers
if b.avoidEmptyDNS && len(nameservers) == 0 {
nameservers = googleDNSServers
}
for _, dns := range nameservers {
_, err = jni.CallObjectMethod(env,
builder,
addDnsServer,
jni.Value(jni.JavaString(env, dns.String())),
)
if err != nil {
return fmt.Errorf("VpnService.Builder.addDnsServer(%v): %v", dns, err)
}
}
for _, dom := range dcfg.SearchDomains {
_, err = jni.CallObjectMethod(env,
builder,
addSearchDomain,
jni.Value(jni.JavaString(env, dom.WithoutTrailingDot())),
)
if err != nil {
return fmt.Errorf("VpnService.Builder.addSearchDomain(%v): %v", dom, err)
}
}
}
// builder.addRoute.
addRoute := jni.GetMethodID(env, bcls, "addRoute", "(Ljava/lang/String;I)Landroid/net/VpnService$Builder;")
for _, route := range rcfg.Routes {
// Normalize route address; Builder.addRoute does not accept non-zero masked bits.
route = route.Masked()
_, err = jni.CallObjectMethod(env,
builder,
addRoute,
jni.Value(jni.JavaString(env, route.Addr().String())),
jni.Value(route.Bits()),
)
if err != nil {
return fmt.Errorf("VpnService.Builder.addRoute(%v): %v", route, err)
}
}
// builder.addAddress.
addAddress := jni.GetMethodID(env, bcls, "addAddress", "(Ljava/lang/String;I)Landroid/net/VpnService$Builder;")
for _, addr := range rcfg.LocalAddrs {
_, err = jni.CallObjectMethod(env,
builder,
addAddress,
jni.Value(jni.JavaString(env, addr.Addr().String())),
jni.Value(addr.Bits()),
)
if err != nil {
return fmt.Errorf("VpnService.Builder.addAddress(%v): %v", addr, err)
}
}
// builder.establish.
establish := jni.GetMethodID(env, bcls, "establish", "()Landroid/os/ParcelFileDescriptor;")
parcelFD, err := jni.CallObjectMethod(env, builder, establish)
if err != nil {
if strings.Contains(err.Error(), "INTERACT_ACROSS_USERS") {
return errMultipleUsers
}
return fmt.Errorf("VpnService.Builder.establish: %v", err)
}
if parcelFD == 0 {
return errVPNNotPrepared
}
// detachFd.
parcelCls := jni.GetObjectClass(env, parcelFD)
detachFd := jni.GetMethodID(env, parcelCls, "detachFd", "()I")
tunFD, err := jni.CallIntMethod(env, parcelFD, detachFd)
if err != nil {
return fmt.Errorf("detachFd: %v", err)
}
// Create TUN device.
tunDev, _, err := tun.CreateUnmonitoredTUNFromFD(int(tunFD))
if err != nil {
unix.Close(int(tunFD))
return err
}
b.devices.add(tunDev)
return nil
})
if err != nil {
b.lastCfg = nil
b.CloseTUNs()
return err
}
b.lastCfg = rcfg
b.lastDNSCfg = dcfg
return nil
}
// CloseVPN closes any active TUN devices.
func (b *backend) CloseTUNs() {
b.lastCfg = nil
b.devices.Shutdown()
}
// Report interfaces in the device in net.Interface format.
func (a *App) getInterfaces() ([]interfaces.Interface, error) {
var ifaceString string
err := jni.Do(a.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, a.appCtx)
m := jni.GetMethodID(env, cls, "getInterfacesAsString", "()Ljava/lang/String;")
n, err := jni.CallObjectMethod(env, a.appCtx, m)
ifaceString = jni.GoString(env, jni.String(n))
return err
})
var ifaces []interfaces.Interface
if err != nil {
return ifaces, err
}
for _, iface := range strings.Split(ifaceString, "\n") {
// Example of the strings we're processing:
// 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
// mnet_data2 12 1500 true false false false false | fe80::3c8c:44dc:46a9:9907%rmnet_data2/64
if strings.TrimSpace(iface) == "" {
continue
}
fields := strings.Split(iface, "|")
if len(fields) != 2 {
log.Printf("getInterfaces: unable to split %q", iface)
continue
}
var name string
var index, mtu int
var up, broadcast, loopback, pointToPoint, multicast bool
_, err := fmt.Sscanf(fields[0], "%s %d %d %t %t %t %t %t",
&name, &index, &mtu, &up, &broadcast, &loopback, &pointToPoint, &multicast)
if err != nil {
log.Printf("getInterfaces: unable to parse %q: %v", iface, err)
continue
}
newIf := interfaces.Interface{
Interface: &net.Interface{
Name: name,
Index: index,
MTU: mtu,
},
AltAddrs: []net.Addr{}, // non-nil to avoid Go using netlink
}
if up {
newIf.Flags |= net.FlagUp
}
if broadcast {
newIf.Flags |= net.FlagBroadcast
}
if loopback {
newIf.Flags |= net.FlagLoopback
}
if pointToPoint {
newIf.Flags |= net.FlagPointToPoint
}
if multicast {
newIf.Flags |= net.FlagMulticast
}
addrs := strings.Trim(fields[1], " \n")
for _, addr := range strings.Split(addrs, " ") {
ip, err := netaddr.ParseIPPrefix(addr)
if err == nil {
newIf.AltAddrs = append(newIf.AltAddrs, ip.IPNet())
}
}
ifaces = append(ifaces, newIf)
}
return ifaces, nil
}
func (b *backend) getPlatformDNSConfig() string {
var baseConfig string
err := jni.Do(b.jvm, func(env *jni.Env) error {
cls := jni.GetObjectClass(env, b.appCtx)
m := jni.GetMethodID(env, cls, "getDnsConfigObj", "()Lcom/tailscale/ipn/DnsConfig;")
dns, err := jni.CallObjectMethod(env, b.appCtx, m)
if err != nil {
return fmt.Errorf("getDnsConfigObj: %v", err)
}
dnsCls := jni.GetObjectClass(env, dns)
m = jni.GetMethodID(env, dnsCls, "getDnsConfigAsString", "()Ljava/lang/String;")
n, err := jni.CallObjectMethod(env, dns, m)
baseConfig = jni.GoString(env, jni.String(n))
return err
})
if err != nil {
log.Printf("getPlatformDNSConfig JNI: %v", err)
return ""
}
return baseConfig
}
func (b *backend) getDNSBaseConfig() (ret dns.OSConfig, _ error) {
defer func() {
// If we couldn't find any base nameservers, ultimately fall back to
// Google's. Normally Tailscale doesn't ever pick a default nameserver
// for users but in this case Android's APIs for reading the underlying
// DNS config are lacking, and almost all Android phones use Google
// services anyway, so it's a reasonable default: it's an ecosystem the
// user has selected by having an Android device.
if len(ret.Nameservers) == 0 && googleSignInEnabled() {
log.Printf("getDNSBaseConfig: none found; falling back to Google public DNS")
ret.Nameservers = append(ret.Nameservers, googleDNSServers...)
}
}()
baseConfig := b.getPlatformDNSConfig()
lines := strings.Split(baseConfig, "\n")
if len(lines) == 0 {
return dns.OSConfig{}, nil
}
config := dns.OSConfig{}
addrs := strings.Trim(lines[0], " \n")
for _, addr := range strings.Split(addrs, " ") {
ip, err := netip.ParseAddr(addr)
if err == nil {
config.Nameservers = append(config.Nameservers, ip)
}
}
if len(lines) > 1 {
for _, s := range strings.Split(strings.Trim(lines[1], " \n"), " ") {
domain, err := dnsname.ToFQDN(s)
if err != nil {
log.Printf("getDNSBaseConfig: unable to parse %q: %v", s, err)
continue
}
config.SearchDomains = append(config.SearchDomains, domain)
}
}
return config, nil
}
// TODO
func googleSignInEnabled() bool {
return false
}
//export Java_com_tailscale_ipn_TailscaleApp_initRepository
func Java_com_tailscale_ipn_TailscaleApp_initRepository(env *C.JNIEnv, class C.jclass, jdataDir C.jbyteArray, context C.jobject) {
initJVM(env, class, context)
}
func initJVM(env *C.JNIEnv, gio C.jclass, ctx C.jobject) {
if res := C.jni_GetJavaVM(env, &App.jvm); res != 0 {
panic("GetJavaVM failed")
}
App.appCtx = C.jni_NewGlobalRef(env, ctx)
}

@ -0,0 +1,94 @@
/* Code generated by cmd/cgo; DO NOT EDIT. */
/* package github.com/tailscale/tailscale-android/cmd/tailscale */
#line 1 "cgo-builtin-export-prolog"
#include <stddef.h>
#ifndef GO_CGO_EXPORT_PROLOGUE_H
#define GO_CGO_EXPORT_PROLOGUE_H
#ifndef GO_CGO_GOSTRING_TYPEDEF
typedef struct { const char *p; ptrdiff_t n; } _GoString_;
#endif
#endif
/* Start of preamble from import "C" comments. */
#line 15 "callbacks.go"
#include <jni.h>
#line 1 "cgo-generated-wrapper"
/* End of preamble from import "C" comments. */
/* Start of boilerplate cgo prologue. */
#line 1 "cgo-gcc-export-header-prolog"
#ifndef GO_CGO_PROLOGUE_H
#define GO_CGO_PROLOGUE_H
typedef signed char GoInt8;
typedef unsigned char GoUint8;
typedef short GoInt16;
typedef unsigned short GoUint16;
typedef int GoInt32;
typedef unsigned int GoUint32;
typedef long long GoInt64;
typedef unsigned long long GoUint64;
typedef GoInt64 GoInt;
typedef GoUint64 GoUint;
typedef size_t GoUintptr;
typedef float GoFloat32;
typedef double GoFloat64;
#ifdef _MSC_VER
#include <complex.h>
typedef _Fcomplex GoComplex64;
typedef _Dcomplex GoComplex128;
#else
typedef float _Complex GoComplex64;
typedef double _Complex GoComplex128;
#endif
/*
static assertion to make sure the file is being used on architecture
at least with matching size of GoInt.
*/
typedef char _check_for_64_bit_pointer_matching_GoInt[sizeof(void*)==64/8 ? 1:-1];
#ifndef GO_CGO_GOSTRING_TYPEDEF
typedef _GoString_ GoString;
#endif
typedef void *GoMap;
typedef void *GoChan;
typedef struct { void *t; void *v; } GoInterface;
typedef struct { void *data; GoInt len; GoInt cap; } GoSlice;
#endif
/* End of boilerplate cgo prologue. */
#ifdef __cplusplus
extern "C" {
#endif
extern void Java_com_tailscale_ipn_App_onVPNPrepared(JNIEnv* env, jclass class);
extern void Java_com_tailscale_ipn_App_onWriteStorageGranted(JNIEnv* env, jclass class);
extern void Java_com_tailscale_ipn_IPNService_connect(JNIEnv* env, jobject this);
extern void Java_com_tailscale_ipn_IPNService_directConnect(JNIEnv* env, jobject this);
extern void Java_com_tailscale_ipn_IPNService_disconnect(JNIEnv* env, jobject this);
extern void Java_com_tailscale_ipn_StartVPNWorker_connect(JNIEnv* env, jobject this);
extern void Java_com_tailscale_ipn_StopVPNWorker_disconnect(JNIEnv* env, jobject this);
extern void Java_com_tailscale_ipn_Peer_onActivityResult0(JNIEnv* env, jclass cls, jobject act, jint reqCode, jint resCode);
extern void Java_com_tailscale_ipn_App_onShareIntent(JNIEnv* env, jclass cls, jint nfiles, jintArray jtypes, jobjectArray jmimes, jobjectArray jitems, jobjectArray jnames, jlongArray jsizes);
extern void Java_com_tailscale_ipn_App_onDnsConfigChanged(JNIEnv* env, jclass cls);
#ifdef __cplusplus
}
#endif
Loading…
Cancel
Save