android: switch to using gomobile

gomobile replaces our custom JNI bindings

Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
pull/220/head
Percy Wegmann 2 months ago committed by Percy Wegmann
parent 98a72c2963
commit 5e7e36e3bc

@ -18,3 +18,4 @@ Client][]. See also the dependencies in the [Tailscale CLI][].
- [{{.Name}}](https://pkg.go.dev/{{.Name}}) ([{{.LicenseName}}]({{.LicenseURL}}))
{{- end }}
- [tailscale.com](https://pkg.go.dev/tailscale.com) ([BSD-3-Clause](https://github.com/tailscale/tailscale/blob/HEAD/LICENSE))
- [Gio UI](https://gioui.org/) ([MIT License](https://git.sr.ht/~eliasnaur/gio/tree/main/item/LICENSE))

3
.gitignore vendored

@ -37,3 +37,6 @@ tailscale.jks
#IDE
.vscode
.idea
libtailscale.aar
libtailscale-sources.jar

@ -6,7 +6,6 @@ DEBUG_APK=tailscale-debug.apk
RELEASE_AAB=tailscale-release.aab
APPID=com.tailscale.ipn
AAR=android_legacy/libs/ipn.aar
AAR_NEXTGEN=android/libs/ipn.aar
KEYSTORE=tailscale.jks
KEYSTORE_ALIAS=tailscale
TAILSCALE_VERSION=$(shell ./version/tailscale-version.sh 200)
@ -136,12 +135,6 @@ $(AAR): toolchain checkandroidsdk
-ldflags "-X tailscale.com/version.longStamp=$(VERSIONNAME) -X tailscale.com/version.shortStamp=$(VERSIONNAME_SHORT) -X tailscale.com/version.gitCommitStamp=$(TAILSCALE_COMMIT) -X tailscale.com/version.extraGitCommitStamp=$(OUR_VERSION)" \
-buildmode archive -target android -appid $(APPID) -tags novulkan,tailscale_go -o $@ github.com/tailscale/tailscale-android/cmd/tailscale
$(AAR_NEXTGEN): $(AAR)
@mkdir -p android/libs && \
cp $(AAR) $(AAR_NEXTGEN)
lib: $(AAR_NEXTGEN)
# tailscale-debug.apk builds a debuggable APK with the Google Play SDK.
$(DEBUG_APK): $(AAR)
(cd android_legacy && ./gradlew test assemblePlayDebug)
@ -152,6 +145,33 @@ apk: $(DEBUG_APK)
run: install
adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.IPNActivity
GOMOBILE=/tmp/gopath/bin/gomobile
GOBIND=/tmp/gopath/bin/gobind
LIBTAILSCALE=android/libs/libtailscale.aar
LIBTAILSCALE_SOURCES=$(shell find libtailscale -name *.go) go.mod go.sum
$(GOMOBILE):
@echo "building gomobile" && \
export GOPATH=/tmp/gopath && \
mkdir -p $$GOPATH && \
export PATH=$$PATH:$$GOPATH/bin && \
go install golang.org/x/mobile/cmd/gomobile@latest
$(GOBIND): $(GOMOBILE)
@export GOPATH=/tmp/gopath && \
$(GOMOBILE) init
gomobile: $(GOBIND)
$(LIBTAILSCALE): $(LIBTAILSCALE_SOURCES) $(GOBIND)
@export GOPATH=/tmp/gopath && \
export PATH=$$PATH:$$GOPATH/bin && \
$(GOMOBILE) bind -target android -androidapi 26 ./libtailscale && \
mkdir -p android/libs/ && \
cp libtailscale.aar $(LIBTAILSCALE)
libtailscale: $(LIBTAILSCALE)
# tailscale-fdroid.apk builds a non-Google Play SDK, without the Google bits.
# This is effectively what the F-Droid build definition produces.
# This is useful for testing on e.g. Amazon Fire Stick devices.
@ -159,12 +179,12 @@ tailscale-fdroid.apk: $(AAR)
(cd android_legacy && ./gradlew test assembleFdroidDebug)
mv android_legacy/build/outputs/apk/fdroid/debug/android_legacy-fdroid-debug.apk $@
tailscale-new-fdroid.apk: $(AAR_NEXTGEN)
tailscale-new-fdroid.apk: $(LIBTAILSCALE)
(cd android && ./gradlew test assembleFdroidDebug)
mv android/build/outputs/apk/fdroid/debug/android-fdroid-debug.apk $@
tailscale-new-debug.apk:
(cd android && ./gradlew test buildAllGoLibs assemblePlayDebug)
tailscale-new-debug.apk: $(LIBTAILSCALE)
(cd android && ./gradlew test assemblePlayDebug)
mv android/build/outputs/apk/play/debug/android-play-debug.apk $@
tailscale-new-debug: tailscale-new-debug.apk
@ -190,4 +210,4 @@ clean:
-rm -rf android_legacy/build $(DEBUG_APK) $(RELEASE_AAB) $(AAR) tailscale-fdroid.apk
-pkill -f gradle
.PHONY: all clean install android_legacy/lib $(DEBUG_APK) $(RELEASE_AAB) $(AAR) release bump_version dockershell lib
.PHONY: all clean install android_legacy/lib $(DEBUG_APK) $(RELEASE_AAB) $(AAR) release bump_version dockershell lib tailscale-new-debug

@ -96,7 +96,7 @@ dependencies {
implementation("io.coil-kt:coil-compose:2.6.0")
// Tailscale dependencies.
implementation ':ipn@aar'
implementation ':libtailscale@aar'
// Tests
testImplementation "junit:junit:4.12"
@ -105,62 +105,3 @@ dependencies {
// Non-free dependencies.
playImplementation 'com.google.android.gms:play-services-auth:20.7.0'
}
def ndkPath = project.hasProperty('ndkPath') ? project.property('ndkPath') : System.getenv('ANDROID_SDK_ROOT')
task checkNDK {
doFirst {
if (ndkPath == null) {
throw new GradleException('NDK path not found. Please define ndkPath in local.properties or ANDROID_SDK_HOME environment variable.')
}
}
}
task buildGoLibArm64(type: Exec) {
inputs.dir '../pkg/tailscale'
outputs.file 'src/main/jniLibs/arm64-v8a/libtailscale.so'
environment "CC", "$ndkPath/ndk-bundle/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android30-clang"
commandLine 'sh', '-c', "GOOS=android GOARCH=arm64 CGO_ENABLED=1 go build -buildmode=c-shared -ldflags=-w -o src/main/jniLibs/arm64-v8a/libtailscale.so ../pkg/tailscale"
}
task buildGoLibArmeabi(type: Exec) {
inputs.dir '../pkg/tailscale'
outputs.file 'src/main/jniLibs/armeabi-v7a/libtailscale.so'
environment "CC", "$ndkPath/ndk-bundle/toolchains/llvm/prebuilt/darwin-x86_64/bin/armv7a-linux-androideabi30-clang"
commandLine 'sh', '-c', "GOOS=android GOARCH=arm CGO_ENABLED=1 go build -buildmode=c-shared -ldflags=-w -o src/main/jniLibs/armeabi-v7a/libtailscale.so ../pkg/tailscale"
}
task buildGoLibX86(type: Exec) {
inputs.dir '../pkg/tailscale'
outputs.file 'src/main/jniLibs/x86/libtailscale.so'
environment "CC", "$ndkPath/ndk-bundle/toolchains/llvm/prebuilt/darwin-x86_64/bin/i686-linux-android30-clang"
commandLine 'sh', '-c', "GOOS=android GOARCH=386 CGO_ENABLED=1 go build -buildmode=c-shared -ldflags=-w -o src/main/jniLibs/x86/libtailscale.so ../pkg/tailscale"
}
task buildGoLibX86_64(type: Exec) {
inputs.dir '../pkg/tailscale'
outputs.file 'src/main/jniLibs/x86_64/libtailscale.so'
environment "CC", "$ndkPath/ndk-bundle/toolchains/llvm/prebuilt/darwin-x86_64/bin/x86_64-linux-android30-clang"
commandLine 'sh', '-c', "GOOS=android GOARCH=amd64 CGO_ENABLED=1 go build -buildmode=c-shared -ldflags=-w -o src/main/jniLibs/x86_64/libtailscale.so ../pkg/tailscale"
}
task buildAllGoLibs {
dependsOn checkNDK, buildGoLibArm64, buildGoLibArmeabi, buildGoLibX86, buildGoLibX86_64
}
assemble.dependsOn buildAllGoLibs
task cleanGoLibs(type: Delete) {
delete 'src/main/jniLibs/arm64-v8a/libtailscale.so',
'src/main/jniLibs/armeabi-v7a/libtailscale.so',
'src/main/jniLibs/x86/libtailscale.so',
'src/main/jniLibs/x86_64/libtailscale.so'
}
clean.dependsOn cleanGoLibs
tasks.whenTaskAdded { task ->
if (task.name.startsWith('merge') && task.name.endsWith('JniLibFolders')) {
task.mustRunAfter buildAllGoLibs
}
}

@ -37,7 +37,6 @@
android:exported="true"
android:label="@string/app_name"
android:launchMode="singleTask"
android:theme="@style/Theme.GioApp"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

@ -33,6 +33,7 @@ import android.os.Handler;
import android.os.Looper;
import android.provider.MediaStore;
import android.provider.Settings;
import android.util.Log;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.core.app.NotificationCompat;
@ -46,6 +47,8 @@ import com.tailscale.ipn.mdm.BooleanSetting;
import com.tailscale.ipn.mdm.MDMSettings;
import com.tailscale.ipn.mdm.ShowHideSetting;
import com.tailscale.ipn.mdm.StringSetting;
import com.tailscale.ipn.ui.localapi.Request;
import com.tailscale.ipn.ui.notifier.Notifier;
import java.io.File;
import java.io.IOException;
@ -57,7 +60,9 @@ import java.util.Collections;
import java.util.List;
import java.util.Objects;
public class App extends Application {
import libtailscale.Libtailscale;
public class App extends Application implements libtailscale.AppContext {
static final String STATUS_CHANNEL_ID = "tailscale-status";
static final int STATUS_NOTIFICATION_ID = 1;
static final String NOTIFY_CHANNEL_ID = "tailscale-notify";
@ -74,9 +79,17 @@ public class App extends Application {
private ConnectivityManager connectivityManager;
public static App getApplication() {
// TODO: this should be injected to MDMSettings by grabbing it from the activity's context
// rather than being a static singleton.
return _application;
}
private libtailscale.Application app;
public libtailscale.Application getTailscaleApp() {
return app;
}
private static boolean isEmpty(String str) {
return str == null || str.length() == 0;
}
@ -86,34 +99,33 @@ public class App extends Application {
f.startActivityForResult(intent, request);
}
static native void initBackend(byte[] dataDir, Context context);
static native void onVPNPrepared();
private static native void onDnsConfigChanged();
public DnsConfig getDnsConfigObj() {
return this.dns;
}
static native void onShareIntent(int nfiles, int[] types, String[] mimes, String[] items, String[] names, long[] sizes);
@Override
public String getPlatformDNSConfig() {
return dns.getDnsConfigAsString();
}
static native void onWriteStorageGranted();
@Override
public boolean isPlayVersion() {
return MaybeGoogle.isGoogle();
}
public DnsConfig getDnsConfigObj() {
return this.dns;
@Override
public void log(String s, String s1) {
Log.d(s, s1);
}
@Override
public void onCreate() {
super.onCreate();
System.loadLibrary("tailscale");
String dataDir = this.getFilesDir().getAbsolutePath();
byte[] dataDirUTF8;
try {
dataDirUTF8 = dataDir.getBytes("UTF-8");
initBackend(dataDirUTF8, this);
} catch (Exception e) {
android.util.Log.d("tailscale", "Error getting directory");
}
app = Libtailscale.start(dataDir, this);
Request.setApp(app);
Notifier.setApp(app);
this.connectivityManager = (ConnectivityManager) this.getSystemService(Context.CONNECTIVITY_SERVICE);
setAndRegisterNetworkCallbacks();
@ -145,13 +157,13 @@ public class App extends Application {
}
dns.updateDNSFromNetwork(sb.toString());
onDnsConfigChanged();
Libtailscale.onDnsConfigChanged();
}
@Override
public void onLost(Network network) {
super.onLost(network);
onDnsConfigChanged();
Libtailscale.onDnsConfigChanged();
}
});
}
@ -221,7 +233,7 @@ public class App extends Application {
return getModelName();
}
String getModelName() {
public String getModelName() {
String manu = Build.MANUFACTURER;
String model = Build.MODEL;
// Strip manufacturer from model.
@ -233,7 +245,7 @@ public class App extends Application {
return manu + " " + model;
}
String getOSVersion() {
public String getOSVersion() {
return Build.VERSION.RELEASE;
}
@ -259,7 +271,7 @@ public class App extends Application {
});
}
boolean isChromeOS() {
public boolean isChromeOS() {
return getPackageManager().hasSystemFeature("android.hardware.type.pc");
}
@ -269,7 +281,7 @@ public class App extends Application {
public void run() {
Intent intent = VpnService.prepare(act);
if (intent == null) {
onVPNPrepared();
Libtailscale.onVPNPrepared();
} else {
startActivityForResult(act, intent, reqCode);
}
@ -386,7 +398,7 @@ public class App extends Application {
//
// Where the fields are:
// name ifindex mtu isUp hasBroadcast isLoopback isPointToPoint hasMulticast | ip1/N ip2/N ip3/N;
String getInterfacesAsString() {
public String getInterfacesAsString() {
List<NetworkInterface> interfaces;
try {
interfaces = Collections.list(NetworkInterface.getNetworkInterfaces());

@ -14,6 +14,8 @@ import android.provider.OpenableColumns;
import java.util.List;
import libtailscale.Libtailscale;
public final class IPNActivity extends Activity {
final static int WRITE_STORAGE_RESULT = 1000;
@ -82,14 +84,15 @@ public final class IPNActivity extends Activity {
nfiles++;
}
}
App.onShareIntent(nfiles, types, mimes, items, names, sizes);
// TODO(oxtoacart): actually implement this
// App.onShareIntent(nfiles, types, mimes, items, names, sizes);
}
@Override
public void onRequestPermissionsResult(int reqCode, String[] perms, int[] grants) {
if (reqCode == WRITE_STORAGE_RESULT) {
if (grants.length > 0 && grants[0] == PackageManager.PERMISSION_GRANTED) {
App.onWriteStorageGranted();
Libtailscale.onWriteStorageGranted();
}
}
}

@ -13,10 +13,21 @@ import android.system.OsConstants;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
public class IPNService extends VpnService {
import java.util.UUID;
import libtailscale.Libtailscale;
public class IPNService extends VpnService implements libtailscale.IPNService {
public static final String ACTION_REQUEST_VPN = "com.tailscale.ipn.REQUEST_VPN";
public static final String ACTION_STOP_VPN = "com.tailscale.ipn.STOP_VPN";
private final String randomID = UUID.randomUUID().toString();
@Override
public String id() {
return randomID;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && ACTION_STOP_VPN.equals(intent.getAction())) {
@ -30,21 +41,17 @@ public class IPNService extends VpnService {
i.setPackage(getPackageName());
i.setClass(getApplicationContext(), com.tailscale.ipn.IPNReceiver.class);
sendBroadcast(i);
requestVPN();
connect();
Libtailscale.requestVPN(this);
return START_STICKY;
}
requestVPN();
Libtailscale.requestVPN(this);
App app = ((App) getApplicationContext());
if (app.vpnReady && app.autoConnect) {
connect();
}
return START_STICKY;
}
private void close() {
stopForeground(true);
disconnect();
Libtailscale.serviceDisconnect(this);
}
@Override
@ -71,7 +78,7 @@ public class IPNService extends VpnService {
}
}
protected VpnService.Builder newBuilder() {
public libtailscale.VPNServiceBuilder newBuilder() {
VpnService.Builder b = new VpnService.Builder()
.setConfigureIntent(configIntent())
.allowFamily(OsConstants.AF_INET)
@ -100,7 +107,7 @@ public class IPNService extends VpnService {
// Google Chromecast https://github.com/tailscale/tailscale/issues/3636
this.disallowApp(b, "com.google.android.apps.chromecast.app");
return b;
return new VPNServiceBuilder(b);
}
public void notify(String title, String message) {
@ -127,10 +134,4 @@ public class IPNService extends VpnService {
startForeground(App.STATUS_NOTIFICATION_ID, builder.build());
}
private native void requestVPN();
private native void disconnect();
private native void connect();
}

@ -0,0 +1,35 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn;
import android.app.Activity;
import java.lang.reflect.Method;
public class MaybeGoogle {
static boolean isGoogle() {
return getGoogle() != null;
}
static String getIdTokenForActivity(Activity act) {
Class<?> google = getGoogle();
if (google == null) {
return "";
}
try {
Method method = google.getMethod("getIdTokenForActivity", Activity.class);
return (String) method.invoke(null, act);
} catch (Exception e) {
return "";
}
}
private static Class getGoogle() {
try {
return Class.forName("com.tailscale.ipn.Google");
} catch (ClassNotFoundException e) {
return null;
}
}
}

@ -3,15 +3,14 @@
package com.tailscale.ipn;
import android.app.Activity;
import android.app.Fragment;
import android.content.Intent;
public class Peer extends Fragment {
private static native void onActivityResult0(Activity act, int reqCode, int resCode);
import libtailscale.Libtailscale;
public class Peer extends Fragment {
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
onActivityResult0(getActivity(), requestCode, resultCode);
Libtailscale.onActivityResult(requestCode, resultCode, MaybeGoogle.getIdTokenForActivity(getActivity()));
}
}

@ -0,0 +1,40 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.net.VpnService
import libtailscale.ParcelFileDescriptor
class VPNServiceBuilder(private val builder: VpnService.Builder) : libtailscale.VPNServiceBuilder {
override fun addAddress(p0: String, p1: Int) {
builder.addAddress(p0, p1)
}
override fun addDNSServer(p0: String) {
builder.addDnsServer(p0)
}
override fun addRoute(p0: String, p1: Int) {
builder.addRoute(p0, p1)
}
override fun addSearchDomain(p0: String) {
builder.addSearchDomain(p0)
}
override fun establish(): ParcelFileDescriptor? {
return builder.establish()?.let { ParcelFileDescriptor(it) }
}
override fun setMTU(p0: Long) {
TODO("Not yet implemented")
}
}
class ParcelFileDescriptor(private val fd: android.os.ParcelFileDescriptor) :
libtailscale.ParcelFileDescriptor {
override fun detach(): Int {
return fd.detachFd()
}
}

@ -9,7 +9,7 @@ import com.tailscale.ipn.ui.model.Errors
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.IpnState
import kotlinx.coroutines.CompletableDeferred
import com.tailscale.ipn.ui.util.InputStreamAdapter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -18,6 +18,7 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.serializer
import java.nio.charset.Charset
import kotlin.reflect.KType
import kotlin.reflect.typeOf
@ -98,7 +99,7 @@ class Client(private val scope: CoroutineScope) {
return post(Endpoint.PROFILES + profile.ID, responseHandler = responseHandler)
}
fun startLoginInteractive(responseHandler: (Result<String>) -> Unit) {
fun startLoginInteractive(responseHandler: (Result<Unit>) -> Unit) {
return post(Endpoint.LOGIN_INTERACTIVE, responseHandler = responseHandler)
}
@ -185,6 +186,7 @@ class Request<T>(
private val method: String,
path: String,
private val body: ByteArray? = null,
private val timeoutMillis: Long = 30000,
private val responseType: KType,
private val responseHandler: (Result<T>) -> Unit
) {
@ -194,63 +196,58 @@ class Request<T>(
private const val TAG = "LocalAPIRequest"
private val jsonDecoder = Json { ignoreUnknownKeys = true }
private val isReady = CompletableDeferred<Boolean>()
// Called by the backend when the localAPI is ready to accept requests.
private lateinit var app: libtailscale.Application
@JvmStatic
@Suppress("unused")
fun onReady() {
isReady.complete(true)
Log.d(TAG, "Ready")
fun setApp(newApp: libtailscale.Application) {
app = newApp
}
}
// 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.
// @see cmd/localapiclient/localapishim.go
//
// method: The HTTP method to use.
// request: The path to the localAPI endpoint.
// body: The body of the request.
private external fun doRequest(method: String, request: String, body: ByteArray?)
@OptIn(ExperimentalSerializationApi::class)
fun execute() {
scope.launch(Dispatchers.IO) {
isReady.await()
Log.d(TAG, "Executing request:${method}:${fullPath}")
doRequest(method, fullPath, body)
}
}
// This is called from the JNI layer to publish responses.
@OptIn(ExperimentalSerializationApi::class)
@Suppress("unused", "UNCHECKED_CAST")
fun onResponse(respData: ByteArray) {
Log.d(TAG, "Response for request: $fullPath")
val response: Result<T> =
when (responseType) {
typeOf<String>() -> Result.success(respData.decodeToString() as T)
else ->
try {
Result.success(
jsonDecoder.decodeFromStream(
Json.serializersModule.serializer(responseType), respData.inputStream())
as T)
} catch (t: Throwable) {
// If we couldn't parse the response body, assume it's an error response
try {
val error =
jsonDecoder.decodeFromStream<Errors.GenericError>(respData.inputStream())
throw Exception(error.error)
} catch (t: Throwable) {
Result.failure(t)
}
}
Log.d(TAG, "Executing request:${method}:${fullPath} on app $app")
try {
val resp =
app.callLocalAPI(
timeoutMillis, method, fullPath, body?.let { InputStreamAdapter(it.inputStream()) })
// TODO: use the streaming body for performance
Log.d(TAG, "Got Response")
val respData = resp.bodyBytes()
Log.d(TAG, "Got response body")
val response: Result<T> =
when (responseType) {
typeOf<String>() -> Result.success(respData.decodeToString() as T)
typeOf<Unit>() -> Result.success(Unit as T)
else ->
try {
Result.success(
jsonDecoder.decodeFromStream(
Json.serializersModule.serializer(responseType), respData.inputStream())
as T)
} catch (t: Throwable) {
// If we couldn't parse the response body, assume it's an error response
try {
val error =
jsonDecoder.decodeFromStream<Errors.GenericError>(respData.inputStream())
throw Exception(error.error)
} catch (t: Throwable) {
Result.failure(t)
}
}
}
if (resp.statusCode() >= 400) {
throw Exception(
"Request failed with status ${resp.statusCode()}: ${respData.toString(Charset.defaultCharset())}")
}
// The response handler will invoked internally by the request parser
scope.launch { responseHandler(response) }
// The response handler will invoked internally by the request parser
scope.launch { responseHandler(response) }
} catch (e: Exception) {
Log.e(TAG, "Error executing request:${method}:${fullPath}: $e")
scope.launch { responseHandler(Result.failure(e)) }
}
}
}
}

@ -8,7 +8,6 @@ import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Ipn.Notify
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@ -29,7 +28,6 @@ import kotlinx.serialization.json.decodeFromStream
object Notifier {
private val TAG = Notifier::class.simpleName
private val decoder = Json { ignoreUnknownKeys = true }
private val isReady = CompletableDeferred<Boolean>()
val state: StateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState)
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
@ -39,61 +37,48 @@ object Notifier {
val browseToURL: StateFlow<String?> = MutableStateFlow(null)
val loginFinished: StateFlow<String?> = MutableStateFlow(null)
val version: StateFlow<String?> = MutableStateFlow(null)
// Indicates whether or not we have granted permission to use the VPN. This must be
// explicitly set by the main activity. null indicates that we have not yet
// checked.
val vpnPermissionGranted: StateFlow<Boolean?> = MutableStateFlow(null)
// Called by the backend when the localAPI is ready to accept requests.
private lateinit var app: libtailscale.Application
private var manager: libtailscale.NotificationManager? = null
@JvmStatic
@Suppress("unused")
fun onReady() {
isReady.complete(true)
Log.d(TAG, "Ready")
fun setApp(newApp: libtailscale.Application) {
app = newApp
}
@OptIn(ExperimentalSerializationApi::class)
fun start(scope: CoroutineScope) {
Log.d(TAG, "Starting")
scope.launch(Dispatchers.IO) {
// Wait for the notifier to be ready
isReady.await()
val mask =
NotifyWatchOpt.Netmap.value or
NotifyWatchOpt.Prefs.value or
NotifyWatchOpt.InitialState.value
startIPNBusWatcher(mask)
manager =
app.watchNotifications(mask.toLong()) { notification ->
val notify = decoder.decodeFromStream<Notify>(notification.inputStream())
notify.State?.let { state.set(Ipn.State.fromInt(it)) }
notify.NetMap?.let(netmap::set)
notify.Prefs?.let(prefs::set)
notify.Engine?.let(engineStatus::set)
notify.TailFSShares?.let(tailFSShares::set)
notify.BrowseToURL?.let(browseToURL::set)
notify.LoginFinished?.let { loginFinished.set(it.property) }
notify.Version?.let(version::set)
}
Log.d(TAG, "Stopped")
}
}
fun stop() {
Log.d(TAG, "Stopping")
stopIPNBusWatcher()
}
// Callback from jni when a new notification is received
@OptIn(ExperimentalSerializationApi::class)
@JvmStatic
@Suppress("unused")
fun onNotify(notification: ByteArray) {
val notify = decoder.decodeFromStream<Notify>(notification.inputStream())
notify.State?.let { state.set(Ipn.State.fromInt(it)) }
notify.NetMap?.let(netmap::set)
notify.Prefs?.let(prefs::set)
notify.Engine?.let(engineStatus::set)
notify.TailFSShares?.let(tailFSShares::set)
notify.BrowseToURL?.let(browseToURL::set)
notify.LoginFinished?.let { loginFinished.set(it.property) }
notify.Version?.let(version::set)
manager?.let {
it.stop()
manager = null
}
}
// Starts watching the IPN Bus. This is blocking.
private external fun startIPNBusWatcher(mask: Int)
// Stop watching the IPN Bus. This is non-blocking.
private external fun stopIPNBusWatcher()
// NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which
// what we want to see on the Notify bus
private enum class NotifyWatchOpt(val value: Int) {

@ -0,0 +1,21 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import java.io.InputStream
class InputStreamAdapter(private val inputStream: InputStream) : libtailscale.InputStream {
override fun read(): ByteArray? {
val b = ByteArray(4096)
val i = inputStream.read(b)
if (i == -1) {
return null
}
return b.sliceArray(0..i)
}
override fun close() {
inputStream.close()
}
}

@ -100,7 +100,7 @@ open class IpnViewModel : ViewModel() {
context.sendBroadcast(intent)
}
fun login(completionHandler: (Result<String>) -> Unit = {}) {
fun login(completionHandler: (Result<Unit>) -> Unit = {}) {
Client(viewModelScope).startLoginInteractive { result ->
result
.onSuccess { Log.d(TAG, "Login started: $it") }

@ -9,8 +9,9 @@ require (
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/tailscale/wireguard-go v0.0.0-20231121184858-cc193a0b3272
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a
golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91
golang.org/x/sys v0.17.0
golang.org/x/exp/shiny v0.0.0-20230817173708-d852ddb80c63
golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87
golang.org/x/sys v0.18.0
inet.af/netaddr v0.0.0-20220617031823-097006376321
tailscale.com v1.61.0-pre.0.20240311120500-7429e8912acb
)
@ -85,15 +86,15 @@ require (
go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
go4.org/unsafe/assume-no-moving-gc v0.0.0-20230525183740-e7c30c78aeb2 // indirect
golang.org/x/crypto v0.18.0 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/image v0.15.0 // indirect
golang.org/x/mod v0.14.0 // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/mod v0.16.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/sync v0.6.0 // indirect
golang.org/x/term v0.16.0 // indirect
golang.org/x/term v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.17.0 // indirect
golang.org/x/tools v0.19.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
gvisor.dev/gvisor v0.0.0-20240306221502-ee1e1f6070e3 // indirect

@ -477,15 +477,15 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
golang.org/x/exp v0.0.0-20210722180016-6781d3edade3/go.mod h1:DVyR6MI7P4kEQgvZJSj1fQGrWIi2RzIrfYWycwheUAc=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA=
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91 h1:ryT6Nf0R83ZgD8WnFFdfI8wCeyqgdXWN4+CkFVNPAT0=
golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91/go.mod h1:VjAR7z0ngyATZTELrBSkxOOHhhlnVUxDye4mcjx5h/8=
golang.org/x/exp/shiny v0.0.0-20230817173708-d852ddb80c63 h1:3AGKexOYqL+ztdWdkB1bDwXgPBuTS/S8A4WzuTvJ8Cg=
golang.org/x/exp/shiny v0.0.0-20230817173708-d852ddb80c63/go.mod h1:UH99kUObWAZkDnWqppdQe5ZhPYESUw8I0zVV1uWBR+0=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20210504121937-7319ad40d33e/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@ -499,14 +499,16 @@ golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHl
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4=
golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87 h1:dm00oNtDy265HReLTARPfIDXTRb2IG0jqQVpn7p5MKE=
golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87/go.mod h1:DN+F2TpepQEh5goqWnM3gopfFakSWM8OmHiz0rPRjT4=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -525,8 +527,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -568,11 +570,11 @@ golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -600,8 +602,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

@ -1,15 +1,19 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
package libtailscale
import (
"context"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"runtime/debug"
"sync"
"sync/atomic"
jnipkg "github.com/tailscale/tailscale-android/pkg/jni"
"github.com/tailscale/tailscale-android/pkg/localapiservice"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
@ -29,12 +33,44 @@ import (
"tailscale.com/wgengine/router"
)
import "C"
type App struct {
dataDir string
// appCtx is a global reference to the com.tailscale.ipn.App instance.
appCtx AppContext
type BackendState struct {
State ipn.State
NetworkMap *netmap.NetworkMap
LostInternet bool
store *stateStore
logIDPublicAtomic atomic.Pointer[logid.PublicID]
localAPIHandler http.Handler
backend *ipnlocal.LocalBackend
ready sync.WaitGroup
}
func start(dataDir string, appCtx AppContext) Application {
defer func() {
if p := recover(); p != nil {
log.Printf("panic in Start %s: %s", p, debug.Stack())
panic(p)
}
}()
initLogging(appCtx)
// Set XDG_CACHE_HOME to make os.UserCacheDir work.
if _, exists := os.LookupEnv("XDG_CACHE_HOME"); !exists {
cachePath := filepath.Join(dataDir, "cache")
os.Setenv("XDG_CACHE_HOME", cachePath)
}
// Set XDG_CONFIG_HOME to make os.UserConfigDir work.
if _, exists := os.LookupEnv("XDG_CONFIG_HOME"); !exists {
cfgPath := filepath.Join(dataDir, "config")
os.Setenv("XDG_CONFIG_HOME", cfgPath)
}
// Set HOME to make os.UserHomeDir work.
if _, exists := os.LookupEnv("HOME"); !exists {
os.Setenv("HOME", dataDir)
}
return newApp(dataDir, appCtx)
}
type backend struct {
@ -54,20 +90,15 @@ type backend struct {
// when no nameservers are provided by Tailscale.
avoidEmptyDNS bool
jvm *jnipkg.JVM
appCtx jnipkg.Object
appCtx AppContext
}
type settingsFunc func(*router.Config, *dns.OSConfig) error
func (a *App) runBackend(ctx context.Context) error {
appDir, err := dataDir()
if err != nil {
fatalErr(err)
}
paths.AppSharedDir.Store(appDir)
paths.AppSharedDir.Store(a.dataDir)
hostinfo.SetOSVersion(a.osVersion())
if !googleSignInEnabled() {
if !a.appCtx.IsPlayVersion() {
hostinfo.SetPackage("nogoogle")
}
deviceModel := a.modelName()
@ -82,7 +113,7 @@ func (a *App) runBackend(ctx context.Context) error {
}
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 {
b, err := newBackend(a.dataDir, a.appCtx, a.store, func(rcfg *router.Config, dcfg *dns.OSConfig) error {
if rcfg == nil {
return nil
}
@ -99,11 +130,9 @@ func (a *App) runBackend(ctx context.Context) error {
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)
a.localAPIHandler = h
// Share the localAPI with the JNI shim
//localapiservice.SetLocalAPIService(a.localAPI)
localapiservice.ConfigureShim(a.jvm, a.appCtx, a.localAPI, b.backend)
a.ready.Done()
// Contrary to the documentation for VpnService.Builder.addDnsServer,
// ChromeOS doesn't fall back to the underlying network nameservers if
@ -111,68 +140,72 @@ func (a *App) runBackend(ctx context.Context) error {
b.avoidEmptyDNS = a.isChromeOS()
var (
cfg configPair
state BackendState
service jnipkg.Object // of IPNService
cfg configPair
state ipn.State
networkMap *netmap.NetworkMap
service IPNService
)
stateCh := make(chan ipn.State)
netmapCh := make(chan *netmap.NetworkMap)
go b.backend.WatchNotifications(ctx, ipn.NotifyInitialNetMap|ipn.NotifyInitialPrefs|ipn.NotifyInitialState, func() {}, func(notify *ipn.Notify) bool {
if notify.State != nil {
stateCh <- *notify.State
}
if notify.NetMap != nil {
netmapCh <- notify.NetMap
}
return true
})
for {
select {
case s := <-stateCh:
state = s
case n := <-netmapCh:
networkMap = n
case c := <-configs:
cfg = c
if b == nil || service == 0 || cfg.rcfg == nil {
if b == nil || service == nil || cfg.rcfg == nil {
configErrs <- nil
break
}
configErrs <- b.updateTUN(service, cfg.rcfg, cfg.dcfg)
case s := <-onVPNRequested:
jnipkg.Do(a.jvm, func(env *jnipkg.Env) error {
if jnipkg.IsSameObject(env, s, service) {
// We already have a reference.
jnipkg.DeleteGlobalRef(env, s)
return nil
}
if service != 0 {
jnipkg.DeleteGlobalRef(env, service)
if service != nil && service.ID() == s.ID() {
// Still the same VPN instance, do nothing
break
}
netns.SetAndroidProtectFunc(func(fd int) error {
if !s.Protect(int32(fd)) {
// TODO(bradfitz): return an error back up to netns if this fails, once
// we've had some experience with this and analyzed the logs over a wide
// range of Android phones. For now we're being paranoid and conservative
// and do the JNI call to protect best effort, only logging if it fails.
// The risk of returning an error is that it breaks users on some Android
// versions even when they're not using exit nodes. I'd rather the
// relatively few number of exit node users file bug reports if Tailscale
// doesn't work and then we can look for this log print.
log.Printf("[unexpected] VpnService.protect(%d) returned false", fd)
}
netns.SetAndroidProtectFunc(func(fd int) error {
return jnipkg.Do(a.jvm, func(env *jnipkg.Env) error {
// Call https://developer.android.com/reference/android/net/VpnService#protect(int)
// to mark fd as a socket that should bypass the VPN and use the underlying network.
cls := jnipkg.GetObjectClass(env, s)
m := jnipkg.GetMethodID(env, cls, "protect", "(I)Z")
ok, err := jnipkg.CallBooleanMethod(env, s, m, jnipkg.Value(fd))
// TODO(bradfitz): return an error back up to netns if this fails, once
// we've had some experience with this and analyzed the logs over a wide
// range of Android phones. For now we're being paranoid and conservative
// and do the JNI call to protect best effort, only logging if it fails.
// The risk of returning an error is that it breaks users on some Android
// versions even when they're not using exit nodes. I'd rather the
// relatively few number of exit node users file bug reports if Tailscale
// doesn't work and then we can look for this log print.
if err != nil || !ok {
log.Printf("[unexpected] VpnService.protect(%d) = %v, %v", fd, ok, err)
}
return nil // even on error. see big TODO above.
})
})
log.Printf("onVPNRequested: rebind required")
// TODO(catzkorn): When we start the android application
// we bind sockets before we have access to the VpnService.protect()
// function which is needed to avoid routing loops. When we activate
// the service we get access to the protect, but do not retrospectively
// protect the sockets already opened, which breaks connectivity.
// As a temporary fix, we rebind and protect the magicsock.Conn on connect
// which restores connectivity.
// See https://github.com/tailscale/corp/issues/13814
b.backend.DebugRebind()
service = s
return nil
return nil // even on error. see big TODO above.
})
if m := state.NetworkMap; m != nil {
log.Printf("onVPNRequested: rebind required")
// TODO(catzkorn): When we start the android application
// we bind sockets before we have access to the VpnService.protect()
// function which is needed to avoid routing loops. When we activate
// the service we get access to the protect, but do not retrospectively
// protect the sockets already opened, which breaks connectivity.
// As a temporary fix, we rebind and protect the magicsock.Conn on connect
// which restores connectivity.
// See https://github.com/tailscale/corp/issues/13814
b.backend.DebugRebind()
service = s
if networkMap != nil {
// TODO
}
if cfg.rcfg != nil && state.State >= ipn.Starting {
if cfg.rcfg != nil && state >= ipn.Starting {
if err := b.updateTUN(service, cfg.rcfg, cfg.dcfg); err != nil {
log.Printf("VPN update failed: %v", err)
notifyVPNClosed()
@ -180,16 +213,11 @@ func (a *App) runBackend(ctx context.Context) error {
}
case s := <-onDisconnect:
b.CloseTUNs()
jnipkg.Do(a.jvm, func(env *jnipkg.Env) error {
defer jnipkg.DeleteGlobalRef(env, s)
if jnipkg.IsSameObject(env, service, s) {
netns.SetAndroidProtectFunc(nil)
jnipkg.DeleteGlobalRef(env, service)
service = 0
}
return nil
})
if state.State >= ipn.Starting {
if service != nil && service.ID() == s.ID() {
netns.SetAndroidProtectFunc(nil)
service = nil
}
if state >= ipn.Starting {
notifyVPNClosed()
}
case <-onDNSConfigChanged:
@ -200,7 +228,7 @@ func (a *App) runBackend(ctx context.Context) error {
}
}
func newBackend(dataDir string, jvm *jnipkg.JVM, appCtx jnipkg.Object, store *stateStore,
func newBackend(dataDir string, appCtx AppContext, store *stateStore,
settings settingsFunc) (*backend, error) {
sys := new(tsd.System)
@ -208,7 +236,6 @@ func newBackend(dataDir string, jvm *jnipkg.JVM, appCtx jnipkg.Object, store *st
logf := logger.RusagePrefixLog(log.Printf)
b := &backend{
jvm: jvm,
devices: newTUNDevices(),
settings: settings,
appCtx: appCtx,
@ -281,5 +308,12 @@ func newBackend(dataDir string, jvm *jnipkg.JVM, appCtx jnipkg.Object, store *st
b.engine = engine
b.backend = lb
b.sys = sys
go func() {
err := lb.Start(ipn.Options{})
if err != nil {
log.Printf("Failed to start LocalBackend, panicking: %s", err)
panic(err)
}
}()
return b, nil
}

@ -0,0 +1,129 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package libtailscale
import (
"sync"
)
var (
// onVPNPrepared is notified when VpnService.prepare succeeds.
onVPNPrepared = make(chan struct{}, 1)
// onVPNClosed is notified when VpnService.prepare fails, or when
// the a running VPN connection is closed.
onVPNClosed = make(chan struct{}, 1)
// onVPNRevoked is notified whenever the VPN service is revoked.
onVPNRevoked = make(chan struct{}, 1)
// onVPNRequested receives global IPNService references when
// a VPN connection is requested.
onVPNRequested = make(chan IPNService)
// onDisconnect receives global IPNService references when
// disconnecting.
onDisconnect = make(chan IPNService)
// onGoogleToken receives google ID tokens.
onGoogleToken = make(chan string)
// onWriteStorageGranted is notified when we are granted WRITE_STORAGE_PERMISSION.
onWriteStorageGranted = make(chan struct{}, 1)
// onDNSConfigChanged is notified when the network changes and the DNS config needs to be updated.
onDNSConfigChanged = make(chan struct{}, 1)
)
const (
// Request codes for Android callbacks.
// requestSignin is for Google Sign-In.
requestSignin = 1000 + iota
// requestPrepareVPN is for when Android's VpnService.prepare
// completes.
requestPrepareVPN
)
// resultOK is Android's Activity.RESULT_OK.
const resultOK = -1
func OnShareIntent(nfiles int32, types []int32, mimes []string, items []string, names []string, sizes []int) {
// TODO(oxtoacart): actually implement this
// const (
// typeNone = 0
// typeInline = 1
// typeURI = 2
// )
// jenv := (*jni.Env)(unsafe.Pointer(env))
// var files []File
// for i := 0; i < int(nfiles); i++ {
// f := File{
// Type: FileType(types[i]),
// MIMEType: mimes[i],
// Name: names[i],
// }
// if f.Name == "" {
// f.Name = "file.bin"
// }
// switch f.Type {
// case FileTypeText:
// f.Text = items[i]
// f.Size = int64(len(f.Text))
// case FileTypeURI:
// f.URI = items[i]
// f.Size = sizes[i]
// default:
// panic("unknown file type")
// }
// files = append(files, f)
// }
// select {
// case <-onFileShare:
// default:
// }
// onFileShare <- files
}
func OnDnsConfigChanged() {
select {
case onDNSConfigChanged <- struct{}{}:
default:
}
}
//export Java_com_tailscale_ipn_App_onWriteStorageGranted
func OnWriteStorageGranted() {
select {
case onWriteStorageGranted <- struct{}{}:
default:
}
}
func notifyVPNPrepared() {
select {
case onVPNPrepared <- struct{}{}:
default:
}
}
func notifyVPNRevoked() {
select {
case onVPNRevoked <- struct{}{}:
default:
}
}
func notifyVPNClosed() {
select {
case onVPNClosed <- struct{}{}:
default:
}
}
var android struct {
// mu protects all fields of this structure. However, once a
// non-nil jvm is returned from javaVM, all the other fields may
// be accessed unlocked.
mu sync.Mutex
// appCtx is the global Android App context.
appCtx AppContext
}

@ -0,0 +1,151 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package libtailscale
import _ "golang.org/x/mobile/bind"
// Start starts the application, storing state in the given dataDir and using
// the given appCtx.
func Start(dataDir string, appCtx AppContext) Application {
return start(dataDir, appCtx)
}
// AppContext provides a context within which the Application is running. This
// context is a hook into functionality that's implemented on the Java side.
type AppContext interface {
// Log logs the given tag and logLine
Log(tag, logLine string)
// EncryptToPref stores the given value to an encrypted preference at the
// given key.
EncryptToPref(key, value string) error
// DecryptFromPref retrieves the given value from an encrypted preference
// at the given key, or returns empty string if unset.
DecryptFromPref(key string) (string, error)
// GetOSVersion gets the Android version.
GetOSVersion() (string, error)
// GetModelName gets the Android device's model name.
GetModelName() (string, error)
// IsPlayVersion reports whether this is the Google Play version of the app
// (as opposed to F-droid/sideloaded).
IsPlayVersion() bool
// IsChromeOS reports whether we're on a ChromeOS device.
IsChromeOS() (bool, error)
// GetInterfacesAsString gets a string representation of all network
// interfaces.
GetInterfacesAsString() (string, error)
// GetPlatformDNSConfig gets a string representation of the current DNS
// configuration.
GetPlatformDNSConfig() string
}
// IPNService corresponds to our IPNService in Java.
type IPNService interface {
// ID returns the unique ID of this instance of the IPNService. Every time
// we start a new IPN service, it should have a new ID.
ID() string
// Protect protects socket identified by the given file descriptor from
// being captured by the VPN. The return value indicates whether or not the
// socket was successfully protected.
Protect(fd int32) bool
// NewBuilder creates a new VPNServiceBuilder in preparation for starting
// the Android VPN.
NewBuilder() VPNServiceBuilder
}
// VPNServiceBuilder corresponds to Android's VpnService.Builder.
type VPNServiceBuilder interface {
SetMTU(int) error
AddDNSServer(string) error
AddSearchDomain(string) error
AddRoute(string, int32) error
AddAddress(string, int32) error
Establish() (ParcelFileDescriptor, error)
}
// ParcelFileDescriptor corresponds to Android's ParcelFileDescriptor.
type ParcelFileDescriptor interface {
Detach() (int32, error)
}
// Application encapsulates the running Tailscale Application. There is only a
// single instance of Application per Android application.
type Application interface {
// CallLocalAPI provides a mechanism for calling Tailscale's HTTP localapi
// without having to call over the network.
CallLocalAPI(timeoutMillis int, method, endpoint string, body InputStream) (LocalAPIResponse, error)
// WatchNotifications provides a mechanism for subscribing to ipn.Notify
// updates. The given NotificationCallback's OnNotify function is invoked
// on every new ipn.Notify message. The returned NotificationManager
// allows the watcher to stop watching notifications.
WatchNotifications(mask int, cb NotificationCallback) NotificationManager
}
// LocalAPIResponse is a response to a localapi call, analogous to an http.Response.
type LocalAPIResponse interface {
StatusCode() int
BodyBytes() ([]byte, error)
BodyInputStream() InputStream
}
// NotificationCallback is callback for receiving ipn.Notify messages.
type NotificationCallback interface {
OnNotify([]byte) error
}
// NotificationManager provides a mechanism for a notification watcher to stop
// watching notifications.
type NotificationManager interface {
Stop()
}
// InputStream provides an adapter between Java's InputStream and Go's
// io.Reader.
type InputStream interface {
Read() ([]byte, error)
Close() error
}
// The below are global callbacks that allow the Java application to notify Go
// of various state changes.
func OnVPNPrepared() {
notifyVPNPrepared()
}
func RequestVPN(service IPNService) {
onVPNRequested <- service
}
func ServiceDisconnect(service IPNService) {
onDisconnect <- service
}
func OnActivityResult(reqCode, resCode int, idToken string) {
switch reqCode {
case requestSignin:
if resCode != resultOK {
onGoogleToken <- ""
break
}
onGoogleToken <- idToken
case requestPrepareVPN:
if resCode == resultOK {
notifyVPNPrepared()
} else {
notifyVPNClosed()
notifyVPNRevoked()
}
}
}

@ -0,0 +1,149 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package libtailscale
import (
"context"
"fmt"
"io"
"log"
"net"
"net/http"
"runtime/debug"
"sync"
"time"
)
// CallLocalAPI calls the given endpoint on the local API using the given HTTP method
// optionally sending the given body. It returns a Response representing the
// result of the call and an error if the call could not be completed or the
// local API returned a status code in the 400 series or greater.
// Note - Response includes a response body available from the Body method, it
// is the caller's responsibility to close this.
func (app *App) CallLocalAPI(timeoutMillis int, method, endpoint string, body InputStream) (LocalAPIResponse, error) {
defer func() {
if p := recover(); p != nil {
log.Printf("panic in CallLocalAPI %s: %s", p, debug.Stack())
panic(p)
}
}()
app.ready.Wait()
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(uint64(timeoutMillis)*uint64(time.Millisecond)))
defer cancel()
if body != nil {
defer body.Close()
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, adaptInputStream(body))
if err != nil {
return nil, fmt.Errorf("error creating new request for %s: %w", endpoint, err)
}
deadline, _ := ctx.Deadline()
pipeReader, pipeWriter := net.Pipe()
pipeReader.SetDeadline(deadline)
pipeWriter.SetDeadline(deadline)
resp := &Response{
headers: http.Header{},
status: http.StatusOK,
bodyReader: pipeReader,
bodyWriter: pipeWriter,
startWritingBody: make(chan interface{}),
}
go func() {
defer func() {
if p := recover(); p != nil {
log.Printf("panic in CallLocalAPI.ServeHTTP %s: %s", p, debug.Stack())
panic(p)
}
}()
app.localAPIHandler.ServeHTTP(resp, req)
resp.Flush()
pipeWriter.Close()
}()
select {
case <-resp.startWritingBody:
return resp, nil
case <-ctx.Done():
return nil, fmt.Errorf("timeout for %s", endpoint)
}
}
// Response represents the result of processing an localAPI request.
// On completion, the response body can be read out of the bodyWriter.
type Response struct {
headers http.Header
status int
bodyWriter net.Conn
bodyReader net.Conn
startWritingBody chan interface{}
startWritingBodyOnce sync.Once
}
func (r *Response) Header() http.Header {
return r.headers
}
// Write writes the data to the response body which an then be
// read out as a json object.
func (r *Response) Write(data []byte) (int, error) {
r.Flush()
if r.status == 0 {
r.WriteHeader(http.StatusOK)
}
return r.bodyWriter.Write(data)
}
func (r *Response) WriteHeader(statusCode int) {
r.status = statusCode
}
func (r *Response) Body() net.Conn {
return r.bodyReader
}
func (r *Response) BodyBytes() ([]byte, error) {
return io.ReadAll(r.bodyReader)
}
func (r *Response) BodyInputStream() InputStream {
return nil
}
func (r *Response) StatusCode() int {
return r.status
}
func (r *Response) Flush() {
r.startWritingBodyOnce.Do(func() {
close(r.startWritingBody)
})
}
func adaptInputStream(in InputStream) io.Reader {
if in == nil {
return nil
}
r, w := io.Pipe()
go func() {
defer w.Close()
for {
b, err := in.Read()
if err != nil {
log.Printf("error reading from inputstream: %s", err)
}
if b == nil {
return
}
w.Write(b)
}
}()
return r
}

@ -1,7 +1,9 @@
// Gratefully borrowed from Gio https://gioui.org/
// SPDX-License-Identifier: MIT
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
// Gratefully borrowed from Gio UI https://gioui.org/ under MIT license
package libtailscale
/*
#cgo LDFLAGS: -llog
@ -17,6 +19,7 @@ import (
"os"
"path/filepath"
"runtime"
"runtime/debug"
"syscall"
"unsafe"
)
@ -28,10 +31,12 @@ var ID = filepath.Base(os.Args[0])
var logTag = C.CString(ID)
func init() {
func initLogging(appCtx AppContext) {
// Android's logcat already includes timestamps.
log.SetFlags(log.Flags() &^ log.LstdFlags)
log.SetOutput(new(androidLogWriter))
log.SetOutput(&androidLogWriter{
appCtx: appCtx,
})
// Redirect stdout and stderr to the Android logger.
logFd(os.Stdout.Fd())
@ -39,23 +44,18 @@ func init() {
}
type androidLogWriter struct {
// buf has room for the maximum log line, plus a terminating '\0'.
buf [logLineLimit + 1]byte
appCtx AppContext
}
func (w *androidLogWriter) Write(data []byte) (int, error) {
n := 0
for len(data) > 0 {
msg := data
// Truncate the buffer, leaving space for the '\0'.
if max := len(w.buf) - 1; len(msg) > max {
msg = msg[:max]
// Truncate the buffer
if len(msg) > logLineLimit {
msg = msg[:logLineLimit]
}
buf := w.buf[:len(msg)+1]
copy(buf, msg)
// Terminating '\0'.
buf[len(msg)] = 0
C.__android_log_write(C.ANDROID_LOG_INFO, logTag, (*C.char)(unsafe.Pointer(&buf[0])))
w.appCtx.Log(ID, string(msg))
n += len(msg)
data = data[len(msg):]
}
@ -71,6 +71,13 @@ func logFd(fd uintptr) {
panic(err)
}
go func() {
defer func() {
if p := recover(); p != nil {
log.Printf("panic in logFd %s: %s", p, debug.Stack())
panic(p)
}
}()
lineBuf := bufio.NewReaderSize(r, logLineLimit)
// The buffer to pass to C, including the terminating '\0'.
buf := make([]byte, lineBuf.Size()+1)

@ -1,10 +1,12 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
package libtailscale
import (
"log"
"os"
"runtime/debug"
"github.com/tailscale/wireguard-go/tun"
)
@ -80,6 +82,13 @@ func newTUNDevices() *multiTUN {
}
func (d *multiTUN) run() {
defer func() {
if p := recover(); p != nil {
log.Printf("panic in multiTUN.run %s: %s", p, debug.Stack())
panic(p)
}
}()
var devices []*tunDevice
// readDone is the readDone channel of the device being read from.
var readDone chan struct{}
@ -161,6 +170,13 @@ func (d *multiTUN) run() {
}
func (d *multiTUN) readFrom(dev *tunDevice) {
defer func() {
if p := recover(); p != nil {
log.Printf("panic in multiTUN.readFrom %s: %s", p, debug.Stack())
panic(p)
}
}()
defer func() {
dev.readDone <- struct{}{}
}()
@ -188,6 +204,13 @@ func (d *multiTUN) readFrom(dev *tunDevice) {
}
func (d *multiTUN) runDevice(dev *tunDevice) {
defer func() {
if p := recover(); p != nil {
log.Printf("panic in multiTUN.runDevice %s: %s", p, debug.Stack())
panic(p)
}
}()
defer func() {
// The documentation for https://developer.android.com/reference/android/net/VpnService.Builder#establish()
// states that "Therefore, after draining the old file
@ -199,6 +222,12 @@ func (d *multiTUN) runDevice(dev *tunDevice) {
}()
// Pump device events.
go func() {
defer func() {
if p := recover(); p != nil {
log.Printf("panic in multiTUN.readFrom.events %s: %s", p, debug.Stack())
panic(p)
}
}()
for {
select {
case e := <-dev.dev.Events():

@ -1,7 +1,7 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
package libtailscale
import (
"errors"
@ -10,9 +10,9 @@ import (
"net"
"net/netip"
"reflect"
"runtime/debug"
"strings"
jnipkg "github.com/tailscale/tailscale-android/pkg/jni"
"github.com/tailscale/wireguard-go/tun"
"golang.org/x/sys/unix"
"inet.af/netaddr"
@ -22,8 +22,6 @@ import (
"tailscale.com/wgengine/router"
)
import "C"
// errVPNNotPrepared is used when VPNService.Builder.establish returns
// null, either because the VPNService is not yet prepared or because
// VPN status was revoked.
@ -37,16 +35,9 @@ var errMultipleUsers = errors.New("VPN cannot be created on this device due to a
// Report interfaces in the device in net.Interface format.
func (a *App) getInterfaces() ([]interfaces.Interface, error) {
var ifaceString string
err := jnipkg.Do(a.jvm, func(env *jnipkg.Env) error {
cls := jnipkg.GetObjectClass(env, a.appCtx)
m := jnipkg.GetMethodID(env, cls, "getInterfacesAsString", "()Ljava/lang/String;")
n, err := jnipkg.CallObjectMethod(env, a.appCtx, m)
ifaceString = jnipkg.GoString(env, jnipkg.String(n))
return err
})
var ifaces []interfaces.Interface
ifaceString, err := a.appCtx.GetInterfacesAsString()
if err != nil {
return ifaces, err
}
@ -125,7 +116,7 @@ var googleDNSServers = []netip.Addr{
netip.MustParseAddr("2001:4860:4860::8844"),
}
func (b *backend) updateTUN(service jnipkg.Object, rcfg *router.Config, dcfg *dns.OSConfig) error {
func (b *backend) updateTUN(service IPNService, rcfg *router.Config, dcfg *dns.OSConfig) error {
if reflect.DeepEqual(rcfg, b.lastCfg) && reflect.DeepEqual(dcfg, b.lastDNSCfg) {
return nil
}
@ -141,123 +132,75 @@ func (b *backend) updateTUN(service jnipkg.Object, rcfg *router.Config, dcfg *dn
if len(rcfg.LocalAddrs) == 0 {
return nil
}
err := jnipkg.Do(b.jvm, func(env *jnipkg.Env) error {
cls := jnipkg.GetObjectClass(env, service)
// Construct a VPNService.Builder. IPNService.newBuilder calls
// setConfigureIntent, and allowFamily for both IPv4 and IPv6.
m := jnipkg.GetMethodID(env, cls, "newBuilder", "()Landroid/net/VpnService$Builder;")
builder, err := jnipkg.CallObjectMethod(env, service, m)
if err != nil {
return fmt.Errorf("IPNService.newBuilder: %v", err)
}
bcls := jnipkg.GetObjectClass(env, builder)
builder := service.NewBuilder()
// builder.setMtu.
setMtu := jnipkg.GetMethodID(env, bcls, "setMtu", "(I)Landroid/net/VpnService$Builder;")
const mtu = defaultMTU
if _, err := jnipkg.CallObjectMethod(env, builder, setMtu, jnipkg.Value(mtu)); err != nil {
return fmt.Errorf("VpnService.Builder.setMtu: %v", err)
if err := builder.SetMTU(defaultMTU); err != nil {
return err
}
if dcfg != nil {
nameservers := dcfg.Nameservers
if b.avoidEmptyDNS && len(nameservers) == 0 {
nameservers = googleDNSServers
}
// builder.addDnsServer
addDnsServer := jnipkg.GetMethodID(env, bcls, "addDnsServer", "(Ljava/lang/String;)Landroid/net/VpnService$Builder;")
// builder.addSearchDomain.
addSearchDomain := jnipkg.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 = jnipkg.CallObjectMethod(env,
builder,
addDnsServer,
jnipkg.Value(jnipkg.JavaString(env, dns.String())),
)
if err != nil {
return fmt.Errorf("VpnService.Builder.addDnsServer(%v): %v", dns, err)
}
}
for _, dom := range dcfg.SearchDomains {
_, err = jnipkg.CallObjectMethod(env,
builder,
addSearchDomain,
jnipkg.Value(jnipkg.JavaString(env, dom.WithoutTrailingDot())),
)
if err != nil {
return fmt.Errorf("VpnService.Builder.addSearchDomain(%v): %v", dom, err)
}
for _, dns := range nameservers {
if err := builder.AddDNSServer(dns.String()); err != nil {
return err
}
}
// builder.addRoute.
addRoute := jnipkg.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 = jnipkg.CallObjectMethod(env,
builder,
addRoute,
jnipkg.Value(jnipkg.JavaString(env, route.Addr().String())),
jnipkg.Value(route.Bits()),
)
if err != nil {
return fmt.Errorf("VpnService.Builder.addRoute(%v): %v", route, err)
for _, dom := range dcfg.SearchDomains {
if err := builder.AddSearchDomain(dom.WithoutTrailingDot()); err != nil {
return err
}
}
}
// builder.addAddress.
addAddress := jnipkg.GetMethodID(env, bcls, "addAddress", "(Ljava/lang/String;I)Landroid/net/VpnService$Builder;")
for _, addr := range rcfg.LocalAddrs {
_, err = jnipkg.CallObjectMethod(env,
builder,
addAddress,
jnipkg.Value(jnipkg.JavaString(env, addr.Addr().String())),
jnipkg.Value(addr.Bits()),
)
if err != nil {
return fmt.Errorf("VpnService.Builder.addAddress(%v): %v", addr, err)
}
for _, route := range rcfg.Routes {
// Normalize route address; Builder.addRoute does not accept non-zero masked bits.
route = route.Masked()
if err := builder.AddRoute(route.Addr().String(), int32(route.Bits())); err != nil {
return err
}
}
// builder.establish.
establish := jnipkg.GetMethodID(env, bcls, "establish", "()Landroid/os/ParcelFileDescriptor;")
parcelFD, err := jnipkg.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
for _, addr := range rcfg.LocalAddrs {
if err := builder.AddAddress(addr.Addr().String(), int32(addr.Bits())); err != nil {
return err
}
}
// detachFd.
parcelCls := jnipkg.GetObjectClass(env, parcelFD)
detachFd := jnipkg.GetMethodID(env, parcelCls, "detachFd", "()I")
tunFD, err := jnipkg.CallIntMethod(env, parcelFD, detachFd)
if err != nil {
return fmt.Errorf("detachFd: %v", err)
parcelFD, err := builder.Establish()
if err != nil {
if strings.Contains(err.Error(), "INTERACT_ACROSS_USERS") {
return errMultipleUsers
}
return fmt.Errorf("VpnService.Builder.establish: %v", err)
}
// Create TUN device.
tunDev, _, err := tun.CreateUnmonitoredTUNFromFD(int(tunFD))
if err != nil {
unix.Close(int(tunFD))
return err
}
if parcelFD == nil {
return errVPNNotPrepared
}
b.devices.add(tunDev)
// detachFd.
tunFD, err := parcelFD.Detach()
if err != nil {
return fmt.Errorf("detachFd: %v", err)
}
return nil
})
// Create TUN device.
tunDev, _, err := tun.CreateUnmonitoredTUNFromFD(int(tunFD))
if err != nil {
b.lastCfg = nil
b.CloseTUNs()
unix.Close(int(tunFD))
return err
}
b.devices.add(tunDev)
// TODO(oxtoacart): figure out what to do with this
// if err != nil {
// b.lastCfg = nil
// b.CloseTUNs()
// return err
// }
b.lastCfg = rcfg
b.lastDNSCfg = dcfg
return nil
@ -270,6 +213,13 @@ func (b *backend) CloseTUNs() {
}
func (b *backend) NetworkChanged() {
defer func() {
if p := recover(); p != nil {
log.Printf("panic in NetworkChanged %s: %s", p, debug.Stack())
panic(p)
}
}()
if b.sys != nil {
if nm, ok := b.sys.NetMon.GetOK(); ok {
nm.InjectEvent()
@ -285,7 +235,7 @@ func (b *backend) getDNSBaseConfig() (ret dns.OSConfig, _ error) {
// 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() {
if len(ret.Nameservers) == 0 && b.appCtx.IsPlayVersion() {
log.Printf("getDNSBaseConfig: none found; falling back to Google public DNS")
ret.Nameservers = append(ret.Nameservers, googleDNSServers...)
}
@ -320,25 +270,7 @@ func (b *backend) getDNSBaseConfig() (ret dns.OSConfig, _ error) {
}
func (b *backend) getPlatformDNSConfig() string {
var baseConfig string
err := jnipkg.Do(b.jvm, func(env *jnipkg.Env) error {
cls := jnipkg.GetObjectClass(env, b.appCtx)
m := jnipkg.GetMethodID(env, cls, "getDnsConfigObj", "()Lcom/tailscale/ipn/DnsConfig;")
dns, err := jnipkg.CallObjectMethod(env, b.appCtx, m)
if err != nil {
return fmt.Errorf("getDnsConfigObj: %v", err)
}
dnsCls := jnipkg.GetObjectClass(env, dns)
m = jnipkg.GetMethodID(env, dnsCls, "getDnsConfigAsString", "()Ljava/lang/String;")
n, err := jnipkg.CallObjectMethod(env, dns, m)
baseConfig = jnipkg.GoString(env, jnipkg.String(n))
return err
})
if err != nil {
log.Printf("getPlatformDNSConfig JNI: %v", err)
return ""
}
return baseConfig
return b.appCtx.GetPlatformDNSConfig()
}
func (b *backend) setCfg(rcfg *router.Config, dcfg *dns.OSConfig) error {

@ -0,0 +1,48 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package libtailscale
import (
"context"
"encoding/json"
"log"
"runtime/debug"
"tailscale.com/ipn"
)
func (app *App) WatchNotifications(mask int, cb NotificationCallback) NotificationManager {
app.ready.Wait()
ctx, cancel := context.WithCancel(context.Background())
go app.backend.WatchNotifications(ctx, ipn.NotifyWatchOpt(mask), func() {}, func(notify *ipn.Notify) bool {
defer func() {
if p := recover(); p != nil {
log.Printf("panic in WatchNotifications %s: %s", p, debug.Stack())
panic(p)
}
}()
b, err := json.Marshal(notify)
if err != nil {
log.Printf("error: WatchNotifications: marshal notify: %s", err)
return true
}
err = cb.OnNotify(b)
if err != nil {
log.Printf("error: WatchNotifications: OnNotify: %s", err)
return true
}
return true
})
return &notificationManager{cancel}
}
type notificationManager struct {
cancel func()
}
func (nm *notificationManager) Stop() {
nm.cancel()
}

@ -1,47 +1,26 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
package libtailscale
import (
"encoding/base64"
"tailscale.com/ipn"
jnipkg "github.com/tailscale/tailscale-android/pkg/jni"
)
// stateStore is the Go interface for a persistent storage
// backend by androidx.security.crypto.EncryptedSharedPreferences (see
// App.java).
type stateStore struct {
jvm *jnipkg.JVM
// appCtx is the global Android app context.
appCtx jnipkg.Object
// Cached method ids on appCtx.
encrypt jnipkg.MethodID
decrypt jnipkg.MethodID
appCtx AppContext
}
func newStateStore(jvm *jnipkg.JVM, appCtx jnipkg.Object) *stateStore {
s := &stateStore{
jvm: jvm,
func newStateStore(appCtx AppContext) *stateStore {
return &stateStore{
appCtx: appCtx,
}
jnipkg.Do(jvm, func(env *jnipkg.Env) error {
appCls := jnipkg.GetObjectClass(env, appCtx)
s.encrypt = jnipkg.GetMethodID(
env, appCls,
"encryptToPref", "(Ljava/lang/String;Ljava/lang/String;)V",
)
s.decrypt = jnipkg.GetMethodID(
env, appCls,
"decryptFromPref", "(Ljava/lang/String;)Ljava/lang/String;",
)
return nil
})
return s
}
func prefKeyFor(id ipn.StateKey) string {
@ -99,35 +78,17 @@ func (s *stateStore) WriteState(id ipn.StateKey, bs []byte) error {
}
func (s *stateStore) read(key string) ([]byte, error) {
var data []byte
err := jnipkg.Do(s.jvm, func(env *jnipkg.Env) error {
jfile := jnipkg.JavaString(env, key)
plain, err := jnipkg.CallObjectMethod(env, s.appCtx, s.decrypt,
jnipkg.Value(jfile))
if err != nil {
return err
}
b64 := jnipkg.GoString(env, jnipkg.String(plain))
if b64 == "" {
return nil
}
data, err = base64.RawStdEncoding.DecodeString(b64)
return err
})
return data, err
b64, err := s.appCtx.DecryptFromPref(key)
if err != nil {
return nil, err
}
if b64 == "" {
return nil, nil
}
return base64.RawStdEncoding.DecodeString(b64)
}
func (s *stateStore) write(key string, value []byte) error {
bs64 := base64.RawStdEncoding.EncodeToString(value)
err := jnipkg.Do(s.jvm, func(env *jnipkg.Env) error {
jfile := jnipkg.JavaString(env, key)
jplain := jnipkg.JavaString(env, bs64)
err := jnipkg.CallVoidMethod(env, s.appCtx, s.encrypt,
jnipkg.Value(jfile), jnipkg.Value(jplain))
if err != nil {
return err
}
return nil
})
return err
return s.appCtx.EncryptToPref(key, bs64)
}

@ -1,17 +1,16 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
package libtailscale
import (
"context"
"log"
"net/http"
"path/filepath"
"runtime/debug"
"time"
"unsafe"
jnipkg "github.com/tailscale/tailscale-android/pkg/jni"
"tailscale.com/logpolicy"
"tailscale.com/logtail"
"tailscale.com/logtail/filch"
@ -23,13 +22,6 @@ import (
"tailscale.com/util/must"
)
import "C"
var (
// googleClass is a global reference to the com.tailscale.ipn.Google class.
googleClass jnipkg.Class
)
const defaultMTU = 1280 // minimalMTU from wgengine/userspace.go
const (
@ -42,25 +34,30 @@ type ConnectEvent struct {
Enable bool
}
func main() {
func newApp(dataDir string, appCtx AppContext) Application {
a := &App{
jvm: (*jnipkg.JVM)(unsafe.Pointer(javaVM())),
appCtx: jnipkg.Object(appContext()),
dataDir: dataDir,
appCtx: appCtx,
}
a.ready.Add(1)
err := a.loadJNIGlobalClassRefs()
if err != nil {
fatalErr(err)
}
a.store = newStateStore(a.jvm, a.appCtx)
a.store = newStateStore(a.appCtx)
interfaces.RegisterInterfaceGetter(a.getInterfaces)
go func() {
defer func() {
if p := recover(); p != nil {
log.Printf("panic in runBackend %s: %s", p, debug.Stack())
panic(p)
}
}()
ctx := context.Background()
if err := a.runBackend(ctx); err != nil {
fatalErr(err)
}
}()
return a
}
func fatalErr(err error) {
@ -71,14 +68,7 @@ func fatalErr(err error) {
// osVersion returns android.os.Build.VERSION.RELEASE. " [nogoogle]" is appended
// if Google Play services are not compiled in.
func (a *App) osVersion() string {
var version string
err := jnipkg.Do(a.jvm, func(env *jnipkg.Env) error {
cls := jnipkg.GetObjectClass(env, a.appCtx)
m := jnipkg.GetMethodID(env, cls, "getOSVersion", "()Ljava/lang/String;")
n, err := jnipkg.CallObjectMethod(env, a.appCtx, m)
version = jnipkg.GoString(env, jnipkg.String(n))
return err
})
version, err := a.appCtx.GetOSVersion()
if err != nil {
panic(err)
}
@ -88,14 +78,7 @@ func (a *App) osVersion() string {
// modelName return the MANUFACTURER + MODEL from
// android.os.Build.
func (a *App) modelName() string {
var model string
err := jnipkg.Do(a.jvm, func(env *jnipkg.Env) error {
cls := jnipkg.GetObjectClass(env, a.appCtx)
m := jnipkg.GetMethodID(env, cls, "getModelName", "()Ljava/lang/String;")
n, err := jnipkg.CallObjectMethod(env, a.appCtx, m)
model = jnipkg.GoString(env, jnipkg.String(n))
return err
})
model, err := a.appCtx.GetModelName()
if err != nil {
panic(err)
}
@ -103,37 +86,11 @@ func (a *App) modelName() string {
}
func (a *App) isChromeOS() bool {
var chromeOS bool
err := jnipkg.Do(a.jvm, func(env *jnipkg.Env) error {
cls := jnipkg.GetObjectClass(env, a.appCtx)
m := jnipkg.GetMethodID(env, cls, "isChromeOS", "()Z")
b, err := jnipkg.CallBooleanMethod(env, a.appCtx, m)
chromeOS = b
return err
})
isChromeOS, err := a.appCtx.IsChromeOS()
if err != nil {
panic(err)
}
return chromeOS
}
func googleSignInEnabled() bool {
return googleClass != 0
}
// Loads the global JNI class references. Failures here are fatal if the
// class ref is required for the app to function.
func (a *App) loadJNIGlobalClassRefs() error {
return jnipkg.Do(a.jvm, func(env *jnipkg.Env) error {
loader := jnipkg.ClassLoaderFor(env, a.appCtx)
cl, err := jnipkg.LoadClass(env, loader, "com.tailscale.ipn.Google")
if err != nil {
// Ignore load errors; the Google class is not included in F-Droid builds.
return nil
}
googleClass = jnipkg.Class(jnipkg.NewGlobalRef(env, jnipkg.Object(cl)))
return nil
})
return isChromeOS
}
// SetupLogs sets up remote logging.

@ -1,506 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package jni implements various helper functions for communicating with the Android JVM
// though JNI.
package jnipkg
import (
"errors"
"fmt"
"reflect"
"runtime"
"sync"
"unicode/utf16"
"unsafe"
)
/*
#cgo CFLAGS: -Wall
#include <jni.h>
#include <stdlib.h>
static jint jni_AttachCurrentThread(JavaVM *vm, JNIEnv **p_env, void *thr_args) {
return (*vm)->AttachCurrentThread(vm, p_env, thr_args);
}
static jint jni_DetachCurrentThread(JavaVM *vm) {
return (*vm)->DetachCurrentThread(vm);
}
static jint jni_GetEnv(JavaVM *vm, JNIEnv **env, jint version) {
return (*vm)->GetEnv(vm, (void **)env, version);
}
static jclass jni_FindClass(JNIEnv *env, const char *name) {
return (*env)->FindClass(env, name);
}
static jthrowable jni_ExceptionOccurred(JNIEnv *env) {
return (*env)->ExceptionOccurred(env);
}
static void jni_ExceptionClear(JNIEnv *env) {
(*env)->ExceptionClear(env);
}
static jclass jni_GetObjectClass(JNIEnv *env, jobject obj) {
return (*env)->GetObjectClass(env, obj);
}
static jmethodID jni_GetMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
return (*env)->GetMethodID(env, clazz, name, sig);
}
static jmethodID jni_GetStaticMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
return (*env)->GetStaticMethodID(env, clazz, name, sig);
}
static jsize jni_GetStringLength(JNIEnv *env, jstring str) {
return (*env)->GetStringLength(env, str);
}
static const jchar *jni_GetStringChars(JNIEnv *env, jstring str) {
return (*env)->GetStringChars(env, str, NULL);
}
static jstring jni_NewString(JNIEnv *env, const jchar *unicodeChars, jsize len) {
return (*env)->NewString(env, unicodeChars, len);
}
static jboolean jni_IsSameObject(JNIEnv *env, jobject ref1, jobject ref2) {
return (*env)->IsSameObject(env, ref1, ref2);
}
static jobject jni_NewGlobalRef(JNIEnv *env, jobject obj) {
return (*env)->NewGlobalRef(env, obj);
}
static void jni_DeleteGlobalRef(JNIEnv *env, jobject obj) {
(*env)->DeleteGlobalRef(env, obj);
}
static void jni_CallStaticVoidMethodA(JNIEnv *env, jclass cls, jmethodID method, jvalue *args) {
(*env)->CallStaticVoidMethodA(env, cls, method, args);
}
static jint jni_CallStaticIntMethodA(JNIEnv *env, jclass cls, jmethodID method, jvalue *args) {
return (*env)->CallStaticIntMethodA(env, cls, method, args);
}
static jobject jni_CallStaticObjectMethodA(JNIEnv *env, jclass cls, jmethodID method, jvalue *args) {
return (*env)->CallStaticObjectMethodA(env, cls, method, args);
}
static jobject jni_CallObjectMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) {
return (*env)->CallObjectMethodA(env, obj, method, args);
}
static jboolean jni_CallBooleanMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) {
return (*env)->CallBooleanMethodA(env, obj, method, args);
}
static jint jni_CallIntMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) {
return (*env)->CallIntMethodA(env, obj, method, args);
}
static void jni_CallVoidMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) {
(*env)->CallVoidMethodA(env, obj, method, args);
}
static jbyteArray jni_NewByteArray(JNIEnv *env, jsize length) {
return (*env)->NewByteArray(env, length);
}
static jboolean *jni_GetBooleanArrayElements(JNIEnv *env, jbooleanArray arr) {
return (*env)->GetBooleanArrayElements(env, arr, NULL);
}
static void jni_ReleaseBooleanArrayElements(JNIEnv *env, jbooleanArray arr, jboolean *elems, jint mode) {
(*env)->ReleaseBooleanArrayElements(env, arr, elems, mode);
}
static jbyte *jni_GetByteArrayElements(JNIEnv *env, jbyteArray arr) {
return (*env)->GetByteArrayElements(env, arr, NULL);
}
static jint *jni_GetIntArrayElements(JNIEnv *env, jintArray arr) {
return (*env)->GetIntArrayElements(env, arr, NULL);
}
static void jni_ReleaseIntArrayElements(JNIEnv *env, jintArray arr, jint *elems, jint mode) {
(*env)->ReleaseIntArrayElements(env, arr, elems, mode);
}
static jlong *jni_GetLongArrayElements(JNIEnv *env, jlongArray arr) {
return (*env)->GetLongArrayElements(env, arr, NULL);
}
static void jni_ReleaseLongArrayElements(JNIEnv *env, jlongArray arr, jlong *elems, jint mode) {
(*env)->ReleaseLongArrayElements(env, arr, elems, mode);
}
static void jni_ReleaseByteArrayElements(JNIEnv *env, jbyteArray arr, jbyte *elems, jint mode) {
(*env)->ReleaseByteArrayElements(env, arr, elems, mode);
}
static jsize jni_GetArrayLength(JNIEnv *env, jarray arr) {
return (*env)->GetArrayLength(env, arr);
}
static void jni_DeleteLocalRef(JNIEnv *env, jobject localRef) {
return (*env)->DeleteLocalRef(env, localRef);
}
static jobject jni_GetObjectArrayElement(JNIEnv *env, jobjectArray array, jsize index) {
return (*env)->GetObjectArrayElement(env, array, index);
}
static jboolean jni_IsInstanceOf(JNIEnv *env, jobject obj, jclass clazz) {
return (*env)->IsInstanceOf(env, obj, clazz);
}
static jint jni_GetJavaVM(JNIEnv *env, JavaVM **jvm) {
return (*env)->GetJavaVM(env, jvm);
}
*/
import "C"
type JVM C.JavaVM
type Env C.JNIEnv
type (
Class C.jclass
Object C.jobject
MethodID C.jmethodID
String C.jstring
ByteArray C.jbyteArray
ObjectArray C.jobjectArray
BooleanArray C.jbooleanArray
LongArray C.jlongArray
IntArray C.jintArray
Boolean C.jboolean
Value uint64 // All JNI types fit into 64-bits.
)
// Cached class handles.
var classes struct {
once sync.Once
stringClass, integerClass Class
integerIntValue MethodID
}
func env(e *Env) *C.JNIEnv {
return (*C.JNIEnv)(unsafe.Pointer(e))
}
func javavm(vm *JVM) *C.JavaVM {
return (*C.JavaVM)(unsafe.Pointer(vm))
}
// Do invokes a function with a temporary JVM environment. The
// environment is not valid after the function returns.
func Do(vm *JVM, f func(env *Env) error) error {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
var env *C.JNIEnv
if res := C.jni_GetEnv(javavm(vm), &env, C.JNI_VERSION_1_6); res != C.JNI_OK {
if res != C.JNI_EDETACHED {
panic(fmt.Errorf("JNI GetEnv failed with error %d", res))
}
if C.jni_AttachCurrentThread(javavm(vm), &env, nil) != C.JNI_OK {
panic(errors.New("runInJVM: AttachCurrentThread failed"))
}
defer C.jni_DetachCurrentThread(javavm(vm))
}
return f((*Env)(unsafe.Pointer(env)))
}
func Bool(b bool) Boolean {
if b {
return C.JNI_TRUE
}
return C.JNI_FALSE
}
func varArgs(args []Value) *C.jvalue {
if len(args) == 0 {
return nil
}
return (*C.jvalue)(unsafe.Pointer(&args[0]))
}
func IsSameObject(e *Env, ref1, ref2 Object) bool {
same := C.jni_IsSameObject(env(e), C.jobject(ref1), C.jobject(ref2))
return same == C.JNI_TRUE
}
func CallStaticIntMethod(e *Env, cls Class, method MethodID, args ...Value) (int, error) {
res := C.jni_CallStaticIntMethodA(env(e), C.jclass(cls), C.jmethodID(method), varArgs(args))
return int(res), exception(e)
}
func CallStaticVoidMethod(e *Env, cls Class, method MethodID, args ...Value) error {
C.jni_CallStaticVoidMethodA(env(e), C.jclass(cls), C.jmethodID(method), varArgs(args))
return exception(e)
}
func CallVoidMethod(e *Env, obj Object, method MethodID, args ...Value) error {
C.jni_CallVoidMethodA(env(e), C.jobject(obj), C.jmethodID(method), varArgs(args))
return exception(e)
}
func CallStaticObjectMethod(e *Env, cls Class, method MethodID, args ...Value) (Object, error) {
res := C.jni_CallStaticObjectMethodA(env(e), C.jclass(cls), C.jmethodID(method), varArgs(args))
return Object(res), exception(e)
}
func CallObjectMethod(e *Env, obj Object, method MethodID, args ...Value) (Object, error) {
res := C.jni_CallObjectMethodA(env(e), C.jobject(obj), C.jmethodID(method), varArgs(args))
return Object(res), exception(e)
}
func CallBooleanMethod(e *Env, obj Object, method MethodID, args ...Value) (bool, error) {
res := C.jni_CallBooleanMethodA(env(e), C.jobject(obj), C.jmethodID(method), varArgs(args))
return res == C.JNI_TRUE, exception(e)
}
func CallIntMethod(e *Env, obj Object, method MethodID, args ...Value) (int32, error) {
res := C.jni_CallIntMethodA(env(e), C.jobject(obj), C.jmethodID(method), varArgs(args))
return int32(res), exception(e)
}
func GetArrayLength(e *Env, jarr ByteArray) int {
size := C.jni_GetArrayLength(env(e), C.jarray(jarr))
return int(size)
}
// GetByteArrayElements returns the contents of the byte array.
func GetByteArrayElements(e *Env, jarr ByteArray) []byte {
if jarr == 0 {
return nil
}
size := C.jni_GetArrayLength(env(e), C.jarray(jarr))
elems := C.jni_GetByteArrayElements(env(e), C.jbyteArray(jarr))
defer C.jni_ReleaseByteArrayElements(env(e), C.jbyteArray(jarr), elems, 0)
backing := (*(*[1 << 30]byte)(unsafe.Pointer(elems)))[:size:size]
s := make([]byte, len(backing))
copy(s, backing)
return s
}
// GetBooleanArrayElements returns the contents of the boolean array.
func GetBooleanArrayElements(e *Env, jarr BooleanArray) []bool {
if jarr == 0 {
return nil
}
size := C.jni_GetArrayLength(env(e), C.jarray(jarr))
elems := C.jni_GetBooleanArrayElements(env(e), C.jbooleanArray(jarr))
defer C.jni_ReleaseBooleanArrayElements(env(e), C.jbooleanArray(jarr), elems, 0)
backing := (*(*[1 << 30]C.jboolean)(unsafe.Pointer(elems)))[:size:size]
r := make([]bool, len(backing))
for i, b := range backing {
r[i] = b == C.JNI_TRUE
}
return r
}
// GetStringArrayElements returns the contents of the String array.
func GetStringArrayElements(e *Env, jarr ObjectArray) []string {
var strings []string
iterateObjectArray(e, jarr, func(e *Env, idx int, item Object) {
s := GoString(e, String(item))
strings = append(strings, s)
})
return strings
}
// GetIntArrayElements returns the contents of the int array.
func GetIntArrayElements(e *Env, jarr IntArray) []int {
if jarr == 0 {
return nil
}
size := C.jni_GetArrayLength(env(e), C.jarray(jarr))
elems := C.jni_GetIntArrayElements(env(e), C.jintArray(jarr))
defer C.jni_ReleaseIntArrayElements(env(e), C.jintArray(jarr), elems, 0)
backing := (*(*[1 << 27]C.jint)(unsafe.Pointer(elems)))[:size:size]
r := make([]int, len(backing))
for i, l := range backing {
r[i] = int(l)
}
return r
}
// GetLongArrayElements returns the contents of the long array.
func GetLongArrayElements(e *Env, jarr LongArray) []int64 {
if jarr == 0 {
return nil
}
size := C.jni_GetArrayLength(env(e), C.jarray(jarr))
elems := C.jni_GetLongArrayElements(env(e), C.jlongArray(jarr))
defer C.jni_ReleaseLongArrayElements(env(e), C.jlongArray(jarr), elems, 0)
backing := (*(*[1 << 27]C.jlong)(unsafe.Pointer(elems)))[:size:size]
r := make([]int64, len(backing))
for i, l := range backing {
r[i] = int64(l)
}
return r
}
func iterateObjectArray(e *Env, jarr ObjectArray, f func(e *Env, idx int, item Object)) {
if jarr == 0 {
return
}
size := C.jni_GetArrayLength(env(e), C.jarray(jarr))
for i := 0; i < int(size); i++ {
item := C.jni_GetObjectArrayElement(env(e), C.jobjectArray(jarr), C.jint(i))
f(e, i, Object(item))
C.jni_DeleteLocalRef(env(e), item)
}
}
// NewByteArray allocates a Java byte array with the content. It
// panics if the allocation fails.
func NewByteArray(e *Env, content []byte) ByteArray {
jarr := C.jni_NewByteArray(env(e), C.jsize(len(content)))
if jarr == 0 {
panic(fmt.Errorf("jni: NewByteArray(%d) failed", len(content)))
}
elems := C.jni_GetByteArrayElements(env(e), jarr)
defer C.jni_ReleaseByteArrayElements(env(e), jarr, elems, 0)
backing := (*(*[1 << 30]byte)(unsafe.Pointer(elems)))[:len(content):len(content)]
copy(backing, content)
return ByteArray(jarr)
}
// ClassLoader returns a reference to the Java ClassLoader associated
// with obj.
func ClassLoaderFor(e *Env, obj Object) Object {
cls := GetObjectClass(e, obj)
getClassLoader := GetMethodID(e, cls, "getClassLoader", "()Ljava/lang/ClassLoader;")
clsLoader, err := CallObjectMethod(e, Object(obj), getClassLoader)
if err != nil {
// Class.getClassLoader should never fail.
panic(err)
}
return Object(clsLoader)
}
// LoadClass invokes the underlying ClassLoader's loadClass method and
// returns the class.
func LoadClass(e *Env, loader Object, class string) (Class, error) {
cls := GetObjectClass(e, loader)
loadClass := GetMethodID(e, cls, "loadClass", "(Ljava/lang/String;)Ljava/lang/Class;")
name := JavaString(e, class)
loaded, err := CallObjectMethod(e, loader, loadClass, Value(name))
if err != nil {
return 0, err
}
return Class(loaded), exception(e)
}
// exception returns an error corresponding to the pending
// exception, and clears it. exceptionError returns nil if no
// exception is pending.
func exception(e *Env) error {
thr := C.jni_ExceptionOccurred(env(e))
if thr == 0 {
return nil
}
C.jni_ExceptionClear(env(e))
cls := GetObjectClass(e, Object(thr))
toString := GetMethodID(e, cls, "toString", "()Ljava/lang/String;")
msg, err := CallObjectMethod(e, Object(thr), toString)
if err != nil {
return err
}
return errors.New(GoString(e, String(msg)))
}
// GetObjectClass returns the Java Class for an Object.
func GetObjectClass(e *Env, obj Object) Class {
if obj == 0 {
panic("null object")
}
cls := C.jni_GetObjectClass(env(e), C.jobject(obj))
if err := exception(e); err != nil {
// GetObjectClass should never fail.
panic(err)
}
return Class(cls)
}
// GetStaticMethodID returns the id for a static method. It panics if the method
// wasn't found.
func GetStaticMethodID(e *Env, cls Class, name, signature string) MethodID {
mname := C.CString(name)
defer C.free(unsafe.Pointer(mname))
msig := C.CString(signature)
defer C.free(unsafe.Pointer(msig))
m := C.jni_GetStaticMethodID(env(e), C.jclass(cls), mname, msig)
if err := exception(e); err != nil {
panic(err)
}
return MethodID(m)
}
// GetMethodID returns the id for a method. It panics if the method
// wasn't found.
func GetMethodID(e *Env, cls Class, name, signature string) MethodID {
mname := C.CString(name)
defer C.free(unsafe.Pointer(mname))
msig := C.CString(signature)
defer C.free(unsafe.Pointer(msig))
m := C.jni_GetMethodID(env(e), C.jclass(cls), mname, msig)
if err := exception(e); err != nil {
panic(err)
}
return MethodID(m)
}
func NewGlobalRef(e *Env, obj Object) Object {
return Object(C.jni_NewGlobalRef(env(e), C.jobject(obj)))
}
func DeleteGlobalRef(e *Env, obj Object) {
C.jni_DeleteGlobalRef(env(e), C.jobject(obj))
}
// JavaString converts the string to a JVM jstring.
func JavaString(e *Env, str string) String {
if str == "" {
return 0
}
utf16Chars := utf16.Encode([]rune(str))
res := C.jni_NewString(env(e), (*C.jchar)(unsafe.Pointer(&utf16Chars[0])), C.int(len(utf16Chars)))
return String(res)
}
// GoString converts the JVM jstring to a Go string.
func GoString(e *Env, str String) string {
if str == 0 {
return ""
}
strlen := C.jni_GetStringLength(env(e), C.jstring(str))
chars := C.jni_GetStringChars(env(e), C.jstring(str))
var utf16Chars []uint16
hdr := (*reflect.SliceHeader)(unsafe.Pointer(&utf16Chars))
hdr.Data = uintptr(unsafe.Pointer(chars))
hdr.Cap = int(strlen)
hdr.Len = int(strlen)
utf8 := utf16.Decode(utf16Chars)
return string(utf8)
}
func GetJavaVM(e *Env) (*JVM, error) {
var jvm *C.JavaVM
result := C.jni_GetJavaVM(env(e), &jvm)
if result != C.JNI_OK {
return nil, errors.New("failed to get JavaVM")
}
return (*JVM)(jvm), nil
}

@ -1,76 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package localapiservice
import (
"context"
"io"
"net/http"
"testing"
"time"
)
var ctx = context.Background()
type BadStatusHandler struct{}
func (b *BadStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}
func TestBadStatus(t *testing.T) {
ctx, cancel := context.WithDeadline(ctx, time.Now().Add(2*time.Second))
client := New(&BadStatusHandler{})
defer cancel()
_, err := client.Call(ctx, "POST", "test", nil)
if err.Error() != "request failed with status code 400" {
t.Error("Expected bad status error, but got", err)
}
}
type TimeoutHandler struct{}
var successfulResponse = "successful response!"
func (b *TimeoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
time.Sleep(6 * time.Second)
w.Write([]byte(successfulResponse))
}
func TestTimeout(t *testing.T) {
ctx, cancel := context.WithDeadline(ctx, time.Now().Add(2*time.Second))
client := New(&TimeoutHandler{})
defer cancel()
_, err := client.Call(ctx, "GET", "test", nil)
if err.Error() != "timeout for test" {
t.Error("Expected timeout error, but got", err)
}
}
type SuccessfulHandler struct{}
func (b *SuccessfulHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(successfulResponse))
}
func TestSuccess(t *testing.T) {
ctx, cancel := context.WithDeadline(ctx, time.Now().Add(2*time.Second))
client := New(&SuccessfulHandler{})
defer cancel()
w, err := client.Call(ctx, "GET", "test", nil)
if err != nil {
t.Error("Expected no error, but got", err)
}
report, err := io.ReadAll(w.Body())
if string(report) != successfulResponse {
t.Error("Expected successful report, but got", report)
}
}

@ -1,113 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package localapiservice
import (
"context"
"fmt"
"io"
"log"
"net"
"net/http"
"time"
"tailscale.com/ipn/ipnlocal"
)
type LocalAPIService struct {
h http.Handler
}
func New(h http.Handler) *LocalAPIService {
return &LocalAPIService{h: h}
}
// Call calls the given endpoint on the local API using the given HTTP method
// optionally sending the given body. It returns a Response representing the
// result of the call and an error if the call could not be completed or the
// local API returned a status code in the 400 series or greater.
// Note - Response includes a response body available from the Body method, it
// is the caller's responsibility to close this.
func (cl *LocalAPIService) Call(ctx context.Context, method, endpoint string, body io.Reader) (*Response, error) {
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
if err != nil {
return nil, fmt.Errorf("error creating new request for %s: %w", endpoint, err)
}
deadline, _ := ctx.Deadline()
pipeReader, pipeWriter := net.Pipe()
pipeReader.SetDeadline(deadline)
pipeWriter.SetDeadline(deadline)
resp := &Response{
headers: http.Header{},
status: http.StatusOK,
bodyReader: pipeReader,
bodyWriter: pipeWriter,
startWritingBody: make(chan interface{}),
}
go func() {
cl.h.ServeHTTP(resp, req)
resp.Flush()
pipeWriter.Close()
}()
select {
case <-resp.startWritingBody:
if resp.StatusCode() >= 400 {
return resp, fmt.Errorf("request failed with status code %d", resp.StatusCode())
}
return resp, nil
case <-ctx.Done():
return nil, fmt.Errorf("timeout for %s", endpoint)
}
}
func (s *LocalAPIService) GetBugReportID(ctx context.Context, bugReportChan chan<- string, fallbackLog string) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
r, err := s.Call(ctx, "POST", "/localapi/v0/bugreport", nil)
defer r.Body().Close()
if err != nil {
log.Printf("get bug report: %s", err)
bugReportChan <- fallbackLog
return
}
logBytes, err := io.ReadAll(r.Body())
if err != nil {
log.Printf("read bug report: %s", err)
bugReportChan <- fallbackLog
return
}
bugReportChan <- string(logBytes)
}
func (s *LocalAPIService) Login(ctx context.Context, backend *ipnlocal.LocalBackend) {
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
r, err := s.Call(ctx, "POST", "/localapi/v0/login-interactive", nil)
defer r.Body().Close()
if err != nil {
log.Printf("login: %s", err)
backend.StartLoginInteractive()
}
}
func (s *LocalAPIService) Logout(ctx context.Context, backend *ipnlocal.LocalBackend) error {
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
r, err := s.Call(ctx, "POST", "/localapi/v0/logout", nil)
defer r.Body().Close()
if err != nil {
log.Printf("logout: %s", err)
logoutctx, logoutcancel := context.WithTimeout(ctx, 5*time.Minute)
defer logoutcancel()
backend.Logout(logoutctx)
}
return err
}

@ -1,202 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package localapiservice
import (
"bytes"
"context"
"encoding/json"
"io"
"log"
"time"
"unsafe"
jnipkg "github.com/tailscale/tailscale-android/pkg/jni"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
)
// #include <jni.h>
import "C"
// Shims the LocalApiClient class from the Kotlin side to the Go side's LocalAPIService.
var shim struct {
// localApiClient is a global reference to the com.tailscale.ipn.ui.localapi.LocalApiClient class.
clientClass jnipkg.Class
// notifierClass is a global reference to the com.tailscale.ipn.ui.notifier.Notifier class.
notifierClass jnipkg.Class
// Typically a shared LocalAPIService instance.
service *LocalAPIService
backend *ipnlocal.LocalBackend
busWatchers map[string]func()
jvm *jnipkg.JVM
}
//export Java_com_tailscale_ipn_ui_localapi_LocalApiClient_doRequest
func Java_com_tailscale_ipn_ui_localapi_LocalApiClient_doRequest(
env *C.JNIEnv,
cls C.jclass,
jpath C.jstring,
jmethod C.jstring,
jbody C.jbyteArray,
jcookie C.jstring) {
jenv := (*jnipkg.Env)(unsafe.Pointer(env))
// The API Path
pathRef := jnipkg.NewGlobalRef(jenv, jnipkg.Object(jpath))
pathStr := jnipkg.GoString(jenv, jnipkg.String(pathRef))
defer jnipkg.DeleteGlobalRef(jenv, pathRef)
// The HTTP verb
methodRef := jnipkg.NewGlobalRef(jenv, jnipkg.Object(jmethod))
methodStr := jnipkg.GoString(jenv, jnipkg.String(methodRef))
defer jnipkg.DeleteGlobalRef(jenv, methodRef)
// The body string. This is optional and may be empty.
bodyRef := jnipkg.NewGlobalRef(jenv, jnipkg.Object(jbody))
bodyArray := jnipkg.GetByteArrayElements(jenv, jnipkg.ByteArray(bodyRef))
defer jnipkg.DeleteGlobalRef(jenv, bodyRef)
resp := doLocalAPIRequest(pathStr, methodStr, bodyArray)
jrespBody := jnipkg.NewByteArray(jenv, resp)
respBody := jnipkg.Value(jrespBody)
cookie := jnipkg.Value(jcookie)
onResponse := jnipkg.GetMethodID(jenv, shim.clientClass, "onResponse", "([BLjava/lang/String;)V")
jnipkg.CallVoidMethod(jenv, jnipkg.Object(cls), onResponse, respBody, cookie)
}
func doLocalAPIRequest(path string, method string, body []byte) []byte {
if shim.service == nil {
return []byte("{\"error\":\"Not Ready\"}")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
var reader io.Reader = nil
if len(body) > 0 {
reader = bytes.NewReader(body)
}
r, err := shim.service.Call(ctx, method, path, reader)
defer r.Body().Close()
if err != nil {
return []byte("{\"error\":\"" + err.Error() + "\"}")
}
respBytes, err := io.ReadAll(r.Body())
if err != nil {
return []byte("{\"error\":\"" + err.Error() + "\"}")
}
return respBytes
}
// Assign a localAPIService to our shim for handling incoming localapi requests from the Kotlin side.
func ConfigureShim(jvm *jnipkg.JVM, appCtx jnipkg.Object, s *LocalAPIService, b *ipnlocal.LocalBackend) {
shim.busWatchers = make(map[string]func())
shim.service = s
shim.backend = b
configureLocalApiJNIHandler(jvm, appCtx)
// Let the Kotlin side know we're ready to handle requests.
jnipkg.Do(jvm, func(env *jnipkg.Env) error {
onReadyAPI := jnipkg.GetStaticMethodID(env, shim.clientClass, "onReady", "()V")
jnipkg.CallStaticVoidMethod(env, shim.clientClass, onReadyAPI)
onNotifyNot := jnipkg.GetStaticMethodID(env, shim.notifierClass, "onReady", "()V")
jnipkg.CallStaticVoidMethod(env, shim.notifierClass, onNotifyNot)
log.Printf("LocalAPI Shim ready")
return nil
})
}
// Loads the Kotlin-side LocalApiClient class and stores it in a global reference.
func configureLocalApiJNIHandler(jvm *jnipkg.JVM, appCtx jnipkg.Object) error {
shim.jvm = jvm
return jnipkg.Do(jvm, func(env *jnipkg.Env) error {
loader := jnipkg.ClassLoaderFor(env, appCtx)
cl, err := jnipkg.LoadClass(env, loader, "com.tailscale.ipn.ui.localapi.LocalApiClient")
if err != nil {
return err
}
shim.clientClass = jnipkg.Class(jnipkg.NewGlobalRef(env, jnipkg.Object(cl)))
cl, err = jnipkg.LoadClass(env, loader, "com.tailscale.ipn.ui.notifier.Notifier")
if err != nil {
return err
}
shim.notifierClass = jnipkg.Class(jnipkg.NewGlobalRef(env, jnipkg.Object(cl)))
return nil
})
}
//export Java_com_tailscale_ipn_ui_notifier_Notifier_stopIPNBusWatcher
func Java_com_tailscale_ipn_ui_notifier_Notifier_stopIPNBusWatcher(
env *C.JNIEnv,
cls C.jclass,
jsessionId C.jstring) {
jenv := (*jnipkg.Env)(unsafe.Pointer(env))
sessionIdRef := jnipkg.NewGlobalRef(jenv, jnipkg.Object(jsessionId))
sessionId := jnipkg.GoString(jenv, jnipkg.String(sessionIdRef))
defer jnipkg.DeleteGlobalRef(jenv, sessionIdRef)
cancel := shim.busWatchers[sessionId]
if cancel != nil {
log.Printf("Deregistering app layer bus watcher with sessionid: %s", sessionId)
cancel()
delete(shim.busWatchers, sessionId)
} else {
log.Printf("Error: Could not find bus watcher with sessionid: %s", sessionId)
}
}
//export Java_com_tailscale_ipn_ui_notifier_Notifier_startIPNBusWatcher
func Java_com_tailscale_ipn_ui_notifier_Notifier_startIPNBusWatcher(
env *C.JNIEnv,
cls C.jclass,
jsessionId C.jstring,
jmask C.jint) {
jenv := (*jnipkg.Env)(unsafe.Pointer(env))
sessionIdRef := jnipkg.NewGlobalRef(jenv, jnipkg.Object(jsessionId))
sessionId := jnipkg.GoString(jenv, jnipkg.String(sessionIdRef))
defer jnipkg.DeleteGlobalRef(jenv, sessionIdRef)
log.Printf("Registering app layer bus watcher with sessionid: %s", sessionId)
ctx, cancel := context.WithCancel(context.Background())
shim.busWatchers[sessionId] = cancel
opts := ipn.NotifyWatchOpt(jmask)
shim.backend.WatchNotifications(ctx, opts, func() {
// onWatchAdded
}, func(roNotify *ipn.Notify) bool {
js, err := json.Marshal(roNotify)
if err != nil {
return true
}
jnipkg.Do(shim.jvm, func(env *jnipkg.Env) error {
jjson := jnipkg.JavaString(env, string(js))
onNotify := jnipkg.GetMethodID(env, shim.notifierClass, "onNotify", "(Ljava/lang/String;Ljava/lang/String;)V")
jnipkg.CallVoidMethod(env, jnipkg.Object(cls), onNotify, jnipkg.Value(jjson), jnipkg.Value(jsessionId))
return nil
})
return true
})
}

@ -1,53 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package localapiservice
import (
"net"
"net/http"
"sync"
)
// Response represents the result of processing an localAPI request.
// On completion, the response body can be read out of the bodyWriter.
type Response struct {
headers http.Header
status int
bodyWriter net.Conn
bodyReader net.Conn
startWritingBody chan interface{}
startWritingBodyOnce sync.Once
}
func (r *Response) Header() http.Header {
return r.headers
}
// Write writes the data to the response body which an then be
// read out as a json object.
func (r *Response) Write(data []byte) (int, error) {
r.Flush()
if r.status == 0 {
r.WriteHeader(http.StatusOK)
}
return r.bodyWriter.Write(data)
}
func (r *Response) WriteHeader(statusCode int) {
r.status = statusCode
}
func (r *Response) Body() net.Conn {
return r.bodyReader
}
func (r *Response) StatusCode() int {
return r.status
}
func (r *Response) Flush() {
r.startWritingBodyOnce.Do(func() {
close(r.startWritingBody)
})
}

@ -1,85 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"os"
"path/filepath"
"sync"
"sync/atomic"
"unsafe"
jnipkg "github.com/tailscale/tailscale-android/pkg/jni"
"github.com/tailscale/tailscale-android/pkg/localapiservice"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/types/logid"
)
// #include <jni.h>
import "C"
type App struct {
jvm *jnipkg.JVM
// appCtx is a global reference to the com.tailscale.ipn.App instance.
appCtx jnipkg.Object
store *stateStore
logIDPublicAtomic atomic.Pointer[logid.PublicID]
localAPI *localapiservice.LocalAPIService
backend *ipnlocal.LocalBackend
}
var android struct {
// mu protects all fields of this structure. However, once a
// non-nil jvm is returned from javaVM, all the other fields may
// be accessed unlocked.
mu sync.Mutex
jvm *jnipkg.JVM
// appCtx is the global Android App context.
appCtx C.jobject
}
func initJVM(env *C.JNIEnv, ctx C.jobject) {
android.mu.Lock()
defer android.mu.Unlock()
jenv := (*jnipkg.Env)(unsafe.Pointer(env))
res, err := jnipkg.GetJavaVM(jenv)
if err != nil {
panic("eror: GetJavaVM failed")
}
android.jvm = res
android.appCtx = C.jobject(jnipkg.NewGlobalRef(jenv, jnipkg.Object(ctx)))
}
//export Java_com_tailscale_ipn_App_initBackend
func Java_com_tailscale_ipn_App_initBackend(env *C.JNIEnv, class C.jclass, jdataDir C.jbyteArray, context C.jobject) {
initJVM(env, context)
jenv := (*jnipkg.Env)(unsafe.Pointer(env))
dirBytes := jnipkg.GetByteArrayElements(jenv, jnipkg.ByteArray(jdataDir))
if dirBytes == nil {
panic("runGoMain: GetByteArrayElements failed")
}
n := jnipkg.GetArrayLength(jenv, jnipkg.ByteArray(jdataDir))
dataDir := C.GoStringN((*C.char)(unsafe.Pointer(&dirBytes[0])), C.int(n))
// Set XDG_CACHE_HOME to make os.UserCacheDir work.
if _, exists := os.LookupEnv("XDG_CACHE_HOME"); !exists {
cachePath := filepath.Join(dataDir, "cache")
os.Setenv("XDG_CACHE_HOME", cachePath)
}
// Set XDG_CONFIG_HOME to make os.UserConfigDir work.
if _, exists := os.LookupEnv("XDG_CONFIG_HOME"); !exists {
cfgPath := filepath.Join(dataDir, "config")
os.Setenv("XDG_CONFIG_HOME", cfgPath)
}
// Set HOME to make os.UserHomeDir work.
if _, exists := os.LookupEnv("HOME"); !exists {
os.Setenv("HOME", dataDir)
}
dataDirChan <- dataDir
main()
}

@ -1,151 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"sync"
"unsafe"
jnipkg "github.com/tailscale/tailscale-android/pkg/jni"
)
// #include <jni.h>
import "C"
var (
// onVPNPrepared is notified when VpnService.prepare succeeds.
onVPNPrepared = make(chan struct{}, 1)
// onVPNClosed is notified when VpnService.prepare fails, or when
// the a running VPN connection is closed.
onVPNClosed = make(chan struct{}, 1)
// onVPNRevoked is notified whenever the VPN service is revoked.
onVPNRevoked = make(chan struct{}, 1)
// onVPNRequested receives global IPNService references when
// a VPN connection is requested.
onVPNRequested = make(chan jnipkg.Object)
// onDisconnect receives global IPNService references when
// disconnecting.
onDisconnect = make(chan jnipkg.Object)
onConnect = make(chan ConnectEvent)
// onGoogleToken receives google ID tokens.
onGoogleToken = make(chan string)
// onDNSConfigChanged is notified when the network changes and the DNS config needs to be updated.
onDNSConfigChanged = make(chan struct{}, 1)
)
const (
// Request codes for Android callbacks.
// requestSignin is for Google Sign-In.
requestSignin C.jint = 1000 + iota
// requestPrepareVPN is for when Android's VpnService.prepare
// completes.
requestPrepareVPN
)
// resultOK is Android's Activity.RESULT_OK.
const resultOK = -1
//export Java_com_tailscale_ipn_App_onVPNPrepared
func Java_com_tailscale_ipn_App_onVPNPrepared(env *C.JNIEnv, class C.jclass) {
notifyVPNPrepared()
}
//export Java_com_tailscale_ipn_IPNService_requestVPN
func Java_com_tailscale_ipn_IPNService_requestVPN(env *C.JNIEnv, this C.jobject) {
jenv := (*jnipkg.Env)(unsafe.Pointer(env))
onVPNRequested <- jnipkg.NewGlobalRef(jenv, jnipkg.Object(this))
}
//export Java_com_tailscale_ipn_IPNService_connect
func Java_com_tailscale_ipn_IPNService_connect(env *C.JNIEnv, this C.jobject) {
onConnect <- ConnectEvent{Enable: true}
}
//export Java_com_tailscale_ipn_IPNService_disconnect
func Java_com_tailscale_ipn_IPNService_disconnect(env *C.JNIEnv, this C.jobject) {
jenv := (*jnipkg.Env)(unsafe.Pointer(env))
onDisconnect <- jnipkg.NewGlobalRef(jenv, jnipkg.Object(this))
}
//export Java_com_tailscale_ipn_StartVPNWorker_connect
func Java_com_tailscale_ipn_StartVPNWorker_connect(env *C.JNIEnv, this C.jobject) {
onConnect <- ConnectEvent{Enable: true}
}
//export Java_com_tailscale_ipn_StopVPNWorker_disconnect
func Java_com_tailscale_ipn_StopVPNWorker_disconnect(env *C.JNIEnv, this C.jobject) {
onConnect <- ConnectEvent{Enable: false}
}
//export Java_com_tailscale_ipn_Peer_onActivityResult0
func Java_com_tailscale_ipn_Peer_onActivityResult0(env *C.JNIEnv, cls C.jclass, act C.jobject, reqCode, resCode C.jint) {
switch reqCode {
case requestSignin:
if resCode != resultOK {
onGoogleToken <- ""
break
}
jenv := (*jnipkg.Env)(unsafe.Pointer(env))
m := jnipkg.GetStaticMethodID(jenv, googleClass,
"getIdTokenForActivity", "(Landroid/app/Activity;)Ljava/lang/String;")
idToken, err := jnipkg.CallStaticObjectMethod(jenv, googleClass, m, jnipkg.Value(act))
if err != nil {
fatalErr(err)
break
}
tok := jnipkg.GoString(jenv, jnipkg.String(idToken))
onGoogleToken <- tok
case requestPrepareVPN:
if resCode == resultOK {
notifyVPNPrepared()
} else {
notifyVPNClosed()
notifyVPNRevoked()
}
}
}
//export Java_com_tailscale_ipn_App_onDnsConfigChanged
func Java_com_tailscale_ipn_App_onDnsConfigChanged(env *C.JNIEnv, cls C.jclass) {
select {
case onDNSConfigChanged <- struct{}{}:
default:
}
}
func notifyVPNPrepared() {
select {
case onVPNPrepared <- struct{}{}:
default:
}
}
func notifyVPNRevoked() {
select {
case onVPNRevoked <- struct{}{}:
default:
}
}
func notifyVPNClosed() {
select {
case onVPNClosed <- struct{}{}:
default:
}
}
var android struct {
// mu protects all fields of this structure. However, once a
// non-nil jvm is returned from javaVM, all the other fields may
// be accessed unlocked.
mu sync.Mutex
jvm *jnipkg.JVM
// appCtx is the global Android App context.
appCtx C.jobject
}
Loading…
Cancel
Save