Full Code of ppyyr/tailscale-android for AI

main 6030dd3fb5ef cached
43 files
276.5 KB
103.9k tokens
322 symbols
1 requests
Download .txt
Showing preview only (292K chars total). Download the full file or copy to clipboard to get everything.
Repository: ppyyr/tailscale-android
Branch: main
Commit: 6030dd3fb5ef
Files: 43
Total size: 276.5 KB

Directory structure:
gitextract_xph_jeqj/

├── .github/
│   └── workflows/
│       └── main.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── Makefile
├── PATENTS
├── README.md
├── android/
│   ├── build.gradle
│   ├── gradle/
│   │   └── wrapper/
│   │       ├── gradle-wrapper.jar
│   │       └── gradle-wrapper.properties
│   ├── gradle.properties
│   ├── gradlew
│   ├── gradlew.bat
│   └── src/
│       ├── main/
│       │   ├── AndroidManifest.xml
│       │   ├── java/
│       │   │   └── com/
│       │   │       └── tailscale/
│       │   │           └── ipn/
│       │   │               ├── App.java
│       │   │               ├── DnsConfig.java
│       │   │               ├── IPNActivity.java
│       │   │               ├── IPNService.java
│       │   │               ├── Peer.java
│       │   │               └── QuickToggleService.java
│       │   └── res/
│       │       ├── drawable/
│       │       │   ├── ic_launcher_foreground.xml
│       │       │   └── ic_tile.xml
│       │       ├── mipmap-anydpi-v26/
│       │       │   ├── ic_launcher.xml
│       │       │   └── ic_launcher_round.xml
│       │       └── values/
│       │           ├── ic_launcher_background.xml
│       │           └── strings.xml
│       ├── play/
│       │   └── java/
│       │       └── com/
│       │           └── tailscale/
│       │               └── ipn/
│       │                   └── Google.java
│       └── test/
│           └── java/
│               └── com/
│                   └── tailscale/
│                       └── ipn/
│                           └── DnsConfigTest.java
├── cmd/
│   └── tailscale/
│       ├── backend.go
│       ├── callbacks.go
│       ├── main.go
│       ├── multitun.go
│       ├── pprof.go
│       ├── store.go
│       ├── tools.go
│       └── ui.go
├── flake.nix
├── go.mod
├── go.sum
├── jni/
│   └── jni.go
├── metadata/
│   └── en-US/
│       ├── full_description.txt
│       └── short_description.txt
└── version/
    └── tailscale-version.sh

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/workflows/main.yml
================================================
# This is a basic workflow to help you get started with Actions

name: CI

# Controls when the workflow will run
on:
  # Triggers the workflow on push or pull request events but only for the main branch
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
  # This workflow contains a single job called "build"
  build:
    # The type of runner that the job will run on
    runs-on: ubuntu-latest

    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
      # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
      - uses: actions/checkout@v3

      # Runs a single command using the runners shell
      - name: Create Docker container
        run: docker build . --file Dockerfile --tag tailscale-android

      # Runs a set of commands using the runners shell
      - name: build tailscale-fdroid.apk
        run: docker run -v /home/runner/work/tailscale-android/tailscale-android:/build/tailscale-android tailscale-android make tailscale-fdroid.apk 
        
      - name: Upload a Build Artifact
        uses: actions/upload-artifact@v3.0.0
        with:
          # Artifact name
          name: tailscale-fdroid.apk # optional, default is artifact
          # A file, directory or wildcard pattern that describes what to upload
          path: /home/runner/work/tailscale-android/tailscale-android/tailscale-fdroid.apk
          # The desired behavior if no files are found using the provided path.
          if-no-files-found: error # optional, default is warn


          retention-days: 30 # optional
        


================================================
FILE: .gitignore
================================================
# Ignore Gradle project-specific cache directory
.gradle

# Ignore Gradle build output directory
build

# The destination for the Go Android archive.
android/libs

# Output files from the Makefile:
tailscale-debug.apk


================================================
FILE: Dockerfile
================================================
# This is a Dockerfile for creating a build environment for
# tailscale-android.

FROM openjdk:8-jdk

# To enable running android tools such as aapt
RUN apt-get update && apt-get -y upgrade
RUN apt-get install -y lib32z1 lib32stdc++6
# For Go:
RUN apt-get -y --no-install-recommends install curl gcc
RUN apt-get -y --no-install-recommends install ca-certificates libc6-dev git

RUN apt-get -y install make

RUN mkdir -p BUILD
ENV HOME /build

# Get android sdk, ndk, and rest of the stuff needed to build the android app.
WORKDIR $HOME
RUN mkdir android-sdk
ENV ANDROID_HOME $HOME/android-sdk
WORKDIR $ANDROID_HOME
RUN curl -O https://dl.google.com/android/repository/sdk-tools-linux-3859397.zip
RUN echo '444e22ce8ca0f67353bda4b85175ed3731cae3ffa695ca18119cbacef1c1bea0  sdk-tools-linux-3859397.zip' | sha256sum -c
RUN unzip sdk-tools-linux-3859397.zip
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager --update
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'platforms;android-31'
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'extras;android;m2repository'
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'ndk;23.1.7779620'
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'platform-tools'
RUN echo y | $ANDROID_HOME/tools/bin/sdkmanager 'build-tools;28.0.3'

ENV PATH $PATH:$HOME/bin:$ANDROID_HOME/platform-tools
ENV ANDROID_SDK_ROOT /build/android-sdk

# We need some version of Go new enough to support the "embed" package
# to run "go run tailscale.com/cmd/printdep" to figure out which Tailscale Go
# version we need later, but otherwise this toolchain isn't used:
RUN curl -L https://go.dev/dl/go1.17.5.linux-amd64.tar.gz | tar -C /usr/local -zxv
RUN ln -s /usr/local/go/bin/go /usr/bin

RUN mkdir -p $HOME/tailscale-android
WORKDIR $HOME/tailscale-android

# Preload Gradle
COPY android/gradlew android/gradlew
COPY android/gradle android/gradle
RUN ./android/gradlew

CMD /bin/bash


================================================
FILE: LICENSE
================================================
Copyright (c) 2020 Tailscale & AUTHORS. All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:

   * Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
   * Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
   * Neither the name of Tailscale Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.


================================================
FILE: Makefile
================================================
# Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
# Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file.

DEBUG_APK=tailscale-debug.apk
RELEASE_AAB=tailscale-release.aab
APPID=com.tailscale.ipn
AAR=android/libs/ipn.aar
KEYSTORE=tailscale.jks
KEYSTORE_ALIAS=tailscale
TAILSCALE_VERSION=$(shell ./version/tailscale-version.sh 200)
OUR_VERSION=$(shell git describe --dirty --exclude "*" --always --abbrev=200)
TAILSCALE_VERSION_ABBREV=$(shell ./version/tailscale-version.sh 11)
OUR_VERSION_ABBREV=$(shell git describe --dirty --exclude "*" --always --abbrev=11)
VERSION_LONG=$(TAILSCALE_VERSION_ABBREV)-g$(OUR_VERSION_ABBREV)
# Extract the long version build.gradle's versionName and strip quotes.
VERSIONNAME=$(patsubst "%",%,$(lastword $(shell grep versionName android/build.gradle)))
# Extract the x.y.z part for the short version.
VERSIONNAME_SHORT=$(shell echo $(VERSIONNAME) | cut -d - -f 1)
TAILSCALE_COMMIT=$(shell echo $(TAILSCALE_VERSION) | cut -d - -f 2 | cut -d t -f 2)
# Extract the version code from build.gradle.
VERSIONCODE=$(lastword $(shell grep versionCode android/build.gradle))
VERSIONCODE_PLUSONE=$(shell expr $(VERSIONCODE) + 1)

TOOLCHAINREV=$(shell go run tailscale.com/cmd/printdep --go)
TOOLCHAINDIR=${HOME}/.cache/tailscale-android-go-$(TOOLCHAINREV)
TOOLCHAINSUM=$(shell $(TOOLCHAINDIR)/go/bin/go version >/dev/null && echo "okay" || echo "bad")
TOOLCHAINWANT=okay
export PATH := $(TOOLCHAINDIR)/go/bin:$(PATH)
export GOROOT := # Unset

all: $(APK)

tag_release:
	sed -i'.bak' 's/versionCode [[:digit:]]\+/versionCode $(VERSIONCODE_PLUSONE)/' android/build.gradle
	sed -i'.bak' 's/versionName .*/versionName "$(VERSION_LONG)"/' android/build.gradle
	git commit -sm "android: bump version code" android/build.gradle
	git tag -a "$(VERSION_LONG)"

bumposs: toolchain
	GOPROXY=direct go get tailscale.com@main
	go mod tidy -compat=1.17

toolchain:
ifneq ($(TOOLCHAINWANT),$(TOOLCHAINSUM))
	@echo want: $(TOOLCHAINWANT)
	@echo got: $(TOOLCHAINSUM)
	rm -rf ${HOME}/.cache/tailscale-android-go-*
	mkdir -p $(TOOLCHAINDIR)
	curl --silent -L $(shell go run tailscale.com/cmd/printdep --go-url) | tar -C $(TOOLCHAINDIR) -zx
endif

$(DEBUG_APK): toolchain
	mkdir -p android/libs
	go run gioui.org/cmd/gogio -buildmode archive -target android -appid $(APPID) -tags novulkan -o $(AAR) github.com/tailscale/tailscale-android/cmd/tailscale
	(cd android && ./gradlew test assemblePlayDebug)
	mv android/build/outputs/apk/play/debug/android-play-debug.apk $@

rundebug: $(DEBUG_APK)
	adb install -r $(DEBUG_APK)
	adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.IPNActivity

# 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.
tailscale-fdroid.apk: toolchain
	mkdir -p android/libs
	go run gioui.org/cmd/gogio -buildmode archive -target android -appid $(APPID) -tags novulkan -o $(AAR) github.com/tailscale/tailscale-android/cmd/tailscale
	(cd android && ./gradlew test assembleFdroidDebug)
	mv android/build/outputs/apk/fdroid/debug/android-fdroid-debug.apk $@

# This target is also used by the F-Droid builder.
release_aar: toolchain
release_aar:
	mkdir -p android/libs
	go run gioui.org/cmd/gogio -ldflags "-X tailscale.com/version.Long=$(VERSIONNAME) -X tailscale.com/version.Short=$(VERSIONNAME_SHORT) -X tailscale.com/version.GitCommit=$(TAILSCALE_COMMIT) -X tailscale.com/version.ExtraGitCommit=$(OUR_VERSION)" -buildmode archive -target android -appid $(APPID) -tags novulkan -o $(AAR) github.com/tailscale/tailscale-android/cmd/tailscale

$(RELEASE_AAB): release_aar
	(cd android && ./gradlew test bundlePlayRelease)
	mv ./android/build/outputs/bundle/playRelease/android-play-release.aab $@

release: $(RELEASE_AAB)
	jarsigner -sigalg SHA256withRSA -digestalg SHA-256 -keystore $(KEYSTORE) $(RELEASE_AAB) $(KEYSTORE_ALIAS)

install: $(DEBUG_APK)
	adb install -r $(DEBUG_APK)

dockershell:
	docker build -t tailscale-android .
	docker run -v $(CURDIR):/build/tailscale-android -it --rm tailscale-android

clean:
	rm -rf android/build $(RELEASE_AAB) $(DEBUG_APK) $(AAR)

.PHONY: all clean install $(DEBUG_APK) $(RELEASE_AAB) release_aar release bump_version dockershell


================================================
FILE: PATENTS
================================================
Additional IP Rights Grant (Patents)

"This implementation" means the copyrightable works distributed by
Tailscale Inc. as part of the Tailscale project.

Tailscale Inc. hereby grants to You a perpetual, worldwide,
non-exclusive, no-charge, royalty-free, irrevocable (except as stated
in this section) patent license to make, have made, use, offer to
sell, sell, import, transfer and otherwise run, modify and propagate
the contents of this implementation of Tailscale, where such license
applies only to those patent claims, both currently owned or
controlled by Tailscale Inc. and acquired in the future, licensable
by Tailscale Inc. that are necessarily infringed by this
implementation of Tailscale.  This grant does not include claims that
would be infringed only as a consequence of further modification of
this implementation.  If you or your agent or exclusive licensee
institute or order or agree to the institution of patent litigation
against any entity (including a cross-claim or counterclaim in a
lawsuit) alleging that this implementation of Tailscale or any code
incorporated within this implementation of Tailscale constitutes
direct or contributory patent infringement, or inducement of patent
infringement, then any patent rights granted to you under this License
for this implementation of Tailscale shall terminate as of the date
such litigation is filed.


================================================
FILE: README.md
================================================
# Tailscale Android Client

https://tailscale.com

Private WireGuard® networks made easy

## Overview

This repository contains the open source Tailscale Android client.

## Using

[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
     alt="Get it on F-Droid"
     height="80">](https://f-droid.org/packages/com.tailscale.ipn/)
[<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png"
     alt="Get it on Google Play"
     height="80">](https://play.google.com/store/apps/details?id=com.tailscale.ipn)

## Building

[Go](https://golang.org), the [Android
SDK](https://developer.android.com/studio/releases/platform-tools), 
the [Android NDK](https://developer.android.com/ndk) are required.

```sh
$ make tailscale-debug.apk
$ adb install -r tailscale-debug.apk
```

The `dockershell` target builds a container with the necessary
dependencies and runs a shell inside it.

```sh
$ make dockershell
# make tailscale-debug.apk
```

If you have Nix 2.4 or later installed, a Nix development environment can
be set up with

```sh
$ alias nix='nix --extra-experimental-features "nix-command flakes"'
$ nix develop
```

Use `make tag_release` to bump the Android version code, update the version
name, and tag the current commit.

We only guarantee to support the latest Go release and any Go beta or
release candidate builds (currently Go 1.14) in module mode. It might
work in earlier Go versions or in GOPATH mode, but we're making no
effort to keep those working.

## Google Sign-In

Google Sign-In support relies on configuring a [Google API Console
project](https://developers.google.com/identity/sign-in/android/start-integrating)
with the app identifier and [signing key
hashes](https://developers.google.com/android/guides/client-auth).
The official release uses the app identifier `com.tailscale.ipn`;
custom builds should use a different identifier.

## Running in the Android emulator

By default, the android emulator uses an older version of OpenGL ES,
which results in a black screen when opening the Tailscale app. To fix
this, with the emulator running:

 - Open the three-dots menu to access emulator settings
 - To to `Settings > Advanced`
 - Set "OpenGL ES API level" to "Renderer maximum (up to OpenGL ES 3.1)"
 - Close the emulator.
 - In Android Studio's emulator view (that lists all your emulated
   devices), hit the down arrow by the virtual device and select "Cold
   boot now" to restart the emulator from scratch.

The Tailscale app should now render correctly.

Additionally, there seems to be a bug that prevents using the
system-level Google sign-in option (the one that pops up a
system-level UI to select your Google account). You can work around
this by selecting "Other" at the sign-in screen, and then selecting
Google from the next screen.

## Developing on a Fire Stick TV

On the Fire Stick:

* Settings > My Fire TV > Developer Options > ADB Debugging > ON

Then some useful commands:
```
adb connect 10.2.200.213:5555
adb install -r tailscale-fdroid.apk
adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.IPNActivity
adb shell pm uninstall com.tailscale.ipn
```

## Building on macOS

To build from the CLI on macOS:

1. Install Android Studio
2. In Android Studio's home screen: "More Actions" > "SDK Manager", install NDK.
3. You can now close Android Studio, unless you want it to create virtual devices
   ("More Actions" > "Virtual Device Manager").
4. Then, from CLI:
5. `export JAVA_HOME='/Applications/Android Studio.app/Contents/jre/Contents/Home'`
6. `export ANDROID_SDK_ROOT=$HOME/Library/Android/sdk`
7. `make tailscale-fdroid.apk`, etc

## Bugs

Please file any issues about this code or the hosted service on
[the tailscale issue tracker](https://github.com/tailscale/tailscale/issues).

## Contributing

`under_construction.gif`

PRs welcome, but we are still working out our contribution process and
tooling.

We require [Developer Certificate of
Origin](https://en.wikipedia.org/wiki/Developer_Certificate_of_Origin)
`Signed-off-by` lines in commits.

## About Us

We are apenwarr, bradfitz, crawshaw, danderson, dfcarney,
from Tailscale Inc.
You can learn more about us from [our website](https://tailscale.com).

WireGuard is a registered trademark of Jason A. Donenfeld.


================================================
FILE: android/build.gradle
================================================
buildscript {
	repositories {
		google()
		jcenter()
	}
	dependencies {
		classpath 'com.android.tools.build:gradle:4.2.0'
	}
}

allprojects {
	repositories {
		google()
		jcenter()
		flatDir {
			dirs 'libs'
		}
	}
}

apply plugin: 'com.android.application'

android {
	ndkVersion "23.1.7779620"
	compileSdkVersion 30
	defaultConfig {
		minSdkVersion 22
		targetSdkVersion 30
		versionCode 114
		versionName "1.29.0-t3c892d106-g42f688f1292"
	}
	compileOptions {
		sourceCompatibility 1.8
		targetCompatibility 1.8
	}
	flavorDimensions "version"
	productFlavors {
		fdroid {
			// The fdroid flavor contains only free dependencies and is suitable
			// for the F-Droid app store.
		}
		play {
			// The play flavor contains all features and is for the Play Store.
		}
	}
}

dependencies {
	implementation "androidx.core:core:1.2.0"
	implementation "androidx.browser:browser:1.2.0"
	implementation "androidx.security:security-crypto:1.1.0-alpha03"
	implementation ':ipn@aar'
	testCompile "junit:junit:4.12"

	// Non-free dependencies.
	playImplementation 'com.google.android.gms:play-services-auth:18.0.0'
}


================================================
FILE: android/gradle/wrapper/gradle-wrapper.properties
================================================
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=3239b5ed86c3838a37d983ac100573f64c1f3fd8e1eb6c89fa5f9529b5ec091d
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists


================================================
FILE: android/gradle.properties
================================================
android.useAndroidX=true


================================================
FILE: android/gradlew
================================================
#!/usr/bin/env sh

#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

##############################################################################
##
##  Gradle start up script for UN*X
##
##############################################################################

# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
    ls=`ls -ld "$PRG"`
    link=`expr "$ls" : '.*-> \(.*\)$'`
    if expr "$link" : '/.*' > /dev/null; then
        PRG="$link"
    else
        PRG=`dirname "$PRG"`"/$link"
    fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null

APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`

# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'

# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"

warn () {
    echo "$*"
}

die () {
    echo
    echo "$*"
    echo
    exit 1
}

# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
  CYGWIN* )
    cygwin=true
    ;;
  Darwin* )
    darwin=true
    ;;
  MINGW* )
    msys=true
    ;;
  NONSTOP* )
    nonstop=true
    ;;
esac

CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar

# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
        # IBM's JDK on AIX uses strange locations for the executables
        JAVACMD="$JAVA_HOME/jre/sh/java"
    else
        JAVACMD="$JAVA_HOME/bin/java"
    fi
    if [ ! -x "$JAVACMD" ] ; then
        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME

Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
    fi
else
    JAVACMD="java"
    which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.

Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi

# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
    MAX_FD_LIMIT=`ulimit -H -n`
    if [ $? -eq 0 ] ; then
        if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
            MAX_FD="$MAX_FD_LIMIT"
        fi
        ulimit -n $MAX_FD
        if [ $? -ne 0 ] ; then
            warn "Could not set maximum file descriptor limit: $MAX_FD"
        fi
    else
        warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
    fi
fi

# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
    GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi

# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
    APP_HOME=`cygpath --path --mixed "$APP_HOME"`
    CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
    JAVACMD=`cygpath --unix "$JAVACMD"`

    # We build the pattern for arguments to be converted via cygpath
    ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
    SEP=""
    for dir in $ROOTDIRSRAW ; do
        ROOTDIRS="$ROOTDIRS$SEP$dir"
        SEP="|"
    done
    OURCYGPATTERN="(^($ROOTDIRS))"
    # Add a user-defined pattern to the cygpath arguments
    if [ "$GRADLE_CYGPATTERN" != "" ] ; then
        OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
    fi
    # Now convert the arguments - kludge to limit ourselves to /bin/sh
    i=0
    for arg in "$@" ; do
        CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
        CHECK2=`echo "$arg"|egrep -c "^-"`                                 ### Determine if an option

        if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then                    ### Added a condition
            eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
        else
            eval `echo args$i`="\"$arg\""
        fi
        i=`expr $i + 1`
    done
    case $i in
        0) set -- ;;
        1) set -- "$args0" ;;
        2) set -- "$args0" "$args1" ;;
        3) set -- "$args0" "$args1" "$args2" ;;
        4) set -- "$args0" "$args1" "$args2" "$args3" ;;
        5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
        6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
        7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
        8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
        9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
    esac
fi

# Escape application args
save () {
    for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
    echo " "
}
APP_ARGS=`save "$@"`

# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"

exec "$JAVACMD" "$@"


================================================
FILE: android/gradlew.bat
================================================
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem      https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem

@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem  Gradle startup script for Windows
@rem
@rem ##########################################################################

@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal

set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%

@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi

@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"

@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome

set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init

echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.

goto fail

:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe

if exist "%JAVA_EXE%" goto init

echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.

goto fail

:init
@rem Get command-line arguments, handling Windows variants

if not "%OS%" == "Windows_NT" goto win9xME_args

:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2

:win9xME_args_slurp
if "x%~1" == "x" goto execute

set CMD_LINE_ARGS=%*

:execute
@rem Setup the command line

set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar

@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%

:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd

:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if  not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1

:mainEnd
if "%OS%"=="Windows_NT" endlocal

:omega


================================================
FILE: android/src/main/AndroidManifest.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
	package="com.tailscale.ipn">
	<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
	<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
	<uses-permission android:name="android.permission.INTERNET" />
	<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
	<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>

	<!-- Disable input emulation on ChromeOS -->
	<uses-feature android:name="android.hardware.type.pc" android:required="false"/>

	<!-- Signal support for Android TV -->
	<uses-feature android:name="android.software.leanback" android:required="false" />
	<uses-feature android:name="android.hardware.touchscreen" android:required="false" />

	<application android:label="Tailscale" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round"
                     android:banner="@drawable/tv_banner"
                     android:name=".App" android:allowBackup="false">
		<activity android:name="IPNActivity"
			android:label="@string/app_name"
			android:theme="@style/Theme.GioApp"
			android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"
			android:windowSoftInputMode="adjustResize"
			android:launchMode="singleTask">
			<intent-filter>
				<action android:name="android.intent.action.MAIN" />
				<category android:name="android.intent.category.LAUNCHER" />
				<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
			</intent-filter>
			<intent-filter>
				<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
			</intent-filter>
			<intent-filter>
				<action android:name="android.intent.action.SEND" />
				<category android:name="android.intent.category.DEFAULT"/>
				<data android:mimeType="application/*" />
				<data android:mimeType="audio/*" />
				<data android:mimeType="image/*" />
				<data android:mimeType="message/*" />
				<data android:mimeType="multipart/*" />
				<data android:mimeType="text/*" />
				<data android:mimeType="video/*" />
			</intent-filter>
			<intent-filter>
				<action android:name="android.intent.action.SEND_MULTIPLE" />
				<category android:name="android.intent.category.DEFAULT" />
				<data android:mimeType="application/*" />
				<data android:mimeType="audio/*" />
				<data android:mimeType="image/*" />
				<data android:mimeType="message/*" />
				<data android:mimeType="multipart/*" />
				<data android:mimeType="text/*" />
				<data android:mimeType="video/*" />
			</intent-filter>
		</activity>
		<service android:name=".IPNService"
			android:permission="android.permission.BIND_VPN_SERVICE">
			<intent-filter>
				<action android:name="android.net.VpnService"/>
			</intent-filter>
		</service>
		<service
			android:name=".QuickToggleService"
			android:icon="@drawable/ic_tile"
			android:label="@string/tile_name"
			android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
		<intent-filter>
			<action android:name="android.service.quicksettings.action.QS_TILE"/>
		</intent-filter>
	</service>
	</application>
</manifest>



================================================
FILE: android/src/main/java/com/tailscale/ipn/App.java
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package com.tailscale.ipn;

import android.app.Application;
import android.app.Activity;
import android.app.DownloadManager;
import android.app.Fragment;
import android.app.FragmentTransaction;
import android.app.NotificationChannel;
import android.app.PendingIntent;
import android.app.UiModeManager;
import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.pm.PackageInfo;
import android.content.pm.Signature;
import android.content.res.Configuration;
import android.provider.MediaStore;
import android.provider.Settings;
import android.net.ConnectivityManager;
import android.net.LinkProperties;
import android.net.Network;
import android.net.NetworkInfo;
import android.net.NetworkRequest;
import android.net.Uri;
import android.net.VpnService;
import android.view.View;
import android.os.Build;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;

import android.Manifest;
import android.webkit.MimeTypeMap;

import java.io.IOException;
import java.io.File;
import java.io.FileOutputStream;

import java.lang.StringBuilder;

import java.net.InetAddress;
import java.net.InterfaceAddress;
import java.net.NetworkInterface;

import java.security.GeneralSecurityException;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;

import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;

import androidx.core.content.ContextCompat;

import androidx.security.crypto.EncryptedSharedPreferences;
import androidx.security.crypto.MasterKey;

import androidx.browser.customtabs.CustomTabsIntent;

import org.gioui.Gio;

public class App extends Application {
	private final static String PEER_TAG = "peer";

	static final String STATUS_CHANNEL_ID = "tailscale-status";
	static final int STATUS_NOTIFICATION_ID = 1;

	static final String NOTIFY_CHANNEL_ID = "tailscale-notify";
	static final int NOTIFY_NOTIFICATION_ID = 2;

	private static final String FILE_CHANNEL_ID = "tailscale-files";
	private static final int FILE_NOTIFICATION_ID = 3;

	private final static Handler mainHandler = new Handler(Looper.getMainLooper());

	public DnsConfig dns = new DnsConfig(this);
	public DnsConfig getDnsConfigObj() { return this.dns; }

	@Override public void onCreate() {
		super.onCreate();
		// Load and initialize the Go library.
		Gio.init(this);
		registerNetworkCallback();

		createNotificationChannel(NOTIFY_CHANNEL_ID, "Notifications", NotificationManagerCompat.IMPORTANCE_DEFAULT);
		createNotificationChannel(STATUS_CHANNEL_ID, "VPN Status", NotificationManagerCompat.IMPORTANCE_LOW);
		createNotificationChannel(FILE_CHANNEL_ID, "File transfers", NotificationManagerCompat.IMPORTANCE_DEFAULT);

	}

	private void registerNetworkCallback() {
		ConnectivityManager cMgr = (ConnectivityManager) this.getSystemService(Context.CONNECTIVITY_SERVICE);
		cMgr.registerNetworkCallback(new NetworkRequest.Builder().build(), new ConnectivityManager.NetworkCallback() {
			private void reportConnectivityChange() {
				NetworkInfo active = cMgr.getActiveNetworkInfo();
				// https://developer.android.com/training/monitoring-device-state/connectivity-status-type
				boolean isConnected = active != null && active.isConnectedOrConnecting();
				onConnectivityChanged(isConnected);
			}

			@Override
			public void onLost(Network network) {
				super.onLost(network);
				this.reportConnectivityChange();
			}

			@Override
			public void onLinkPropertiesChanged(Network network, LinkProperties linkProperties) {
				super.onLinkPropertiesChanged(network, linkProperties);
				this.reportConnectivityChange();
			}
		});
	}

	public void startVPN() {
		Intent intent = new Intent(this, IPNService.class);
		intent.setAction(IPNService.ACTION_CONNECT);
		startService(intent);
	}

	public void stopVPN() {
		Intent intent = new Intent(this, IPNService.class);
		intent.setAction(IPNService.ACTION_DISCONNECT);
		startService(intent);
	}

	// encryptToPref a byte array of data using the Jetpack Security
	// library and writes it to a global encrypted preference store.
	public void encryptToPref(String prefKey, String plaintext) throws IOException, GeneralSecurityException {
		getEncryptedPrefs().edit().putString(prefKey, plaintext).commit();
	}

	// decryptFromPref decrypts a encrypted preference using the Jetpack Security
	// library and returns the plaintext.
	public String decryptFromPref(String prefKey) throws IOException, GeneralSecurityException {
		return getEncryptedPrefs().getString(prefKey, null);
	}

	private SharedPreferences getEncryptedPrefs() throws IOException, GeneralSecurityException {
		MasterKey key = new MasterKey.Builder(this)
			.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
			.build();

		return EncryptedSharedPreferences.create(
			this,
			"secret_shared_prefs",
			key,
			EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
			EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
		);
	}

	void setTileReady(boolean ready) {
		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
			return;
		}
		QuickToggleService.setReady(this, ready);
	}

	void setTileStatus(boolean status) {
		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
			return;
		}
		QuickToggleService.setStatus(this, status);
	}

	String getHostname() {
		String userConfiguredDeviceName = getUserConfiguredDeviceName();
		if (!isEmpty(userConfiguredDeviceName)) return userConfiguredDeviceName;

		return getModelName();
	}

	String getModelName() {
		String manu = Build.MANUFACTURER;
		String model = Build.MODEL;
		// Strip manufacturer from model.
		int idx = model.toLowerCase().indexOf(manu.toLowerCase());
		if (idx != -1) {
			model = model.substring(idx + manu.length());
			model = model.trim();
		}
		return manu + " " + model;
	}

	String getOSVersion() {
		return Build.VERSION.RELEASE;
	}

	// get user defined nickname from Settings
	// returns null if not available
	private String getUserConfiguredDeviceName() {
		String nameFromSystemBluetooth = Settings.System.getString(getContentResolver(), "bluetooth_name");
		String nameFromSecureBluetooth = Settings.Secure.getString(getContentResolver(), "bluetooth_name");
		String nameFromSystemDevice = Settings.Secure.getString(getContentResolver(), "device_name");

		if (!isEmpty(nameFromSystemBluetooth)) return nameFromSystemBluetooth;
		if (!isEmpty(nameFromSecureBluetooth)) return nameFromSecureBluetooth;
		if (!isEmpty(nameFromSystemDevice)) return nameFromSystemDevice;
		return null;
	}

	private static boolean isEmpty(String str) {
		return str == null || str.length() == 0;
	}

	// attachPeer adds a Peer fragment for tracking the Activity
	// lifecycle.
	void attachPeer(Activity act) {
		act.runOnUiThread(new Runnable() {
			@Override public void run() {
				FragmentTransaction ft = act.getFragmentManager().beginTransaction();
				ft.add(new Peer(), PEER_TAG);
				ft.commit();
				act.getFragmentManager().executePendingTransactions();
			}
		});
	}

	boolean isChromeOS() {
		return getPackageManager().hasSystemFeature("android.hardware.type.pc");
	}

	void prepareVPN(Activity act, int reqCode) {
		act.runOnUiThread(new Runnable() {
			@Override public void run() {
				Intent intent = VpnService.prepare(act);
				if (intent == null) {
					onVPNPrepared();
				} else {
					startActivityForResult(act, intent, reqCode);
				}
			}
		});
	}

	static void startActivityForResult(Activity act, Intent intent, int request) {
		Fragment f = act.getFragmentManager().findFragmentByTag(PEER_TAG);
		f.startActivityForResult(intent, request);
	}

	void showURL(Activity act, String url) {
		act.runOnUiThread(new Runnable() {
			@Override public void run() {
				CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
				int headerColor = 0xff496495;
				builder.setToolbarColor(headerColor);
				CustomTabsIntent intent = builder.build();
				intent.launchUrl(act, Uri.parse(url));
			}
		});
	}

	// getPackageSignatureFingerprint returns the first package signing certificate, if any.
	byte[] getPackageCertificate() throws Exception {
		PackageInfo info;
		info = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES);
		for (Signature signature : info.signatures) {
			return signature.toByteArray();
		}
		return null;
	}

	void requestWriteStoragePermission(Activity act) {
		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
			// We can write files without permission.
			return;
		}
		if (ContextCompat.checkSelfPermission(act, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
			return;
		}
		act.requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, IPNActivity.WRITE_STORAGE_RESULT);
	}

	String insertMedia(String name, String mimeType) throws IOException {
		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
			ContentResolver resolver = getContentResolver();
			ContentValues contentValues = new ContentValues();
			contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name);
			if (!"".equals(mimeType)) {
				contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
			}
			Uri root = MediaStore.Files.getContentUri("external");
			return resolver.insert(root, contentValues).toString();
		} else {
			File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
			dir.mkdirs();
			File f = new File(dir, name);
			return Uri.fromFile(f).toString();
		}
	}

	int openUri(String uri, String mode) throws IOException {
		ContentResolver resolver = getContentResolver();
		return resolver.openFileDescriptor(Uri.parse(uri), mode).detachFd();
	}

	void deleteUri(String uri) {
		ContentResolver resolver = getContentResolver();
		resolver.delete(Uri.parse(uri), null, null);
	}

	public void notifyFile(String uri, String msg) {
		Intent viewIntent;
		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
			viewIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));
		} else {
			// uri is a file:// which is not allowed to be shared outside the app.
			viewIntent = new Intent(DownloadManager.ACTION_VIEW_DOWNLOADS);
		}
		PendingIntent pending = PendingIntent.getActivity(this, 0, viewIntent, PendingIntent.FLAG_UPDATE_CURRENT);
		NotificationCompat.Builder builder = new NotificationCompat.Builder(this, FILE_CHANNEL_ID)
			.setSmallIcon(R.drawable.ic_notification)
			.setContentTitle("File received")
			.setContentText(msg)
			.setContentIntent(pending)
			.setAutoCancel(true)
			.setOnlyAlertOnce(true)
			.setPriority(NotificationCompat.PRIORITY_DEFAULT);

		NotificationManagerCompat nm = NotificationManagerCompat.from(this);
		nm.notify(FILE_NOTIFICATION_ID, builder.build());
	}

	private void createNotificationChannel(String id, String name, int importance) {
		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
			return;
		}
		NotificationChannel channel = new NotificationChannel(id, name, importance);
		NotificationManagerCompat nm = NotificationManagerCompat.from(this);
		nm.createNotificationChannel(channel);
	}

	static native void onVPNPrepared();
	private static native void onConnectivityChanged(boolean connected);
	static native void onShareIntent(int nfiles, int[] types, String[] mimes, String[] items, String[] names, long[] sizes);
	static native void onWriteStorageGranted();

        // Returns details of the interfaces in the system, encoded as a single string for ease
        // of JNI transfer over to the Go environment.
        //
        // Example:
        // rmnet_data0 10 2000 true false false false false | fe80::4059:dc16:7ed3:9c6e%rmnet_data0/64
        // dummy0 3 1500 true false false false false | fe80::1450:5cff:fe13:f891%dummy0/64
        // wlan0 30 1500 true true false false true | fe80::2f60:2c82:4163:8389%wlan0/64 10.1.10.131/24
        // r_rmnet_data0 21 1500 true false false false false | fe80::9318:6093:d1ad:ba7f%r_rmnet_data0/64
        // rmnet_data2 12 1500 true false false false false | fe80::3c8c:44dc:46a9:9907%rmnet_data2/64
        // r_rmnet_data1 22 1500 true false false false false | fe80::b6cd:5cb0:8ae6:fe92%r_rmnet_data1/64
        // rmnet_data1 11 1500 true false false false false | fe80::51f2:ee00:edce:d68b%rmnet_data1/64
        // lo 1 65536 true false true false false | ::1/128 127.0.0.1/8
        // v4-rmnet_data2 68 1472 true true false true true | 192.0.0.4/32
        //
        // Where the fields are:
        // name ifindex mtu isUp hasBroadcast isLoopback isPointToPoint hasMulticast | ip1/N ip2/N ip3/N;
	String getInterfacesAsString() {
            List<NetworkInterface> interfaces;
            try {
                interfaces = Collections.list(NetworkInterface.getNetworkInterfaces());
            } catch (Exception e) {
                return "";
            }

            StringBuilder sb = new StringBuilder("");
            for (NetworkInterface nif : interfaces) {
                try {
                    // Android doesn't have a supportsBroadcast() but the Go net.Interface wants
                    // one, so we say the interface has broadcast if it has multicast.
                    sb.append(String.format(java.util.Locale.ROOT, "%s %d %d %b %b %b %b %b |", nif.getName(),
                                   nif.getIndex(), nif.getMTU(), nif.isUp(), nif.supportsMulticast(),
                                   nif.isLoopback(), nif.isPointToPoint(), nif.supportsMulticast()));

                    for (InterfaceAddress ia : nif.getInterfaceAddresses()) {
                        // InterfaceAddress == hostname + "/" + IP
                        String[] parts = ia.toString().split("/", 0);
                        if (parts.length > 1) {
                            sb.append(String.format(java.util.Locale.ROOT, "%s/%d ", parts[1], ia.getNetworkPrefixLength()));
                        }
                    }
                } catch (Exception e) {
                    // TODO(dgentry) should log the exception not silently suppress it.
                    continue;
                }
                sb.append("\n");
            }

            return sb.toString();
        }

	boolean isTV() {
		UiModeManager mm = (UiModeManager)getSystemService(UI_MODE_SERVICE);
		return mm.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION;
	}
}


================================================
FILE: android/src/main/java/com/tailscale/ipn/DnsConfig.java
================================================
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package com.tailscale.ipn;

import android.content.Context;
import android.net.ConnectivityManager;
import android.net.DhcpInfo;
import android.net.LinkProperties;
import android.net.Network;
import android.net.NetworkCapabilities;
import android.net.NetworkInfo;
import android.net.wifi.WifiManager;

import java.lang.reflect.Method;

import java.net.InetAddress;

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

// Tailscale DNS Config retrieval
//
// Tailscale's DNS support can either override the local DNS servers with a set of servers
// configured in the admin panel, or supplement the local DNS servers with additional
// servers for specific domains like example.com.beta.tailscale.net. In the non-override mode,
// we need to retrieve the current set of DNS servers from the platform. These will typically
// be the DNS servers received from DHCP.
//
// Importantly, after the Tailscale VPN comes up it will set a DNS server of 100.100.100.100
// but we still want to retrieve the underlying DNS servers received from DHCP. If we roam
// from Wi-Fi to LTE, we want the DNS servers received from LTE.
//
// --------------------- Android 7 and later -----------------------------------------
//
// ## getDnsConfigFromLinkProperties
// Android provides a getAllNetworks interface in the ConnectivityManager. We walk through
// each interface to pick the most appropriate one.
// - If there is an Ethernet interface active we use that.
// - If Wi-Fi is active we use that.
// - If LTE is active we use that.
// - We never use a VPN's DNS servers. That VPN is likely us. Even if not us, Android
// only allows one VPN at a time so a different VPN's DNS servers won't be available
// once Tailscale comes up.
//
// getAllNetworks() is used as the sole mechanism for retrieving the DNS config with
// Android 7 and later.
//
// --------------------- Releases older than Android 7 -------------------------------
//
// We support Tailscale back to Android 5. Android versions 5 and 6 supply a getAllNetworks()
// implementation but it always returns an empty list.
//
// ## getDnsConfigFromLinkProperties with getActiveNetwork
// ConnectivityManager also supports a getActiveNetwork() routine, which Android 5 and 6 do
// return a value for. If Tailscale isn't up yet and we can get the Wi-Fi/LTE/etc DNS
// config using getActiveNetwork(), we use that.
//
// Once Tailscale is up, getActiveNetwork() returns tailscale0 with DNS server 100.100.100.100
// and that isn't useful. So we try two other mechanisms:
//
// ## getDnsServersFromSystemProperties
// Android versions prior to 8 let us retrieve the actual system DNS servers from properties.
// Later Android versions removed the properties and only return an empty string.
//
// We check the net.dns1 - net.dns4 DNS servers. If Tailscale is up the DNS server will be
// 100.100.100.100, which isn't useful, but if we get something different we'll use that.
//
// getDnsServersFromSystemProperties can only retrieve the IPv4 or IPv6 addresses of the
// configured DNS servers. We also want to know the DNS Search Domains configured, but
// we have no way to retrieve this using these interfaces. We return an empty list of
// search domains. Sorry.
//
// ## getDnsServersFromNetworkInfo
// ConnectivityManager supports an older API called getActiveNetworkInfo to return the
// active network interface. It doesn't handle VPNs, so the interface will always be Wi-Fi
// or Cellular even if Tailscale is up.
//
// For Wi-Fi interfaces we retrieve the DHCP response from the WifiManager. For Cellular
// interfaces we check for properties populated by most of the radio drivers.
//
// getDnsServersFromNetworkInfo does not have a way to retrieve the DNS Search Domains,
// so we return an empty list. Additionally, these interfaces are so old that they only
// support IPv4. We can't retrieve IPv6 DNS server addresses this way.

public class DnsConfig {
	private Context ctx;

	public DnsConfig(Context ctx) {
		this.ctx = ctx;
	}

	// getDnsConfigAsString returns the current DNS configuration as a multiline string:
	// line[0] DNS server addresses separated by spaces
	// line[1] search domains separated by spaces
	//
	// For example:
	// 8.8.8.8 8.8.4.4
	// example.com
	//
	// an empty string means the current DNS configuration could not be retrieved.
	String getDnsConfigAsString() {
		String s = getDnsConfigFromLinkProperties();
		if (!s.trim().isEmpty()) {
			return s;
		}
		if (android.os.Build.VERSION.SDK_INT >= 23) {
			// If ConnectivityManager.getAllNetworks() works, it is the
			// authoritative mechanism and we rely on it. The other methods
			// which follow involve more compromises.
			return "";
		}

		s = getDnsServersFromSystemProperties();
		if (!s.trim().isEmpty()) {
			return s;
		}
		return getDnsServersFromNetworkInfo();
	}

	// getDnsConfigFromLinkProperties finds the DNS servers for each Network interface
	// returned by ConnectivityManager getAllNetworks().LinkProperties, and return the
	// one that (heuristically) would be the primary DNS servers.
	//
	// on a Nexus 4  with Android  5.1 on wifi: 2602:248:7b4a:ff60::1 10.1.10.1
	// on a Nexus 7  with Android  6.0 on wifi: 2602:248:7b4a:ff60::1 10.1.10.1
	// on a Pixel 3a with Android 12.0 on wifi: 2602:248:7b4a:ff60::1 10.1.10.1\nlocaldomain
	// on a Pixel 3a with Android 12.0 on LTE:  fd00:976a::9 fd00:976a::10
	//
	// One odd behavior noted on Pixel3a with Android 12:
	// With Wi-Fi already connected, starting Tailscale returned DNS servers 2602:248:7b4a:ff60::1 10.1.10.1
	// Turning off Wi-Fi and connecting LTE returned DNS servers fd00:976a::9 fd00:976a::10.
	// Turning Wi-Fi back on return DNS servers: 10.1.10.1. The IPv6 DNS server is gone.
	// This appears to be the ConnectivityManager behavior, not something we are doing.
	//
	// This implementation can work through Android 12 (SDK 30). In SDK 31 the
	// getAllNetworks() method is deprecated and we'll need to implement a
	// android.net.ConnectivityManager.NetworkCallback instead to monitor
	// link changes and track which DNS server to use.
	String getDnsConfigFromLinkProperties() {
		ConnectivityManager cMgr = (ConnectivityManager) ctx.getSystemService(Context.CONNECTIVITY_SERVICE);
		if (cMgr == null) {
			return "";
		}

		Network[] networks = cMgr.getAllNetworks();
		if (networks == null) {
			// Android 6 and before often returns an empty list, but we
			// can try again with just the active network.
			//
			// Once Tailscale is connected, the active network will be Tailscale
			// which will have 100.100.100.100 for its DNS server. We reject
			// TYPE_VPN in getPreferabilityForNetwork, so it won't be returned.
			Network active = cMgr.getActiveNetwork();
			if (active == null) {
				return "";
			}
			networks = new Network[]{active};
		}

		// getPreferabilityForNetwork returns an index into dnsConfigs from 0-3.
		String[] dnsConfigs = new String[]{"", "", "", ""};
		for (Network network : networks) {
			int idx = getPreferabilityForNetwork(cMgr, network);
			if ((idx < 0) || (idx > 3)) {
				continue;
			}

			LinkProperties linkProp = cMgr.getLinkProperties(network);
			NetworkCapabilities nc = cMgr.getNetworkCapabilities(network);
			List<InetAddress> dnsList = linkProp.getDnsServers();
			StringBuilder sb = new StringBuilder("");
			for (InetAddress ip : dnsList) {
				sb.append(ip.getHostAddress() + " ");
			}

			String d = linkProp.getDomains();
			if (d != null) {
				sb.append("\n");
				sb.append(d);
			}

			dnsConfigs[idx] = sb.toString();
		}

		// return the lowest index DNS config which exists. If an Ethernet config
		// was found, return it. Otherwise if Wi-fi was found, return it. Etc.
		for (String s : dnsConfigs) {
			if (!s.trim().isEmpty()) {
				return s;
			}
		}

		return "";
	}

	// getDnsServersFromSystemProperties returns DNS servers found in system properties.
	// On Android versions prior to Android 8, we can directly query the DNS
	// servers the system is using. More recent Android releases return empty strings.
	//
	// Once Tailscale is connected these properties will return 100.100.100.100, which we
	// suppress.
	//
	// on a Nexus 4  with Android  5.1 on wifi: 2602:248:7b4a:ff60::1 10.1.10.1
	// on a Nexus 7  with Android  6.0 on wifi: 2602:248:7b4a:ff60::1 10.1.10.1
	// on a Pixel 3a with Android 12.0 on wifi:
	// on a Pixel 3a with Android 12.0 on  LTE:
	//
	// The list of DNS search domains does not appear to be available in system properties.
	String getDnsServersFromSystemProperties() {
		try {
			Class SystemProperties = Class.forName("android.os.SystemProperties");
			Method method = SystemProperties.getMethod("get", String.class);
			List<String> servers = new ArrayList<String>();
			for (String name : new String[]{"net.dns1", "net.dns2", "net.dns3", "net.dns4"}) {
				String value = (String) method.invoke(null, name);
				if (value != null && !value.isEmpty() &&
						!value.equals("100.100.100.100") &&
						!servers.contains(value)) {
					servers.add(value);
				}
			}
			return String.join(" ", servers);
		} catch (Exception e) {
			return "";
		}
	}


	public String intToInetString(int hostAddress) {
		return String.format(java.util.Locale.ROOT, "%d.%d.%d.%d",
			(0xff & hostAddress),
			(0xff & (hostAddress >> 8)),
			(0xff & (hostAddress >> 16)),
			(0xff & (hostAddress >> 24)));
	}

	// getDnsServersFromNetworkInfo retrieves DNS servers using ConnectivityManager
	// getActiveNetworkInfo() plus interface-specific mechanisms to retrieve the DNS servers.
	// Only IPv4 DNS servers are supported by this mechanism, neither the WifiManager nor the
	// interface-specific dns properties appear to populate IPv6 DNS server addresses.
	//
	// on a Nexus 4  with Android 5.1  on wifi: 10.1.10.1
	// on a Nexus 7  with Android 6.0  on wifi: 10.1.10.1
	// on a Pixel-3a with Android 12.0 on wifi: 10.1.10.1
	// on a Pixel-3a with Android 12.0 on  LTE:
	//
	// The list of DNS search domains is not available in this way.
	String getDnsServersFromNetworkInfo() {
		ConnectivityManager cMgr = (ConnectivityManager) ctx.getSystemService(Context.CONNECTIVITY_SERVICE);
		if (cMgr == null) {
			return "";
		}

		NetworkInfo info = cMgr.getActiveNetworkInfo();
		if (info == null) {
			return "";
		}

		Class SystemProperties;
		Method method;

		try {
			SystemProperties = Class.forName("android.os.SystemProperties");
			method = SystemProperties.getMethod("get", String.class);
		} catch (Exception e) {
			return "";
		}

		List<String> servers = new ArrayList<String>();

		switch(info.getType()) {
		case ConnectivityManager.TYPE_WIFI:
		case ConnectivityManager.TYPE_WIMAX:
			for (String name : new String[]{
				"net.wifi0.dns1", "net.wifi0.dns2", "net.wifi0.dns3", "net.wifi0.dns4",
				"net.wlan0.dns1", "net.wlan0.dns2", "net.wlan0.dns3", "net.wlan0.dns4",
				"net.eth0.dns1", "net.eth0.dns2", "net.eth0.dns3", "net.eth0.dns4",
				"dhcp.wlan0.dns1", "dhcp.wlan0.dns2", "dhcp.wlan0.dns3", "dhcp.wlan0.dns4",
				"dhcp.tiwlan0.dns1", "dhcp.tiwlan0.dns2", "dhcp.tiwlan0.dns3", "dhcp.tiwlan0.dns4"}) {
				try {
					String value = (String) method.invoke(null, name);
					if (value != null && !value.isEmpty() && !servers.contains(value)) {
						servers.add(value);
					}
				} catch (Exception e) {
					continue;
				}
			}

			WifiManager wMgr = (WifiManager) ctx.getSystemService(Context.WIFI_SERVICE);
			if (wMgr != null) {
				DhcpInfo dhcp = wMgr.getDhcpInfo();
				if (dhcp.dns1 != 0) {
					String value = intToInetString(dhcp.dns1);
					if (value != null && !value.isEmpty() && !servers.contains(value)) {
						servers.add(value);
					}
				}
				if (dhcp.dns2 != 0) {
					String value = intToInetString(dhcp.dns2);
					if (value != null && !value.isEmpty() && !servers.contains(value)) {
						servers.add(value);
					}
				}
			}
			return String.join(" ", servers);
		case ConnectivityManager.TYPE_MOBILE:
		case ConnectivityManager.TYPE_MOBILE_HIPRI:
			for (String name : new String[]{
				"net.rmnet0.dns1", "net.rmnet0.dns2", "net.rmnet0.dns3", "net.rmnet0.dns4",
				"net.rmnet1.dns1", "net.rmnet1.dns2", "net.rmnet1.dns3", "net.rmnet1.dns4",
				"net.rmnet2.dns1", "net.rmnet2.dns2", "net.rmnet2.dns3", "net.rmnet2.dns4",
				"net.rmnet3.dns1", "net.rmnet3.dns2", "net.rmnet3.dns3", "net.rmnet3.dns4",
				"net.rmnet4.dns1", "net.rmnet4.dns2", "net.rmnet4.dns3", "net.rmnet4.dns4",
				"net.rmnet5.dns1", "net.rmnet5.dns2", "net.rmnet5.dns3", "net.rmnet5.dns4",
				"net.rmnet6.dns1", "net.rmnet6.dns2", "net.rmnet6.dns3", "net.rmnet6.dns4",
				"net.rmnet7.dns1", "net.rmnet7.dns2", "net.rmnet7.dns3", "net.rmnet7.dns4",
				"net.pdp0.dns1", "net.pdp0.dns2", "net.pdp0.dns3", "net.pdp0.dns4",
				"net.pdpbr0.dns1", "net.pdpbr0.dns2", "net.pdpbr0.dns3", "net.pdpbr0.dns4"}) {
				try {
					String value = (String) method.invoke(null, name);
					if (value != null && !value.isEmpty() && !servers.contains(value)) {
						servers.add(value);
					}
				} catch (Exception e) {
					continue;
				}

			}
		}

		return "";
	}

	// getPreferabilityForNetwork is a utility routine which implements a priority for
	// different types of network transport, used in a heuristic to pick DNS servers to use.
	int getPreferabilityForNetwork(ConnectivityManager cMgr, Network network) {
		NetworkCapabilities nc = cMgr.getNetworkCapabilities(network);

		if (nc == null) {
			return -1;
		}
		if (nc.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
			// tun0 has both VPN and WIFI set, have to check VPN first and return.
			return -1;
		}

		if (nc.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
			return 0;
		} else if (nc.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
			return 1;
		} else if (nc.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
			return 2;
		} else {
			return 3;
		}
	}
}


================================================
FILE: android/src/main/java/com/tailscale/ipn/IPNActivity.java
================================================
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package com.tailscale.ipn;

import android.app.Activity;
import android.content.res.AssetFileDescriptor;
import android.content.res.Configuration;
import android.content.Intent;
import android.database.Cursor;
import android.os.Bundle;
import android.provider.OpenableColumns;
import android.net.Uri;
import android.content.pm.PackageManager;

import java.util.List;
import java.util.ArrayList;

import org.gioui.GioView;

public final class IPNActivity extends Activity {
	final static int WRITE_STORAGE_RESULT = 1000;

	private GioView view;

	@Override public void onCreate(Bundle state) {
		super.onCreate(state);
		view = new GioView(this);
		setContentView(view);
		handleIntent();
	}

	@Override public void onNewIntent(Intent i) {
		setIntent(i);
		handleIntent();
	}

	private void handleIntent() {
		Intent it = getIntent();
		String act = it.getAction();
		String[] texts;
		Uri[] uris;
		if (Intent.ACTION_SEND.equals(act)) {
			uris = new Uri[]{it.getParcelableExtra(Intent.EXTRA_STREAM)};
			texts = new String[]{it.getStringExtra(Intent.EXTRA_TEXT)};
		} else if (Intent.ACTION_SEND_MULTIPLE.equals(act)) {
			List<Uri> extraUris = it.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
			uris = extraUris.toArray(new Uri[0]);
			texts = new String[uris.length];
		} else {
			return;
		}
		String mime = it.getType();
		int nitems = uris.length;
		String[] items = new String[nitems];
		String[] mimes = new String[nitems];
		int[] types = new int[nitems];
		String[] names = new String[nitems];
		long[] sizes = new long[nitems];
		int nfiles = 0;
		for (int i = 0; i < uris.length; i++) {
			String text = texts[i];
			Uri uri = uris[i];
			if (text != null) {
				types[nfiles] = 1; // FileTypeText
				names[nfiles] = "file.txt";
				mimes[nfiles] = mime;
				items[nfiles] = text;
				// Determined by len(text) in Go to eliminate UTF-8 encoding differences.
				sizes[nfiles] = 0;
				nfiles++;
			} else if (uri != null) {
				Cursor c = getContentResolver().query(uri, null, null, null, null);
				if (c == null) {
					// Ignore files we have no permission to access.
					continue;
				}
				int nameCol = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
				int sizeCol = c.getColumnIndex(OpenableColumns.SIZE);
				c.moveToFirst();
				String name = c.getString(nameCol);
				long size = c.getLong(sizeCol);
				types[nfiles] = 2; // FileTypeURI
				mimes[nfiles] = mime;
				items[nfiles] = uri.toString();
				names[nfiles] = name;
				sizes[nfiles] = size;
				nfiles++;
			}
		}
		App.onShareIntent(nfiles, types, mimes, items, names, sizes);
	}

	@Override public void onRequestPermissionsResult(int reqCode, String[] perms, int[] grants) {
		switch (reqCode) {
		case WRITE_STORAGE_RESULT:
			if (grants.length > 0 && grants[0] == PackageManager.PERMISSION_GRANTED) {
				App.onWriteStorageGranted();
			}
		}
	}

	@Override public void onDestroy() {
		view.destroy();
		super.onDestroy();
	}

	@Override public void onStart() {
		super.onStart();
		view.start();
	}

	@Override public void onStop() {
		view.stop();
		super.onStop();
	}

	@Override public void onConfigurationChanged(Configuration c) {
		super.onConfigurationChanged(c);
		view.configurationChanged();
	}

	@Override public void onLowMemory() {
		super.onLowMemory();
		view.onLowMemory();
	}

	@Override public void onBackPressed() {
		if (!view.backPressed())
			super.onBackPressed();
	}
}


================================================
FILE: android/src/main/java/com/tailscale/ipn/IPNService.java
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package com.tailscale.ipn;

import android.os.Build;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.VpnService;
import android.system.OsConstants;

import org.gioui.GioActivity;

import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;

public class IPNService extends VpnService {
	public static final String ACTION_CONNECT = "com.tailscale.ipn.CONNECT";
	public static final String ACTION_DISCONNECT = "com.tailscale.ipn.DISCONNECT";

	@Override public int onStartCommand(Intent intent, int flags, int startId) {
		if (intent != null && ACTION_DISCONNECT.equals(intent.getAction())) {
			close();
			return START_NOT_STICKY;
		}
		connect();
		return START_STICKY;
	}

	private void close() {
		stopForeground(true);
		disconnect();
	}

	@Override public void onDestroy() {
		close();
		super.onDestroy();
	}

	@Override public void onRevoke() {
		close();
		super.onRevoke();
	}

	private PendingIntent configIntent() {
		return PendingIntent.getActivity(this, 0, new Intent(this, IPNActivity.class), PendingIntent.FLAG_UPDATE_CURRENT);
	}

	private void disallowApp(VpnService.Builder b, String name) {
		try {
			b.addDisallowedApplication(name);
		} catch (PackageManager.NameNotFoundException e) {
			return;
		}
	}

	protected VpnService.Builder newBuilder() {
		VpnService.Builder b = new VpnService.Builder()
			.setConfigureIntent(configIntent())
			.allowFamily(OsConstants.AF_INET)
			.allowFamily(OsConstants.AF_INET6);
		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
			b.setMetered(false); // Inherit the metered status from the underlying networks.
		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
			b.setUnderlyingNetworks(null); // Use all available networks.

		// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
		this.disallowApp(b, "com.google.android.apps.messaging");

		// Stadia https://github.com/tailscale/tailscale/issues/3460
		this.disallowApp(b, "com.google.stadia.android");

		// Android Auto https://github.com/tailscale/tailscale/issues/3828
		this.disallowApp(b, "com.google.android.projection.gearhead");

		return b;
	}

	public void notify(String title, String message) {
		NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.NOTIFY_CHANNEL_ID)
			.setSmallIcon(R.drawable.ic_notification)
			.setContentTitle(title)
			.setContentText(message)
			.setContentIntent(configIntent())
			.setAutoCancel(true)
			.setOnlyAlertOnce(true)
			.setPriority(NotificationCompat.PRIORITY_DEFAULT);

		NotificationManagerCompat nm = NotificationManagerCompat.from(this);
		nm.notify(App.NOTIFY_NOTIFICATION_ID, builder.build());
	}

	public void updateStatusNotification(String title, String message) {
		NotificationCompat.Builder builder = new NotificationCompat.Builder(this, App.STATUS_CHANNEL_ID)
			.setSmallIcon(R.drawable.ic_notification)
			.setContentTitle(title)
			.setContentText(message)
			.setContentIntent(configIntent())
			.setPriority(NotificationCompat.PRIORITY_LOW);

		startForeground(App.STATUS_NOTIFICATION_ID, builder.build());
	}

	private native void connect();
	private native void disconnect();
}


================================================
FILE: android/src/main/java/com/tailscale/ipn/Peer.java
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package com.tailscale.ipn;

import android.app.Activity;
import android.app.Fragment;
import android.content.Intent;

public class Peer extends Fragment {
	@Override public void onActivityResult(int requestCode, int resultCode, Intent data) {
		onActivityResult0(getActivity(), requestCode, resultCode);
	}

	private static native void onActivityResult0(Activity act, int reqCode, int resCode);
}


================================================
FILE: android/src/main/java/com/tailscale/ipn/QuickToggleService.java
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package com.tailscale.ipn;

import android.content.Context;
import android.content.ComponentName;
import android.content.Intent;
import android.service.quicksettings.Tile;
import android.service.quicksettings.TileService;

import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.AtomicBoolean;

public class QuickToggleService extends TileService {
	// lock protects the static fields below it.
	private static Object lock = new Object();
	// Active tracks whether the VPN is active.
	private static boolean active;
	// Ready tracks whether the tailscale backend is
	// ready to switch on/off.
	private static boolean ready;
	// currentTile tracks getQsTile while service is listening.
	private static Tile currentTile;

	@Override public void onStartListening() {
		synchronized (lock) {
			currentTile = getQsTile();
		}
		updateTile();
	}

	@Override public void onStopListening() {
		synchronized (lock) {
			currentTile = null;
		}
	}

	@Override public void onClick() {
		boolean r;
		synchronized (lock) {
			r = ready;
		}
		if (r) {
			onTileClick();
		} else {
			// Start main activity.
			Intent i = getPackageManager().getLaunchIntentForPackage(getPackageName());
			startActivityAndCollapse(i);
		}
	}

	private static void updateTile() {
		Tile t;
		boolean act;
		synchronized (lock) {
			t = currentTile;
			act = active && ready;
		}
		if (t == null) {
			return;
		}
		t.setState(act ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
		t.updateTile();
	}

	static void setReady(Context ctx, boolean rdy) {
		synchronized (lock) {
			ready = rdy;
		}
		updateTile();
	}

	static void setStatus(Context ctx, boolean act) {
		synchronized (lock) {
			active = act;
		}
		updateTile();
	}

	private static native void onTileClick();
}


================================================
FILE: android/src/main/res/drawable/ic_launcher_foreground.xml
================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="108dp"
    android:height="108dp"
    android:viewportWidth="100"
    android:viewportHeight="100">
  <group android:translateX="20"
      android:translateY="20">
    <path
        android:pathData="M15,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
        android:fillColor="#FFFDFA"/>
    <path
        android:pathData="M30,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
        android:fillColor="#FFFDFA"/>
    <path
        android:pathData="M15,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
        android:fillColor="#54514D"/>
    <path
        android:pathData="M45,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
        android:fillColor="#54514D"/>
    <path
        android:pathData="M30,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
        android:fillColor="#FFFDFA"/>
    <path
        android:pathData="M45,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
        android:fillColor="#FFFDFA"/>
    <path
        android:pathData="M15,15.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
        android:fillColor="#54514D"/>
    <path
        android:pathData="M30,14.9999m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
        android:fillColor="#54514D"/>
    <path
        android:pathData="M45,14.9999m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
        android:fillColor="#54514D"/>
  </group>
</vector>


================================================
FILE: android/src/main/res/drawable/ic_tile.xml
================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="24dp"
    android:height="24dp"
    android:viewportWidth="60"
    android:viewportHeight="60">
  <group>
    <path
        android:pathData="M15,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
        android:fillColor="#FFFDFA"/>
    <path
        android:pathData="M30,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
        android:fillColor="#FFFDFA"/>
    <path
        android:pathData="M15,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
        android:fillColor="#54514D"/>
    <path
        android:pathData="M45,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
        android:fillColor="#54514D"/>
    <path
        android:pathData="M30,45.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
        android:fillColor="#FFFDFA"/>
    <path
        android:pathData="M45,30.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
        android:fillColor="#FFFDFA"/>
    <path
        android:pathData="M15,15.0002m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
        android:fillColor="#54514D"/>
    <path
        android:pathData="M30,14.9999m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
        android:fillColor="#54514D"/>
    <path
        android:pathData="M45,14.9999m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0"
        android:fillColor="#54514D"/>
  </group>
</vector>


================================================
FILE: android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
    <background android:drawable="@color/ic_launcher_background"/>
    <foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

================================================
FILE: android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
    <background android:drawable="@color/ic_launcher_background"/>
    <foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

================================================
FILE: android/src/main/res/values/ic_launcher_background.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="ic_launcher_background">#1F2125</color>
</resources>

================================================
FILE: android/src/main/res/values/strings.xml
================================================
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="app_name">Tailscale</string>
    <string name="tile_name">Tailscale</string>
</resources>


================================================
FILE: android/src/play/java/com/tailscale/ipn/Google.java
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package com.tailscale.ipn;

import android.app.Activity;
import android.content.Intent;
import android.content.Context;

import com.google.android.gms.auth.api.signin.GoogleSignIn;
import com.google.android.gms.auth.api.signin.GoogleSignInAccount;
import com.google.android.gms.auth.api.signin.GoogleSignInClient;
import com.google.android.gms.auth.api.signin.GoogleSignInOptions;

// Google implements helpers for Google services.
public final class Google {
	static String getIdTokenForActivity(Activity act) {
		GoogleSignInAccount acc = GoogleSignIn.getLastSignedInAccount(act);
		return acc.getIdToken();
	}

	static void googleSignIn(Activity act, String serverOAuthID, int reqCode) {
		act.runOnUiThread(new Runnable() {
			@Override public void run() {
				GoogleSignInOptions gso = new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
					.requestIdToken(serverOAuthID)
					.requestEmail()
					.build();
				GoogleSignInClient client = GoogleSignIn.getClient(act, gso);
				Intent signInIntent = client.getSignInIntent();
				App.startActivityForResult(act, signInIntent, reqCode);
			}
		});
	}

	static void googleSignOut(Context ctx) {
		GoogleSignInOptions gso = new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
			.build();
		GoogleSignInClient client = GoogleSignIn.getClient(ctx, gso);
		client.signOut();
	}
}


================================================
FILE: android/src/test/java/com/tailscale/ipn/DnsConfigTest.java
================================================
import org.junit.Before;
import org.junit.Test;

import static org.junit.Assert.assertEquals;
import com.tailscale.ipn.DnsConfig;

public class DnsConfigTest {
	DnsConfig dns;

	@Before
	public void setup() {
		dns = new DnsConfig(null);
	}

	@Test
	public void dnsConfig_intToInetStringTest() {
		assertEquals(dns.intToInetString(0x0101a8c0), "192.168.1.1");
		assertEquals(dns.intToInetString(0x04030201), "1.2.3.4");
		assertEquals(dns.intToInetString(0), "0.0.0.0");
	}
}


================================================
FILE: cmd/tailscale/backend.go
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
	"errors"
	"fmt"
	"log"
	"net/http"
	"path/filepath"
	"reflect"
	"strings"
	"time"

	"github.com/tailscale/tailscale-android/jni"
	"golang.org/x/sys/unix"
	"golang.zx2c4.com/wireguard/tun"
	"inet.af/netaddr"
	"tailscale.com/ipn"
	"tailscale.com/ipn/ipnlocal"
	"tailscale.com/logpolicy"
	"tailscale.com/logtail"
	"tailscale.com/logtail/filch"
	"tailscale.com/net/dns"
	"tailscale.com/net/tsdial"
	"tailscale.com/smallzstd"
	"tailscale.com/types/logger"
	"tailscale.com/util/dnsname"
	"tailscale.com/wgengine"
	"tailscale.com/wgengine/netstack"
	"tailscale.com/wgengine/router"
)

type backend struct {
	engine     wgengine.Engine
	backend    *ipnlocal.LocalBackend
	devices    *multiTUN
	settings   settingsFunc
	lastCfg    *router.Config
	lastDNSCfg *dns.OSConfig

	logIDPublic string

	// avoidEmptyDNS controls whether to use fallback nameservers
	// when no nameservers are provided by Tailscale.
	avoidEmptyDNS bool

	jvm    *jni.JVM
	appCtx jni.Object
}

type settingsFunc func(*router.Config, *dns.OSConfig) error

const defaultMTU = 1280 // minimalMTU from wgengine/userspace.go

const (
	logPrefKey               = "privatelogid"
	loginMethodPrefKey       = "loginmethod"
	customLoginServerPrefKey = "customloginserver"
)

const (
	loginMethodGoogle = "google"
	loginMethodWeb    = "web"
)

// googleDnsServers are used on ChromeOS, where an empty VpnBuilder DNS setting results
// in erasing the platform DNS servers. The developer docs say this is not supposed to happen,
// but nonetheless it does.
var googleDnsServers = []netaddr.IP{netaddr.MustParseIP("8.8.8.8"), netaddr.MustParseIP("8.8.4.4"),
	netaddr.MustParseIP("2001:4860:4860::8888"), netaddr.MustParseIP("2001:4860:4860::8844"),
}

// errVPNNotPrepared is used when VPNService.Builder.establish returns
// null, either because the VPNService is not yet prepared or because
// VPN status was revoked.
var errVPNNotPrepared = errors.New("VPN service not prepared or was revoked")

func newBackend(dataDir string, jvm *jni.JVM, appCtx jni.Object, store *stateStore,
	settings settingsFunc) (*backend, error) {

	logf := logger.RusagePrefixLog(log.Printf)
	b := &backend{
		jvm:      jvm,
		devices:  newTUNDevices(),
		settings: settings,
		appCtx:   appCtx,
	}
	var logID logtail.PrivateID
	logID.UnmarshalText([]byte("dead0000dead0000dead0000dead0000dead0000dead0000dead0000dead0000"))
	storedLogID, err := store.read(logPrefKey)
	// In all failure cases we ignore any errors and continue with the dead value above.
	if err != nil || storedLogID == nil {
		// Read failed or there was no previous log id.
		newLogID, err := logtail.NewPrivateID()
		if err == nil {
			logID = newLogID
			enc, err := newLogID.MarshalText()
			if err == nil {
				store.write(logPrefKey, enc)
			}
		}
	} else {
		logID.UnmarshalText([]byte(storedLogID))
	}
	b.SetupLogs(dataDir, logID)
	dialer := new(tsdial.Dialer)
	cb := &router.CallbackRouter{
		SetBoth:           b.setCfg,
		SplitDNS:          false,
		GetBaseConfigFunc: b.getDNSBaseConfig,
	}
	engine, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
		Tun:    b.devices,
		Router: cb,
		DNS:    cb,
		Dialer: dialer,
	})
	if err != nil {
		return nil, fmt.Errorf("runBackend: NewUserspaceEngine: %v", err)
	}
	b.logIDPublic = logID.Public().String()
	tunDev, magicConn, dnsMgr, ok := engine.(wgengine.InternalsGetter).GetInternals()
	if !ok {
		return nil, fmt.Errorf("%T is not a wgengine.InternalsGetter", engine)
	}
	ns, err := netstack.Create(logf, tunDev, engine, magicConn, dialer, dnsMgr)
	if err != nil {
		return nil, fmt.Errorf("netstack.Create: %w", err)
	}
	ns.ProcessLocalIPs = false // let Android kernel handle it; VpnBuilder sets this up
	ns.ProcessSubnets = true   // for Android-being-an-exit-node support
	lb, err := ipnlocal.NewLocalBackend(logf, b.logIDPublic, store, dialer, engine, 0)
	if err != nil {
		engine.Close()
		return nil, fmt.Errorf("runBackend: NewLocalBackend: %v", err)
	}
	ns.SetLocalBackend(lb)
	if err := ns.Start(); err != nil {
		return nil, fmt.Errorf("startNetstack: %w", err)
	}
	b.engine = engine
	b.backend = lb
	return b, nil
}

func (b *backend) Start(notify func(n ipn.Notify)) error {
	b.backend.SetNotifyCallback(notify)
	return b.backend.Start(ipn.Options{
		StateKey: "ipn-android",
	})
}

func (b *backend) LinkChange() {
	if b.engine != nil {
		b.engine.LinkChange(false)
	}
}

func (b *backend) setCfg(rcfg *router.Config, dcfg *dns.OSConfig) error {
	return b.settings(rcfg, dcfg)
}

func (b *backend) updateTUN(service jni.Object, rcfg *router.Config, dcfg *dns.OSConfig) error {
	if reflect.DeepEqual(rcfg, b.lastCfg) && reflect.DeepEqual(dcfg, b.lastDNSCfg) {
		return nil
	}

	// Close previous tunnel(s).
	// This is necessary for ChromeOS, native Android devices
	// seem to handle seamless handover between tunnels correctly.
	//
	// TODO(eliasnaur): If seamless handover becomes a desirable feature, skip
	// the closing on ChromeOS.
	b.CloseTUNs()

	if len(rcfg.LocalAddrs) == 0 {
		return nil
	}
	err := jni.Do(b.jvm, func(env *jni.Env) error {
		cls := jni.GetObjectClass(env, service)
		// Construct a VPNService.Builder. IPNService.newBuilder calls
		// setConfigureIntent, and allowFamily for both IPv4 and IPv6.
		m := jni.GetMethodID(env, cls, "newBuilder", "()Landroid/net/VpnService$Builder;")
		builder, err := jni.CallObjectMethod(env, service, m)
		if err != nil {
			return fmt.Errorf("IPNService.newBuilder: %v", err)
		}
		bcls := jni.GetObjectClass(env, builder)

		// builder.setMtu.
		setMtu := jni.GetMethodID(env, bcls, "setMtu", "(I)Landroid/net/VpnService$Builder;")
		const mtu = defaultMTU
		if _, err := jni.CallObjectMethod(env, builder, setMtu, jni.Value(mtu)); err != nil {
			return fmt.Errorf("VpnService.Builder.setMtu: %v", err)
		}

		// builder.addDnsServer
		addDnsServer := jni.GetMethodID(env, bcls, "addDnsServer", "(Ljava/lang/String;)Landroid/net/VpnService$Builder;")
		// builder.addSearchDomain.
		addSearchDomain := jni.GetMethodID(env, bcls, "addSearchDomain", "(Ljava/lang/String;)Landroid/net/VpnService$Builder;")
		if dcfg != nil {
			nameservers := dcfg.Nameservers
			if b.avoidEmptyDNS && len(nameservers) == 0 {
				nameservers = googleDnsServers
			}
			for _, dns := range nameservers {
				_, err = jni.CallObjectMethod(env,
					builder,
					addDnsServer,
					jni.Value(jni.JavaString(env, dns.String())),
				)
				if err != nil {
					return fmt.Errorf("VpnService.Builder.addDnsServer(%v): %v", dns, err)
				}
			}

			for _, dom := range dcfg.SearchDomains {
				_, err = jni.CallObjectMethod(env,
					builder,
					addSearchDomain,
					jni.Value(jni.JavaString(env, dom.WithoutTrailingDot())),
				)
				if err != nil {
					return fmt.Errorf("VpnService.Builder.addSearchDomain(%v): %v", dom, err)
				}
			}
		}

		// builder.addRoute.
		addRoute := jni.GetMethodID(env, bcls, "addRoute", "(Ljava/lang/String;I)Landroid/net/VpnService$Builder;")
		for _, route := range rcfg.Routes {
			// Normalize route address; Builder.addRoute does not accept non-zero masked bits.
			route = route.Masked()
			_, err = jni.CallObjectMethod(env,
				builder,
				addRoute,
				jni.Value(jni.JavaString(env, route.IP().String())),
				jni.Value(route.Bits()),
			)
			if err != nil {
				return fmt.Errorf("VpnService.Builder.addRoute(%v): %v", route, err)
			}
		}

		// builder.addAddress.
		addAddress := jni.GetMethodID(env, bcls, "addAddress", "(Ljava/lang/String;I)Landroid/net/VpnService$Builder;")
		for _, addr := range rcfg.LocalAddrs {
			_, err = jni.CallObjectMethod(env,
				builder,
				addAddress,
				jni.Value(jni.JavaString(env, addr.IP().String())),
				jni.Value(addr.Bits()),
			)
			if err != nil {
				return fmt.Errorf("VpnService.Builder.addAddress(%v): %v", addr, err)
			}
		}

		// builder.establish.
		establish := jni.GetMethodID(env, bcls, "establish", "()Landroid/os/ParcelFileDescriptor;")
		parcelFD, err := jni.CallObjectMethod(env, builder, establish)
		if err != nil {
			return fmt.Errorf("VpnService.Builder.establish: %v", err)
		}
		if parcelFD == 0 {
			return errVPNNotPrepared
		}

		// detachFd.
		parcelCls := jni.GetObjectClass(env, parcelFD)
		detachFd := jni.GetMethodID(env, parcelCls, "detachFd", "()I")
		tunFD, err := jni.CallIntMethod(env, parcelFD, detachFd)
		if err != nil {
			return fmt.Errorf("detachFd: %v", err)
		}

		// Create TUN device.
		tunDev, _, err := tun.CreateUnmonitoredTUNFromFD(int(tunFD))
		if err != nil {
			unix.Close(int(tunFD))
			return err
		}

		b.devices.add(tunDev)

		return nil
	})
	if err != nil {
		b.lastCfg = nil
		b.CloseTUNs()
		return err
	}
	b.lastCfg = rcfg
	b.lastDNSCfg = dcfg
	return nil
}

// CloseVPN closes any active TUN devices.
func (b *backend) CloseTUNs() {
	b.lastCfg = nil
	b.devices.Shutdown()
}

// SetupLogs sets up remote logging.
func (b *backend) SetupLogs(logDir string, logID logtail.PrivateID) {
	logcfg := logtail.Config{
		Collection: "tailnode.log.tailscale.io",
		PrivateID:  logID,
		Stderr:     log.Writer(),
		NewZstdEncoder: func() logtail.Encoder {
			w, err := smallzstd.NewEncoder(nil)
			if err != nil {
				panic(err)
			}
			return w
		},
		HTTPC: &http.Client{Transport: logpolicy.NewLogtailTransport(logtail.DefaultHost)},
	}
	drainCh := make(chan struct{})
	logcfg.DrainLogs = drainCh
	go func() {
		// Upload logs infrequently. Interval chosen arbitrarily.
		// The objective is to reduce phone power use.
		t := time.NewTicker(2 * time.Minute)
		for range t.C {
			select {
			case drainCh <- struct{}{}:
			default:
			}
		}
	}()

	filchOpts := filch.Options{
		ReplaceStderr: true,
	}

	var filchErr error
	if logDir != "" {
		logPath := filepath.Join(logDir, "ipn.log.")
		logcfg.Buffer, filchErr = filch.New(logPath, filchOpts)
	}

	logf := logger.RusagePrefixLog(log.Printf)
	tlog := logtail.NewLogger(logcfg, logf)

	log.SetFlags(0)
	log.SetOutput(tlog)

	log.Printf("goSetupLogs: success")

	if logDir == "" {
		log.Printf("SetupLogs: no logDir, storing logs in memory")
	}
	if filchErr != nil {
		log.Printf("SetupLogs: filch setup failed: %v", filchErr)
	}
}

// We log the result of each of the DNS configuration discovery mechanisms, as we're
// expecting a long tail of obscure Android devices with interesting behavior.
func (b *backend) logDNSConfigMechanisms() {
	err := jni.Do(b.jvm, func(env *jni.Env) error {
		cls := jni.GetObjectClass(env, b.appCtx)
		m := jni.GetMethodID(env, cls, "getDnsConfigObj", "()Lcom/tailscale/ipn/DnsConfig;")
		dns, err := jni.CallObjectMethod(env, b.appCtx, m)
		if err != nil {
			return fmt.Errorf("getDnsConfigObj JNI: %v", err)
		}
		dnsCls := jni.GetObjectClass(env, dns)

		for _, impl := range []string{"getDnsConfigFromLinkProperties",
			"getDnsServersFromSystemProperties",
			"getDnsServersFromNetworkInfo"} {

			m = jni.GetMethodID(env, dnsCls, impl, "()Ljava/lang/String;")
			n, err := jni.CallObjectMethod(env, dns, m)
			baseConfig := jni.GoString(env, jni.String(n))
			if err != nil {
				log.Printf("%s JNI: %v", impl, err)
			} else {
				oneLine := strings.Replace(baseConfig, "\n", ";", -1)
				log.Printf("%s: %s", impl, oneLine)
			}
		}
		return nil
	})
	if err != nil {
		log.Printf("logDNSConfigMechanisms: %v", err)
	}
}

func (b *backend) getPlatformDNSConfig() string {
	var baseConfig string
	err := jni.Do(b.jvm, func(env *jni.Env) error {
		cls := jni.GetObjectClass(env, b.appCtx)
		m := jni.GetMethodID(env, cls, "getDnsConfigObj", "()Lcom/tailscale/ipn/DnsConfig;")
		dns, err := jni.CallObjectMethod(env, b.appCtx, m)
		if err != nil {
			return fmt.Errorf("getDnsConfigObj: %v", err)
		}
		dnsCls := jni.GetObjectClass(env, dns)
		m = jni.GetMethodID(env, dnsCls, "getDnsConfigAsString", "()Ljava/lang/String;")
		n, err := jni.CallObjectMethod(env, dns, m)
		baseConfig = jni.GoString(env, jni.String(n))
		return err
	})
	if err != nil {
		log.Printf("getPlatformDNSConfig JNI: %v", err)
		return ""
	}
	return baseConfig
}

func (b *backend) getDNSBaseConfig() (dns.OSConfig, error) {
	b.logDNSConfigMechanisms()
	baseConfig := b.getPlatformDNSConfig()
	lines := strings.Split(baseConfig, "\n")
	if len(lines) == 0 {
		return dns.OSConfig{}, nil
	}

	config := dns.OSConfig{}
	addrs := strings.Trim(lines[0], " \n")
	for _, addr := range strings.Split(addrs, " ") {
		ip, err := netaddr.ParseIP(addr)
		if err == nil {
			config.Nameservers = append(config.Nameservers, ip)
		}
	}

	if len(lines) > 1 {
		for _, s := range strings.Split(strings.Trim(lines[1], " \n"), " ") {
			domain, err := dnsname.ToFQDN(s)
			if err != nil {
				log.Printf("getDNSBaseConfig: unable to parse %q: %v", s, err)
				continue
			}
			config.SearchDomains = append(config.SearchDomains, domain)
		}
	}

	return config, nil
}


================================================
FILE: cmd/tailscale/callbacks.go
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

// JNI implementations of Java native callback methods.

import (
	"unsafe"

	"github.com/tailscale/tailscale-android/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)

	// onConnect receives global IPNService references when
	// a VPN connection is requested.
	onConnect = make(chan jni.Object)
	// onDisconnect receives global IPNService references when
	// disconnecting.
	onDisconnect = make(chan jni.Object)
	// onConnectivityChange is notified every time the network
	// conditions change.
	onConnectivityChange = make(chan bool, 1)

	// onGoogleToken receives google ID tokens.
	onGoogleToken = make(chan string)

	// onFileShare receives file sharing intents.
	onFileShare = make(chan []File, 1)

	// onWriteStorageGranted is notified when we are granted WRITE_STORAGE_PERMISSION.
	onWriteStorageGranted = 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_App_onWriteStorageGranted
func Java_com_tailscale_ipn_App_onWriteStorageGranted(env *C.JNIEnv, class C.jclass) {
	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:
	}
}

//export Java_com_tailscale_ipn_IPNService_connect
func Java_com_tailscale_ipn_IPNService_connect(env *C.JNIEnv, this C.jobject) {
	jenv := (*jni.Env)(unsafe.Pointer(env))
	onConnect <- jni.NewGlobalRef(jenv, jni.Object(this))
}

//export Java_com_tailscale_ipn_IPNService_disconnect
func Java_com_tailscale_ipn_IPNService_disconnect(env *C.JNIEnv, this C.jobject) {
	jenv := (*jni.Env)(unsafe.Pointer(env))
	onDisconnect <- jni.NewGlobalRef(jenv, jni.Object(this))
}

//export Java_com_tailscale_ipn_App_onConnectivityChanged
func Java_com_tailscale_ipn_App_onConnectivityChanged(env *C.JNIEnv, cls C.jclass, connected C.jboolean) {
	select {
	case <-onConnectivityChange:
	default:
	}
	onConnectivityChange <- connected == C.JNI_TRUE
}

//export Java_com_tailscale_ipn_QuickToggleService_onTileClick
func Java_com_tailscale_ipn_QuickToggleService_onTileClick(env *C.JNIEnv, cls C.jclass) {
	requestBackend(ToggleEvent{})
}

//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 := (*jni.Env)(unsafe.Pointer(env))
		m := jni.GetStaticMethodID(jenv, googleClass,
			"getIdTokenForActivity", "(Landroid/app/Activity;)Ljava/lang/String;")
		idToken, err := jni.CallStaticObjectMethod(jenv, googleClass, m, jni.Value(act))
		if err != nil {
			fatalErr(err)
			break
		}
		tok := jni.GoString(jenv, jni.String(idToken))
		onGoogleToken <- tok
	case requestPrepareVPN:
		if resCode == resultOK {
			notifyVPNPrepared()
		} else {
			notifyVPNClosed()
			notifyVPNRevoked()
		}
	}
}

//export Java_com_tailscale_ipn_App_onShareIntent
func Java_com_tailscale_ipn_App_onShareIntent(env *C.JNIEnv, cls C.jclass, nfiles C.jint, jtypes C.jintArray, jmimes C.jobjectArray, jitems C.jobjectArray, jnames C.jobjectArray, jsizes C.jlongArray) {
	const (
		typeNone   = 0
		typeInline = 1
		typeURI    = 2
	)
	jenv := (*jni.Env)(unsafe.Pointer(env))
	types := jni.GetIntArrayElements(jenv, jni.IntArray(jtypes))
	mimes := jni.GetStringArrayElements(jenv, jni.ObjectArray(jmimes))
	items := jni.GetStringArrayElements(jenv, jni.ObjectArray(jitems))
	names := jni.GetStringArrayElements(jenv, jni.ObjectArray(jnames))
	sizes := jni.GetLongArrayElements(jenv, jni.LongArray(jsizes))
	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
}


================================================
FILE: cmd/tailscale/main.go
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
	"context"
	"crypto/rand"
	"crypto/sha1"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"log"
	"mime"
	"net"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"sync/atomic"
	"time"
	"unsafe"

	"gioui.org/app"
	"gioui.org/io/system"
	"gioui.org/layout"
	"gioui.org/op"
	"inet.af/netaddr"

	"github.com/tailscale/tailscale-android/jni"
	"tailscale.com/client/tailscale/apitype"
	"tailscale.com/hostinfo"
	"tailscale.com/ipn"
	"tailscale.com/ipn/ipnlocal"
	"tailscale.com/net/dns"
	"tailscale.com/net/interfaces"
	"tailscale.com/net/netns"
	"tailscale.com/paths"
	"tailscale.com/tailcfg"
	"tailscale.com/types/netmap"
	"tailscale.com/wgengine/router"
)

type App struct {
	jvm *jni.JVM
	// appCtx is a global reference to the com.tailscale.ipn.App instance.
	appCtx jni.Object

	store             *stateStore
	logIDPublicAtomic atomic.Value // of string

	// netStates receives the most recent network state.
	netStates chan BackendState
	// prefs receives new preferences from the backend.
	prefs chan *ipn.Prefs
	// browseURLs receives URLs when the backend wants to browse.
	browseURLs chan string
	// targetsLoaded receives lists of file targets.
	targetsLoaded chan FileTargets
	// invalidates receives whenever the window should be refreshed.
	invalidates chan struct{}
}

var (
	// googleClass is a global reference to the com.tailscale.ipn.Google class.
	googleClass jni.Class
)

type FileTargets struct {
	Targets []*apitype.FileTarget
	Err     error
}

type File struct {
	Type     FileType
	Name     string
	Size     int64
	MIMEType string
	// URI of the file, valid if Type is FileTypeURI.
	URI string
	// Text is the content of the file, if Type is FileTypeText.
	Text string
}

// FileSendInfo describes the state of an ongoing file send operation.
type FileSendInfo struct {
	State FileSendState
	// Progress tracks the progress of the transfer from 0.0 to 1.0. Valid
	// only when State is FileSendStarted.
	Progress float64
}

type clientState struct {
	browseURL string
	backend   BackendState
	// query is the search query, in lowercase.
	query string

	Peers []UIPeer
}

type FileType uint8

// FileType constants are known to IPNActivity.java.
const (
	FileTypeText FileType = 1
	FileTypeURI  FileType = 2
)

type ExitStatus uint8

const (
	// No exit node selected.
	ExitNone ExitStatus = iota
	// Exit node selected and exists, but is offline or missing.
	ExitOffline
	// Exit node selected and online.
	ExitOnline
)

type FileSendState uint8

const (
	FileSendNotStarted FileSendState = iota
	FileSendConnecting
	FileSendTransferring
	FileSendComplete
	FileSendFailed
)

type Peer struct {
	Label  string
	Online bool
	ID     tailcfg.StableNodeID
}

type BackendState struct {
	Prefs        *ipn.Prefs
	State        ipn.State
	NetworkMap   *netmap.NetworkMap
	LostInternet bool
	// Exits are the peers that can act as exit node.
	Exits []Peer
	// ExitState describes the state of our exit node.
	ExitStatus ExitStatus
	// Exit is our current exit node, if any.
	Exit Peer
}

// UIEvent is an event flowing from the UI to the backend.
type UIEvent interface{}

type RouteAllEvent struct {
	ID tailcfg.StableNodeID
}

type ConnectEvent struct {
	Enable bool
}

type CopyEvent struct {
	Text string
}

type SearchEvent struct {
	Query string
}

type OAuth2Event struct {
	Token *tailcfg.Oauth2Token
}

type FileSendEvent struct {
	Target  *apitype.FileTarget
	Context context.Context
	Updates func(FileSendInfo)
}

type SetLoginServerEvent struct {
	URL string
}

// UIEvent types.
type (
	ToggleEvent       struct{}
	ReauthEvent       struct{}
	BugEvent          struct{}
	WebAuthEvent      struct{}
	GoogleAuthEvent   struct{}
	LogoutEvent       struct{}
	BeExitNodeEvent   bool
	ExitAllowLANEvent bool
)

// serverOAuthID is the OAuth ID of the tailscale-android server, used
// by GoogleSignInOptions.Builder.requestIdToken.
const serverOAuthID = "744055068597-hv4opg0h7vskq1hv37nq3u26t8c15qk0.apps.googleusercontent.com"

// releaseCertFingerprint is the SHA-1 fingerprint of the Google Play Store signing key.
// It is used to check whether the app is signed for release.
const releaseCertFingerprint = "86:9D:11:8B:63:1E:F8:35:C6:D9:C2:66:53:BC:28:22:2F:B8:C1:AE"

// backendEvents receives events from the UI (Activity, Tile etc.) to the backend.
var backendEvents = make(chan UIEvent)

func main() {
	a := &App{
		jvm:           (*jni.JVM)(unsafe.Pointer(app.JavaVM())),
		appCtx:        jni.Object(app.AppContext()),
		netStates:     make(chan BackendState, 1),
		browseURLs:    make(chan string, 1),
		prefs:         make(chan *ipn.Prefs, 1),
		targetsLoaded: make(chan FileTargets, 1),
		invalidates:   make(chan struct{}, 1),
	}
	err := jni.Do(a.jvm, func(env *jni.Env) error {
		loader := jni.ClassLoaderFor(env, a.appCtx)
		cl, err := jni.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 = jni.Class(jni.NewGlobalRef(env, jni.Object(cl)))
		return nil
	})
	if err != nil {
		fatalErr(err)
	}
	a.store = newStateStore(a.jvm, a.appCtx)
	interfaces.RegisterInterfaceGetter(a.getInterfaces)
	go func() {
		if err := a.runBackend(); err != nil {
			fatalErr(err)
		}
	}()
	go func() {
		if err := a.runUI(); err != nil {
			fatalErr(err)
		}
	}()
	app.Main()
}

func (a *App) runBackend() error {
	appDir, err := app.DataDir()
	if err != nil {
		fatalErr(err)
	}
	paths.AppSharedDir.Store(appDir)
	hostinfo.SetOSVersion(a.osVersion())
	if !googleSignInEnabled() {
		hostinfo.SetPackage("nogoogle")
	}
	deviceModel := a.modelName()
	if a.isChromeOS() {
		deviceModel = "ChromeOS: " + deviceModel
	}
	hostinfo.SetDeviceModel(deviceModel)

	type configPair struct {
		rcfg *router.Config
		dcfg *dns.OSConfig
	}
	configs := make(chan configPair)
	configErrs := make(chan error)
	b, err := newBackend(appDir, a.jvm, a.appCtx, a.store, func(rcfg *router.Config, dcfg *dns.OSConfig) error {
		if rcfg == nil {
			return nil
		}
		configs <- configPair{rcfg, dcfg}
		return <-configErrs
	})
	if err != nil {
		return err
	}
	a.logIDPublicAtomic.Store(b.logIDPublic)
	defer b.CloseTUNs()

	// Contrary to the documentation for VpnService.Builder.addDnsServer,
	// ChromeOS doesn't fall back to the underlying network nameservers if
	// we don't provide any.
	b.avoidEmptyDNS = a.isChromeOS()

	var timer *time.Timer
	var alarmChan <-chan time.Time
	alarm := func(t *time.Timer) {
		if timer != nil {
			timer.Stop()
		}
		timer = t
		if timer != nil {
			alarmChan = timer.C
		}
	}
	notifications := make(chan ipn.Notify, 1)
	startErr := make(chan error)
	// Start from a goroutine to avoid deadlock when Start
	// calls the callback.
	go func() {
		startErr <- b.Start(func(n ipn.Notify) {
			notifications <- n
		})
	}()
	var (
		cfg       configPair
		state     BackendState
		service   jni.Object // of IPNService
		signingIn bool
	)
	var (
		waitingFilesDone = make(chan struct{})
		waitingFiles     bool
		processingFiles  bool
	)
	processFiles := func() {
		if !waitingFiles || processingFiles {
			return
		}
		processingFiles = true
		waitingFiles = false
		go func() {
			if err := a.processWaitingFiles(b.backend); err != nil {
				log.Printf("processWaitingFiles: %v", err)
			}
			waitingFilesDone <- struct{}{}
		}()
	}
	for {
		select {
		case err := <-startErr:
			if err != nil {
				return err
			}
		case <-waitingFilesDone:
			processingFiles = false
			processFiles()
		case s := <-configs:
			cfg = s
			if b == nil || service == 0 || cfg.rcfg == nil {
				configErrs <- nil
				break
			}
			configErrs <- b.updateTUN(service, cfg.rcfg, cfg.dcfg)
		case n := <-notifications:
			exitWasOnline := state.ExitStatus == ExitOnline
			if p := n.Prefs; p != nil {
				first := state.Prefs == nil
				state.Prefs = p.Clone()
				state.updateExitNodes()
				if first {
					state.Prefs.Hostname = a.hostname()
					go b.backend.SetPrefs(state.Prefs)
				}
				a.setPrefs(state.Prefs)
			}
			if s := n.State; s != nil {
				oldState := state.State
				state.State = *s
				if service != 0 {
					a.updateNotification(service, state.State)
				}
				if service != 0 {
					if cfg.rcfg != nil && state.State >= ipn.Starting {
						if err := b.updateTUN(service, cfg.rcfg, cfg.dcfg); err != nil {
							log.Printf("VPN update failed: %v", err)
							notifyVPNClosed()
						}
					} else {
						b.CloseTUNs()
					}
				}
				// Stop VPN if we logged out.
				if oldState > ipn.Stopped && state.State <= ipn.Stopped {
					if err := a.callVoidMethod(a.appCtx, "stopVPN", "()V"); err != nil {
						fatalErr(err)
					}
				}
				a.notify(state)
			}
			if u := n.BrowseToURL; u != nil {
				signingIn = false
				a.setURL(*u)
			}
			if m := n.NetMap; m != nil {
				state.NetworkMap = m
				state.updateExitNodes()
				a.notify(state)
				if service != 0 {
					alarm(a.notifyExpiry(service, m.Expiry))
				}
			}
			// Notify if a previously online exit is not longer online (or missing).
			if service != 0 && exitWasOnline && state.ExitStatus == ExitOffline {
				a.pushNotify(service, "Connection Lost", "Your exit node is offline. Disable your exit node or contact your network admin for help.")
			}
			targets, err := b.backend.FileTargets()
			if err != nil {
				// Construct a user-visible error message.
				if b.backend.State() != ipn.Running {
					err = fmt.Errorf("Not connected to tailscale")
				} else {
					err = fmt.Errorf("Failed to load device list")
				}
			}
			a.targetsLoaded <- FileTargets{targets, err}
			waitingFiles = n.FilesWaiting != nil
			processFiles()
		case <-onWriteStorageGranted:
			processFiles()
		case <-alarmChan:
			if m := state.NetworkMap; m != nil && service != 0 {
				alarm(a.notifyExpiry(service, m.Expiry))
			}
		case e := <-backendEvents:
			switch e := e.(type) {
			case OAuth2Event:
				go b.backend.Login(e.Token)
			case ToggleEvent:
				state.Prefs.WantRunning = !state.Prefs.WantRunning
				go b.backend.SetPrefs(state.Prefs)
			case BeExitNodeEvent:
				state.Prefs.SetAdvertiseExitNode(bool(e))
				go b.backend.SetPrefs(state.Prefs)
			case ExitAllowLANEvent:
				state.Prefs.ExitNodeAllowLANAccess = bool(e)
				go b.backend.SetPrefs(state.Prefs)
			case WebAuthEvent:
				if !signingIn {
					go b.backend.StartLoginInteractive()
					signingIn = true
				}
			case SetLoginServerEvent:
				state.Prefs.ControlURL = e.URL
				b.backend.SetPrefs(state.Prefs)
				// A hack to get around ipnlocal's inability to update
				// ControlURL after Start()... Can we re-init instead?
				os.Exit(0)
			case LogoutEvent:
				go b.backend.Logout()
			case ConnectEvent:
				state.Prefs.WantRunning = e.Enable
				go b.backend.SetPrefs(state.Prefs)
			case RouteAllEvent:
				state.Prefs.ExitNodeID = e.ID
				go b.backend.SetPrefs(state.Prefs)
				state.updateExitNodes()
				a.notify(state)
			}
		case s := <-onConnect:
			jni.Do(a.jvm, func(env *jni.Env) error {
				if jni.IsSameObject(env, s, service) {
					// We already have a reference.
					jni.DeleteGlobalRef(env, s)
					return nil
				}
				if service != 0 {
					jni.DeleteGlobalRef(env, service)
				}
				netns.SetAndroidProtectFunc(func(fd int) error {
					return jni.Do(a.jvm, func(env *jni.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 := jni.GetObjectClass(env, s)
						m := jni.GetMethodID(env, cls, "protect", "(I)Z")
						ok, err := jni.CallBooleanMethod(env, s, m, jni.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.
					})
				})
				service = s
				return nil
			})
			a.updateNotification(service, state.State)
			if m := state.NetworkMap; m != nil {
				alarm(a.notifyExpiry(service, m.Expiry))
			}
			if cfg.rcfg != nil && state.State >= ipn.Starting {
				if err := b.updateTUN(service, cfg.rcfg, cfg.dcfg); err != nil {
					log.Printf("VPN update failed: %v", err)
					notifyVPNClosed()
				}
			}
		case connected := <-onConnectivityChange:
			if state.LostInternet != !connected {
				log.Printf("LostInternet state change: %v -> %v", state.LostInternet, !connected)
			}
			state.LostInternet = !connected
			if b != nil {
				go b.LinkChange()
			}
			a.notify(state)
		case s := <-onDisconnect:
			b.CloseTUNs()
			jni.Do(a.jvm, func(env *jni.Env) error {
				defer jni.DeleteGlobalRef(env, s)
				if jni.IsSameObject(env, service, s) {
					netns.SetAndroidProtectFunc(nil)
					jni.DeleteGlobalRef(env, service)
					service = 0
				}
				return nil
			})
			if state.State >= ipn.Starting {
				notifyVPNClosed()
			}
		}
	}
}

func (a *App) processWaitingFiles(b *ipnlocal.LocalBackend) error {
	files, err := b.WaitingFiles()
	if err != nil {
		return err
	}
	var aerr error
	for _, f := range files {
		if err := a.downloadFile(b, f); err != nil && aerr == nil {
			aerr = err
		}
	}
	return aerr
}

func (a *App) downloadFile(b *ipnlocal.LocalBackend, f apitype.WaitingFile) (cerr error) {
	in, _, err := b.OpenFile(f.Name)
	if err != nil {
		return err
	}
	defer in.Close()
	ext := filepath.Ext(f.Name)
	mimeType := mime.TypeByExtension(ext)
	var mediaURI string
	err = jni.Do(a.jvm, func(env *jni.Env) error {
		cls := jni.GetObjectClass(env, a.appCtx)
		insertMedia := jni.GetMethodID(env, cls, "insertMedia", "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;")
		jname := jni.JavaString(env, f.Name)
		jmime := jni.JavaString(env, mimeType)
		uri, err := jni.CallObjectMethod(env, a.appCtx, insertMedia, jni.Value(jname), jni.Value(jmime))
		if err != nil {
			return err
		}
		mediaURI = jni.GoString(env, jni.String(uri))
		return nil
	})
	if err != nil {
		return fmt.Errorf("insertMedia: %w", err)
	}
	deleteURI := func(uri string) error {
		return jni.Do(a.jvm, func(env *jni.Env) error {
			cls := jni.GetObjectClass(env, a.appCtx)
			m := jni.GetMethodID(env, cls, "deleteUri", "(Ljava/lang/String;)V")
			juri := jni.JavaString(env, uri)
			return jni.CallVoidMethod(env, a.appCtx, m, jni.Value(juri))
		})
	}
	out, err := a.openURI(mediaURI, "w")
	if err != nil {
		deleteURI(mediaURI)
		return fmt.Errorf("openUri: %w", err)
	}
	if _, err := io.Copy(out, in); err != nil {
		deleteURI(mediaURI)
		return fmt.Errorf("copy: %w", err)
	}
	if err := out.Close(); err != nil {
		deleteURI(mediaURI)
		return fmt.Errorf("close: %w", err)
	}
	if err := a.notifyFile(mediaURI, f.Name); err != nil {
		fatalErr(err)
	}
	return b.DeleteFile(f.Name)
}

// openURI calls a.appCtx.getContentResolver().openFileDescriptor on uri and
// mode and returns the detached file descriptor.
func (a *App) openURI(uri, mode string) (*os.File, error) {
	var f *os.File
	err := jni.Do(a.jvm, func(env *jni.Env) error {
		cls := jni.GetObjectClass(env, a.appCtx)
		openURI := jni.GetMethodID(env, cls, "openUri", "(Ljava/lang/String;Ljava/lang/String;)I")
		juri := jni.JavaString(env, uri)
		jmode := jni.JavaString(env, mode)
		fd, err := jni.CallIntMethod(env, a.appCtx, openURI, jni.Value(juri), jni.Value(jmode))
		if err != nil {
			return err
		}
		f = os.NewFile(uintptr(fd), "media-store")
		return nil
	})
	return f, err
}

func (a *App) isChromeOS() bool {
	var chromeOS bool
	err := jni.Do(a.jvm, func(env *jni.Env) error {
		cls := jni.GetObjectClass(env, a.appCtx)
		m := jni.GetMethodID(env, cls, "isChromeOS", "()Z")
		b, err := jni.CallBooleanMethod(env, a.appCtx, m)
		chromeOS = b
		return err
	})
	if err != nil {
		panic(err)
	}
	return chromeOS
}

func (s *BackendState) updateExitNodes() {
	s.ExitStatus = ExitNone
	var exitID tailcfg.StableNodeID
	if p := s.Prefs; p != nil {
		exitID = p.ExitNodeID
		if exitID != "" {
			s.ExitStatus = ExitOffline
		}
	}
	hasMyExit := exitID == ""
	s.Exits = nil
	var peers []*tailcfg.Node
	if s.NetworkMap != nil {
		peers = s.NetworkMap.Peers
	}
	for _, p := range peers {
		canRoute := false
		for _, r := range p.AllowedIPs {
			if r == netaddr.MustParseIPPrefix("0.0.0.0/0") || r == netaddr.MustParseIPPrefix("::/0") {
				canRoute = true
				break
			}
		}
		myExit := p.StableID == exitID
		hasMyExit = hasMyExit || myExit
		exit := Peer{
			Label:  p.DisplayName(true),
			Online: canRoute,
			ID:     p.StableID,
		}
		if myExit {
			s.Exit = exit
			if canRoute {
				s.ExitStatus = ExitOnline
			}
		}
		if canRoute || myExit {
			s.Exits = append(s.Exits, exit)
		}
	}
	sort.Slice(s.Exits, func(i, j int) bool {
		return s.Exits[i].Label < s.Exits[j].Label
	})
	if !hasMyExit {
		// Insert node missing from netmap.
		s.Exit = Peer{Label: "Unknown device", ID: exitID}
		s.Exits = append([]Peer{s.Exit}, s.Exits...)
	}
}

// hostname builds a hostname from android.os.Build fields, in place of a
// useless os.Hostname().
func (a *App) hostname() string {
	var hostname string
	err := jni.Do(a.jvm, func(env *jni.Env) error {
		cls := jni.GetObjectClass(env, a.appCtx)
		getHostname := jni.GetMethodID(env, cls, "getHostname", "()Ljava/lang/String;")
		n, err := jni.CallObjectMethod(env, a.appCtx, getHostname)
		hostname = jni.GoString(env, jni.String(n))
		return err
	})
	if err != nil {
		panic(err)
	}
	return hostname
}

// 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 := jni.Do(a.jvm, func(env *jni.Env) error {
		cls := jni.GetObjectClass(env, a.appCtx)
		m := jni.GetMethodID(env, cls, "getOSVersion", "()Ljava/lang/String;")
		n, err := jni.CallObjectMethod(env, a.appCtx, m)
		version = jni.GoString(env, jni.String(n))
		return err
	})
	if err != nil {
		panic(err)
	}
	return version
}

// modelName return the MANUFACTURER + MODEL from
// android.os.Build.
func (a *App) modelName() string {
	var model string
	err := jni.Do(a.jvm, func(env *jni.Env) error {
		cls := jni.GetObjectClass(env, a.appCtx)
		m := jni.GetMethodID(env, cls, "getModelName", "()Ljava/lang/String;")
		n, err := jni.CallObjectMethod(env, a.appCtx, m)
		model = jni.GoString(env, jni.String(n))
		return err
	})
	if err != nil {
		panic(err)
	}
	return model
}

func googleSignInEnabled() bool {
	return googleClass != 0
}

// updateNotification updates the foreground persistent status notification.
func (a *App) updateNotification(service jni.Object, state ipn.State) error {
	var msg, title string
	switch state {
	case ipn.Starting:
		title, msg = "Connecting...", ""
	case ipn.Running:
		title, msg = "Connected", ""
	default:
		return nil
	}
	return jni.Do(a.jvm, func(env *jni.Env) error {
		cls := jni.GetObjectClass(env, service)
		update := jni.GetMethodID(env, cls, "updateStatusNotification", "(Ljava/lang/String;Ljava/lang/String;)V")
		jtitle := jni.JavaString(env, title)
		jmessage := jni.JavaString(env, msg)
		return jni.CallVoidMethod(env, service, update, jni.Value(jtitle), jni.Value(jmessage))
	})
}

// notifyExpiry notifies the user of imminent session expiry and
// returns a new timer that triggers when the user should be notified
// again.
func (a *App) notifyExpiry(service jni.Object, expiry time.Time) *time.Timer {
	if expiry.IsZero() {
		return nil
	}
	d := time.Until(expiry)
	var title string
	const msg = "Reauthenticate to maintain the connection to your network."
	var t *time.Timer
	const (
		aday = 24 * time.Hour
		soon = 5 * time.Minute
	)
	switch {
	case d <= 0:
		title = "Your authentication has expired!"
	case d <= soon:
		title = "Your authentication expires soon!"
		t = time.NewTimer(d)
	case d <= aday:
		title = "Your authentication expires in a day."
		t = time.NewTimer(d - soon)
	default:
		return time.NewTimer(d - aday)
	}
	if err := a.pushNotify(service, title, msg); err != nil {
		fatalErr(err)
	}
	return t
}

func (a *App) notifyFile(uri, msg string) error {
	return jni.Do(a.jvm, func(env *jni.Env) error {
		cls := jni.GetObjectClass(env, a.appCtx)
		notify := jni.GetMethodID(env, cls, "notifyFile", "(Ljava/lang/String;Ljava/lang/String;)V")
		juri := jni.JavaString(env, uri)
		jmsg := jni.JavaString(env, msg)
		return jni.CallVoidMethod(env, a.appCtx, notify, jni.Value(juri), jni.Value(jmsg))
	})
}

func (a *App) pushNotify(service jni.Object, title, msg string) error {
	return jni.Do(a.jvm, func(env *jni.Env) error {
		cls := jni.GetObjectClass(env, service)
		notify := jni.GetMethodID(env, cls, "notify", "(Ljava/lang/String;Ljava/lang/String;)V")
		jtitle := jni.JavaString(env, title)
		jmessage := jni.JavaString(env, msg)
		return jni.CallVoidMethod(env, service, notify, jni.Value(jtitle), jni.Value(jmessage))
	})
}

func (a *App) notify(state BackendState) {
	select {
	case <-a.netStates:
	default:
	}
	a.netStates <- state
	ready := jni.Bool(state.State >= ipn.Stopped)
	if err := a.callVoidMethod(a.appCtx, "setTileReady", "(Z)V", jni.Value(ready)); err != nil {
		fatalErr(err)
	}
}

func (a *App) setPrefs(prefs *ipn.Prefs) {
	wantRunning := jni.Bool(prefs.WantRunning)
	if err := a.callVoidMethod(a.appCtx, "setTileStatus", "(Z)V", jni.Value(wantRunning)); err != nil {
		fatalErr(err)
	}
	select {
	case <-a.prefs:
	default:
	}
	a.prefs <- prefs
}

func (a *App) setURL(url string) {
	select {
	case <-a.browseURLs:
	default:
	}
	a.browseURLs <- url
}

func (a *App) runUI() error {
	w := app.NewWindow()
	ui, err := newUI(a.store)
	if err != nil {
		return err
	}
	var ops op.Ops
	state := new(clientState)
	var (
		// activity is the most recent Android Activity reference as reported
		// by Gio ViewEvents.
		activity jni.Object
		// files is list of files from the most recent file sharing intent.
		files []File
	)
	deleteActivityRef := func() {
		if activity == 0 {
			return
		}
		jni.Do(a.jvm, func(env *jni.Env) error {
			jni.DeleteGlobalRef(env, activity)
			return nil
		})
		activity = 0
	}
	defer deleteActivityRef()
	for {
		select {
		case <-onVPNClosed:
			requestBackend(ConnectEvent{Enable: false})
		case tok := <-onGoogleToken:
			ui.signinType = noSignin
			if tok != "" {
				requestBackend(OAuth2Event{
					Token: &tailcfg.Oauth2Token{
						AccessToken: tok,
						TokenType:   ipn.GoogleIDTokenType,
					},
				})
			} else {
				// Warn about possible debug certificate.
				if !a.isReleaseSigned() {
					ui.ShowMessage("Google Sign-In failed because the app is not signed for Play Store")
					w.Invalidate()
				}
			}
		case p := <-a.prefs:
			ui.enabled.Value = p.WantRunning
			ui.runningExit = p.AdvertisesExitNode()
			ui.exitLAN.Value = p.ExitNodeAllowLANAccess
			w.Invalidate()
		case url := <-a.browseURLs:
			ui.signinType = noSignin
			if a.isTV() {
				ui.ShowQRCode(url)
			} else {
				state.browseURL = url
			}
			w.Invalidate()
			a.updateState(activity, state)
		case newState := <-a.netStates:
			oldState := state.backend.State
			state.backend = newState
			a.updateState(activity, state)
			w.Invalidate()
			if activity != 0 {
				newState := state.backend.State
				// Start VPN if we just logged in.
				if oldState <= ipn.Stopped && newState > ipn.Stopped {
					if err := a.prepareVPN(activity); err != nil {
						fatalErr(err)
					}
				}
			}
		case <-onVPNPrepared:
			if state.backend.State > ipn.Stopped {
				if err := a.callVoidMethod(a.appCtx, "startVPN", "()V"); err != nil {
					return err
				}
				if activity != 0 {
					if err := a.callVoidMethod(a.appCtx, "requestWriteStoragePermission", "(Landroid/app/Activity;)V", jni.Value(activity)); err != nil {
						return err
					}
				}
			}
		case <-onVPNRevoked:
			ui.ShowMessage("VPN access denied or another VPN service is always-on")
			w.Invalidate()
		case files = <-onFileShare:
			ui.ShowShareDialog()
			w.Invalidate()
		case t := <-a.targetsLoaded:
			ui.FillShareDialog(t.Targets, t.Err)
			w.Invalidate()
		case <-a.invalidates:
			w.Invalidate()
		case e := <-w.Events():
			switch e := e.(type) {
			case app.ViewEvent:
				deleteActivityRef()
				view := jni.Object(e.View)
				if view == 0 {
					break
				}
				activity = a.contextForView(view)
				w.Invalidate()
				a.attachPeer(activity)
				if state.backend.State > ipn.Stopped {
					if err := a.prepareVPN(activity); err != nil {
						return err
					}
				}
			case system.DestroyEvent:
				return e.Err
			case *system.CommandEvent:
				if e.Type == system.CommandBack {
					if ui.onBack() {
						e.Cancel = true
						w.Invalidate()
					}
				}
			case system.FrameEvent:
				ins := e.Insets
				e.Insets = system.Insets{}
				gtx := layout.NewContext(&ops, e)
				events := ui.layout(gtx, ins, state)
				e.Frame(gtx.Ops)
				a.processUIEvents(w, events, activity, state, files)
			}
		}
	}
}

func (a *App) isTV() bool {
	var istv bool
	err := jni.Do(a.jvm, func(env *jni.Env) error {
		cls := jni.GetObjectClass(env, a.appCtx)
		m := jni.GetMethodID(env, cls, "isTV", "()Z")
		b, err := jni.CallBooleanMethod(env, a.appCtx, m)
		istv = b
		return err
	})
	if err != nil {
		fatalErr(err)
	}
	return istv
}

// isReleaseSigned reports whether the app is signed with a release
// signature.
func (a *App) isReleaseSigned() bool {
	var cert []byte
	err := jni.Do(a.jvm, func(env *jni.Env) error {
		cls := jni.GetObjectClass(env, a.appCtx)
		m := jni.GetMethodID(env, cls, "getPackageCertificate", "()[B")
		str, err := jni.CallObjectMethod(env, a.appCtx, m)
		if err != nil {
			return err
		}
		cert = jni.GetByteArrayElements(env, jni.ByteArray(str))
		return nil
	})
	if err != nil {
		fatalErr(err)
	}
	h := sha1.New()
	h.Write(cert)
	fingerprint := h.Sum(nil)
	hex := fmt.Sprintf("%x", fingerprint)
	// Strip colons and convert to lower case to ease comparing.
	wantFingerprint := strings.ReplaceAll(strings.ToLower(releaseCertFingerprint), ":", "")
	return hex == wantFingerprint
}

// attachPeer registers an Android Fragment instance for
// handling onActivityResult callbacks.
func (a *App) attachPeer(act jni.Object) {
	err := a.callVoidMethod(a.appCtx, "attachPeer", "(Landroid/app/Activity;)V", jni.Value(act))
	if err != nil {
		fatalErr(err)
	}
}

func (a *App) updateState(act jni.Object, state *clientState) {
	if act != 0 && state.browseURL != "" {
		a.browseToURL(act, state.browseURL)
		state.browseURL = ""
	}

	netmap := state.backend.NetworkMap
	var (
		peers []*tailcfg.Node
		myID  tailcfg.UserID
	)
	if netmap != nil {
		peers = netmap.Peers
		myID = netmap.User
	}
	// Split into sections.
	users := make(map[tailcfg.UserID]struct{})
	var uiPeers []UIPeer
	for _, p := range peers {
		if q := state.query; q != "" {
			// Filter peers according to search query.
			host := strings.ToLower(p.Hostinfo.Hostname())
			name := strings.ToLower(p.Name)
			var addr string
			if len(p.Addresses) > 0 {
				addr = p.Addresses[0].IP().String()
			}
			if !strings.Contains(host, q) && !strings.Contains(name, q) && !strings.Contains(addr, q) {
				continue
			}
		}
		users[p.User] = struct{}{}
		uiPeers = append(uiPeers, UIPeer{
			Owner: p.User,
			Peer:  p,
		})
	}
	// Add section (user) headers.
	for u := range users {
		name := netmap.UserProfiles[u].DisplayName
		name = strings.ToUpper(name)
		uiPeers = append(uiPeers, UIPeer{Owner: u, Name: name})
	}
	sort.Slice(uiPeers, func(i, j int) bool {
		lhs, rhs := uiPeers[i], uiPeers[j]
		if lu, ru := lhs.Owner, rhs.Owner; ru != lu {
			// Sort own peers first.
			if lu == myID {
				return true
			}
			if ru == myID {
				return false
			}
			return lu < ru
		}
		lp, rp := lhs.Peer, rhs.Peer
		// Sort headers first.
		if lp == nil {
			return true
		}
		if rp == nil {
			return false
		}
		lName := lp.DisplayName(lp.User == myID)
		rName := rp.DisplayName(rp.User == myID)
		return lName < rName || lName == rName && lp.ID < rp.ID
	})
	state.Peers = uiPeers
}

func (a *App) prepareVPN(act jni.Object) error {
	return a.callVoidMethod(a.appCtx, "prepareVPN", "(Landroid/app/Activity;I)V",
		jni.Value(act), jni.Value(requestPrepareVPN))
}

func requestBackend(e UIEvent) {
	go func() {
		backendEvents <- e
	}()
}

func (a *App) processUIEvents(w *app.Window, events []UIEvent, act jni.Object, state *clientState, files []File) {
	for _, e := range events {
		switch e := e.(type) {
		case ReauthEvent:
			method, _ := a.store.ReadString(loginMethodPrefKey, loginMethodWeb)
			switch method {
			case loginMethodGoogle:
				a.googleSignIn(act)
			default:
				requestBackend(WebAuthEvent{})
			}
		case BugEvent:
			backendLogID, _ := a.logIDPublicAtomic.Load().(string)
			logMarker := fmt.Sprintf("BUG-%v-%v-%v", backendLogID, time.Now().UTC().Format("20060102150405Z"), randHex(8))
			log.Printf("user bugreport: %s", logMarker)
			w.WriteClipboard(logMarker)
		case BeExitNodeEvent:
			requestBackend(e)
		case ExitAllowLANEvent:
			requestBackend(e)
		case WebAuthEvent:
			a.store.WriteString(loginMethodPrefKey, loginMethodWeb)
			requestBackend(e)
		case SetLoginServerEvent:
			a.store.WriteString(customLoginServerPrefKey, e.URL)
			requestBackend(e)
		case LogoutEvent:
			a.signOut()
			requestBackend(e)
		case ConnectEvent:
			requestBackend(e)
		case RouteAllEvent:
			requestBackend(e)
		case CopyEvent:
			w.WriteClipboard(e.Text)
		case GoogleAuthEvent:
			a.store.WriteString(loginMethodPrefKey, loginMethodGoogle)
			a.googleSignIn(act)
		case SearchEvent:
			state.query = strings.ToLower(e.Query)
			a.updateState(act, state)
		case FileSendEvent:
			a.sendFiles(e, files)
		}
	}
}

func (a *App) sendFiles(e FileSendEvent, files []File) {
	go func() {
		var totalSize int64
		for _, f := range files {
			totalSize += f.Size
		}
		if totalSize == 0 {
			totalSize = 1
		}
		var totalSent int64
		progress := func(n int64) {
			totalSent += n
			e.Updates(FileSendInfo{
				State:    FileSendTransferring,
				Progress: float64(totalSent) / float64(totalSize),
			})
			a.invalidate()
		}
		defer a.invalidate()
		for _, f := range files {
			if err := a.sendFile(e.Context, e.Target, f, progress); err != nil {
				if errors.Is(err, context.Canceled) {
					return
				}
				e.Updates(FileSendInfo{
					State: FileSendFailed,
				})
				return
			}
		}
		e.Updates(FileSendInfo{
			State: FileSendComplete,
		})
	}()
}

func (a *App) invalidate() {
	select {
	case a.invalidates <- struct{}{}:
	default:
	}
}

func (a *App) sendFile(ctx context.Context, target *apitype.FileTarget, f File, progress func(n int64)) error {
	var body io.Reader
	switch f.Type {
	case FileTypeText:
		body = strings.NewReader(f.Text)
	case FileTypeURI:
		f, err := a.openURI(f.URI, "r")
		if err != nil {
			return err
		}
		defer f.Close()
		body = f
	default:
		panic("unknown file type")
	}
	body = &progressReader{r: body, size: f.Size, progress: progress}
	dstURL := target.PeerAPIURL + "/v0/put/" + url.PathEscape(f.Name)
	req, err := http.NewRequestWithContext(ctx, "PUT", dstURL, body)
	if err != nil {
		return err
	}
	req.ContentLength = f.Size
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	if res.StatusCode != 200 {
		return fmt.Errorf("PUT failed: %s", res.Status)
	}
	return nil
}

// progressReader wraps an io.Reader to call a progress function
// on every non-zero Read.
type progressReader struct {
	r        io.Reader
	bytes    int64
	size     int64
	eof      bool
	progress func(n int64)
}

func (r *progressReader) Read(p []byte) (int, error) {
	n, err := r.r.Read(p)
	// The request body may be read after http.Client.Do returns, see
	// https://github.com/golang/go/issues/30597. Don't update progress if the
	// file has been read.
	r.eof = r.eof || errors.Is(err, io.EOF)
	if !r.eof && r.bytes < r.size {
		r.progress(int64(n))
		r.bytes += int64(n)
	}
	return n, err
}

func (a *App) signOut() {
	if googleClass == 0 {
		return
	}
	err := jni.Do(a.jvm, func(env *jni.Env) error {
		m := jni.GetStaticMethodID(env, googleClass,
			"googleSignOut", "(Landroid/content/Context;)V")
		return jni.CallStaticVoidMethod(env, googleClass, m, jni.Value(a.appCtx))
	})
	if err != nil {
		fatalErr(err)
	}
}

func (a *App) googleSignIn(act jni.Object) {
	if act == 0 || googleClass == 0 {
		return
	}
	err := jni.Do(a.jvm, func(env *jni.Env) error {
		sid := jni.JavaString(env, serverOAuthID)
		m := jni.GetStaticMethodID(env, googleClass,
			"googleSignIn", "(Landroid/app/Activity;Ljava/lang/String;I)V")
		return jni.CallStaticVoidMethod(env, googleClass, m,
			jni.Value(act), jni.Value(sid), jni.Value(requestSignin))
	})
	if err != nil {
		fatalErr(err)
	}
}

func (a *App) browseToURL(act jni.Object, url string) {
	if act == 0 {
		return
	}
	err := jni.Do(a.jvm, func(env *jni.Env) error {
		jurl := jni.JavaString(env, url)
		return a.callVoidMethod(a.appCtx, "showURL", "(Landroid/app/Activity;Ljava/lang/String;)V", jni.Value(act), jni.Value(jurl))
	})
	if err != nil {
		fatalErr(err)
	}
}

func (a *App) callVoidMethod(obj jni.Object, name, sig string, args ...jni.Value) error {
	if obj == 0 {
		panic("invalid object")
	}
	return jni.Do(a.jvm, func(env *jni.Env) error {
		cls := jni.GetObjectClass(env, obj)
		m := jni.GetMethodID(env, cls, name, sig)
		return jni.CallVoidMethod(env, obj, m, args...)
	})
}

// activityForView calls View.getContext and returns a global
// reference to the result.
func (a *App) contextForView(view jni.Object) jni.Object {
	if view == 0 {
		panic("invalid object")
	}
	var ctx jni.Object
	err := jni.Do(a.jvm, func(env *jni.Env) error {
		cls := jni.GetObjectClass(env, view)
		m := jni.GetMethodID(env, cls, "getContext", "()Landroid/content/Context;")
		var err error
		ctx, err = jni.CallObjectMethod(env, view, m)
		ctx = jni.NewGlobalRef(env, ctx)
		return err
	})
	if err != nil {
		panic(err)
	}
	return ctx
}

// Report interfaces in the device in net.Interface format.
func (a *App) getInterfaces() ([]interfaces.Interface, error) {
	var ifaceString string
	err := jni.Do(a.jvm, func(env *jni.Env) error {
		cls := jni.GetObjectClass(env, a.appCtx)
		m := jni.GetMethodID(env, cls, "getInterfacesAsString", "()Ljava/lang/String;")
		n, err := jni.CallObjectMethod(env, a.appCtx, m)
		ifaceString = jni.GoString(env, jni.String(n))
		return err

	})
	var ifaces []interfaces.Interface
	if err != nil {
		return ifaces, err
	}

	for _, iface := range strings.Split(ifaceString, "\n") {
		// Example of the strings we're processing:
		// wlan0 30 1500 true true false false true | fe80::2f60:2c82:4163:8389%wlan0/64 10.1.10.131/24
		// r_rmnet_data0 21 1500 true false false false false | fe80::9318:6093:d1ad:ba7f%r_rmnet_data0/64
		// mnet_data2 12 1500 true false false false false | fe80::3c8c:44dc:46a9:9907%rmnet_data2/64

		if strings.TrimSpace(iface) == "" {
			continue
		}

		fields := strings.Split(iface, "|")
		if len(fields) != 2 {
			log.Printf("getInterfaces: unable to split %q", iface)
			continue
		}

		var name string
		var index, mtu int
		var up, broadcast, loopback, pointToPoint, multicast bool
		_, err := fmt.Sscanf(fields[0], "%s %d %d %t %t %t %t %t",
			&name, &index, &mtu, &up, &broadcast, &loopback, &pointToPoint, &multicast)
		if err != nil {
			log.Printf("getInterfaces: unable to parse %q: %v", iface, err)
			continue
		}

		newIf := interfaces.Interface{
			Interface: &net.Interface{
				Name:  name,
				Index: index,
				MTU:   mtu,
			},
			AltAddrs: []net.Addr{}, // non-nil to avoid Go using netlink
		}
		if up {
			newIf.Flags |= net.FlagUp
		}
		if broadcast {
			newIf.Flags |= net.FlagBroadcast
		}
		if loopback {
			newIf.Flags |= net.FlagLoopback
		}
		if pointToPoint {
			newIf.Flags |= net.FlagPointToPoint
		}
		if multicast {
			newIf.Flags |= net.FlagMulticast
		}

		addrs := strings.Trim(fields[1], " \n")
		for _, addr := range strings.Split(addrs, " ") {
			ip, err := netaddr.ParseIPPrefix(addr)
			if err == nil {
				newIf.AltAddrs = append(newIf.AltAddrs, ip.IPNet())
			}
		}

		ifaces = append(ifaces, newIf)
	}

	return ifaces, nil
}

func fatalErr(err error) {
	// TODO: expose in UI.
	log.Printf("fatal error: %v", err)
}

func randHex(n int) string {
	b := make([]byte, n)
	rand.Read(b)
	return hex.EncodeToString(b)
}


================================================
FILE: cmd/tailscale/multitun.go
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
	"os"

	"golang.zx2c4.com/wireguard/tun"
)

// multiTUN implements a tun.Device that supports multiple
// underlying devices. This is necessary because Android VPN devices
// have static configurations and wgengine.NewUserspaceEngine
// assumes a single static tun.Device.
type multiTUN struct {
	// devices is for adding new devices.
	devices chan tun.Device
	// event is the combined event channel from all active devices.
	events chan tun.Event

	close    chan struct{}
	closeErr chan error

	reads        chan ioRequest
	writes       chan ioRequest
	flushes      chan chan error
	mtus         chan chan mtuReply
	names        chan chan nameReply
	shutdowns    chan struct{}
	shutdownDone chan struct{}
}

// tunDevice wraps and drives a single run.Device.
type tunDevice struct {
	dev tun.Device
	// close closes the device.
	close     chan struct{}
	closeDone chan error
	// readDone is notified when the read goroutine is done.
	readDone chan struct{}
}

type ioRequest struct {
	data   []byte
	offset int
	reply  chan<- ioReply
}

type ioReply struct {
	bytes int
	err   error
}

type mtuReply struct {
	mtu int
	err error
}

type nameReply struct {
	name string
	err  error
}

func newTUNDevices() *multiTUN {
	d := &multiTUN{
		devices:      make(chan tun.Device),
		events:       make(chan tun.Event),
		close:        make(chan struct{}),
		closeErr:     make(chan error),
		reads:        make(chan ioRequest),
		writes:       make(chan ioRequest),
		flushes:      make(chan chan error),
		mtus:         make(chan chan mtuReply),
		names:        make(chan chan nameReply),
		shutdowns:    make(chan struct{}),
		shutdownDone: make(chan struct{}),
	}
	go d.run()
	return d
}

func (d *multiTUN) run() {
	var devices []*tunDevice
	// readDone is the readDone channel of the device being read from.
	var readDone chan struct{}
	// runDone is the closeDone channel of the device being written to.
	var runDone chan error
	for {
		select {
		case <-readDone:
			// The oldest device has reached EOF, replace it.
			n := copy(devices, devices[1:])
			devices = devices[:n]
			if len(devices) > 0 {
				// Start reading from the next device.
				dev := devices[0]
				readDone = dev.readDone
				go d.readFrom(dev)
			}
		case <-runDone:
			// A device completed runDevice, replace it.
			if len(devices) > 0 {
				dev := devices[len(devices)-1]
				runDone = dev.closeDone
				go d.runDevice(dev)
			}
		case <-d.shutdowns:
			// Shut down all devices.
			for _, dev := range devices {
				close(dev.close)
				<-dev.closeDone
				<-dev.readDone
			}
			devices = nil
			d.shutdownDone <- struct{}{}
		case <-d.close:
			var derr error
			for _, dev := range devices {
				if err := <-dev.closeDone; err != nil {
					derr = err
				}
			}
			d.closeErr <- derr
			return
		case dev := <-d.devices:
			if len(devices) > 0 {
				// Ask the most recent device to stop.
				prev := devices[len(devices)-1]
				close(prev.close)
			}
			wrap := &tunDevice{
				dev:       dev,
				close:     make(chan struct{}),
				closeDone: make(chan error),
				readDone:  make(chan struct{}, 1),
			}
			if len(devices) == 0 {
				// Start using this first device.
				readDone = wrap.readDone
				go d.readFrom(wrap)
				runDone = wrap.closeDone
				go d.runDevice(wrap)
			}
			devices = append(devices, wrap)
		case f := <-d.flushes:
			var err error
			if len(devices) > 0 {
				dev := devices[len(devices)-1]
				err = dev.dev.Flush()
			}
			f <- err
		case m := <-d.mtus:
			r := mtuReply{mtu: defaultMTU}
			if len(devices) > 0 {
				dev := devices[len(devices)-1]
				r.mtu, r.err = dev.dev.MTU()
			}
			m <- r
		case n := <-d.names:
			var r nameReply
			if len(devices) > 0 {
				dev := devices[len(devices)-1]
				r.name, r.err = dev.dev.Name()
			}
			n <- r
		}
	}
}

func (d *multiTUN) readFrom(dev *tunDevice) {
	defer func() {
		dev.readDone <- struct{}{}
	}()
	for {
		select {
		case r := <-d.reads:
			n, err := dev.dev.Read(r.data, r.offset)
			stop := false
			if err != nil {
				select {
				case <-dev.close:
					stop = true
					err = nil
				default:
				}
			}
			r.reply <- ioReply{n, err}
			if stop {
				return
			}
		case <-d.close:
			return
		}
	}
}

func (d *multiTUN) runDevice(dev *tunDevice) {
	defer func() {
		// The documentation for https://developer.android.com/reference/android/net/VpnService.Builder#establish()
		// states that "Therefore, after draining the old file
		// descriptor...", but pending Reads are never unblocked
		// when a new descriptor is created.
		//
		// Close it instead and hope that no packets are lost.
		dev.closeDone <- dev.dev.Close()
	}()
	// Pump device events.
	go func() {
		for {
			select {
			case e := <-dev.dev.Events():
				d.events <- e
			case <-dev.close:
				return
			}
		}
	}()
	for {
		select {
		case w := <-d.writes:
			n, err := dev.dev.Write(w.data, w.offset)
			w.reply <- ioReply{n, err}
		case <-dev.close:
			// Device closed.
			return
		case <-d.close:
			// Multi-device closed.
			return
		}
	}
}

func (d *multiTUN) add(dev tun.Device) {
	d.devices <- dev
}

func (d *multiTUN) File() *os.File {
	// The underlying file descriptor is not constant on Android.
	// Let's hope no-one uses it.
	panic("not available on Android")
}

func (d *multiTUN) Read(data []byte, offset int) (int, error) {
	r := make(chan ioReply)
	d.reads <- ioRequest{data, offset, r}
	rep := <-r
	return rep.bytes, rep.err
}

func (d *multiTUN) Write(data []byte, offset int) (int, error) {
	r := make(chan ioReply)
	d.writes <- ioRequest{data, offset, r}
	rep := <-r
	return rep.bytes, rep.err
}

func (d *multiTUN) Flush() error {
	r := make(chan error)
	d.flushes <- r
	return <-r
}

func (d *multiTUN) MTU() (int, error) {
	r := make(chan mtuReply)
	d.mtus <- r
	rep := <-r
	return rep.mtu, rep.err
}

func (d *multiTUN) Name() (string, error) {
	r := make(chan nameReply)
	d.names <- r
	rep := <-r
	return rep.name, rep.err
}

func (d *multiTUN) Events() chan tun.Event {
	return d.events
}

func (d *multiTUN) Shutdown() {
	d.shutdowns <- struct{}{}
	<-d.shutdownDone
}

func (d *multiTUN) Close() error {
	close(d.close)
	return <-d.closeErr
}


================================================
FILE: cmd/tailscale/pprof.go
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

//go:build pprof
// +build pprof

package main

import (
	"net/http"
	_ "net/http/pprof"
)

func init() {
	go func() {
		http.ListenAndServe(":6060", nil)
	}()
}


================================================
FILE: cmd/tailscale/store.go
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
	"encoding/base64"

	"tailscale.com/ipn"

	"github.com/tailscale/tailscale-android/jni"
)

// stateStore is the Go interface for a persistent storage
// backend by androidx.security.crypto.EncryptedSharedPreferences (see
// App.java).
type stateStore struct {
	jvm *jni.JVM
	// appCtx is the global Android app context.
	appCtx jni.Object

	// Cached method ids on appCtx.
	encrypt jni.MethodID
	decrypt jni.MethodID
}

func newStateStore(jvm *jni.JVM, appCtx jni.Object) *stateStore {
	s := &stateStore{
		jvm:    jvm,
		appCtx: appCtx,
	}
	jni.Do(jvm, func(env *jni.Env) error {
		appCls := jni.GetObjectClass(env, appCtx)
		s.encrypt = jni.GetMethodID(
			env, appCls,
			"encryptToPref", "(Ljava/lang/String;Ljava/lang/String;)V",
		)
		s.decrypt = jni.GetMethodID(
			env, appCls,
			"decryptFromPref", "(Ljava/lang/String;)Ljava/lang/String;",
		)
		return nil
	})
	return s
}

func prefKeyFor(id ipn.StateKey) string {
	return "statestore-" + string(id)
}

func (s *stateStore) ReadString(key string, def string) (string, error) {
	data, err := s.read(key)
	if err != nil {
		return def, err
	}
	if data == nil {
		return def, nil
	}
	return string(data), nil
}

func (s *stateStore) WriteString(key string, val string) error {
	return s.write(key, []byte(val))
}

func (s *stateStore) ReadBool(key string, def bool) (bool, error) {
	data, err := s.read(key)
	if err != nil {
		return def, err
	}
	if data == nil {
		return def, nil
	}
	return string(data) == "true", nil
}

func (s *stateStore) WriteBool(key string, val bool) error {
	data := []byte("false")
	if val {
		data = []byte("true")
	}
	return s.write(key, data)
}

func (s *stateStore) ReadState(id ipn.StateKey) ([]byte, error) {
	state, err := s.read(prefKeyFor(id))
	if err != nil {
		return nil, err
	}
	if state == nil {
		return nil, ipn.ErrStateNotExist
	}
	return state, nil
}

func (s *stateStore) WriteState(id ipn.StateKey, bs []byte) error {
	prefKey := prefKeyFor(id)
	return s.write(prefKey, bs)
}

func (s *stateStore) read(key string) ([]byte, error) {
	var data []byte
	err := jni.Do(s.jvm, func(env *jni.Env) error {
		jfile := jni.JavaString(env, key)
		plain, err := jni.CallObjectMethod(env, s.appCtx, s.decrypt,
			jni.Value(jfile))
		if err != nil {
			return err
		}
		b64 := jni.GoString(env, jni.String(plain))
		if b64 == "" {
			return nil
		}
		data, err = base64.RawStdEncoding.DecodeString(b64)
		return err
	})
	return data, err
}

func (s *stateStore) write(key string, value []byte) error {
	bs64 := base64.RawStdEncoding.EncodeToString(value)
	err := jni.Do(s.jvm, func(env *jni.Env) error {
		jfile := jni.JavaString(env, key)
		jplain := jni.JavaString(env, bs64)
		err := jni.CallVoidMethod(env, s.appCtx, s.encrypt,
			jni.Value(jfile), jni.Value(jplain))
		if err != nil {
			return err
		}
		return nil
	})
	return err
}


================================================
FILE: cmd/tailscale/tools.go
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

//go:build tools
// +build tools

package main

import (
	_ "gioui.org/cmd/gogio"
)


================================================
FILE: cmd/tailscale/ui.go
================================================
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package main

import (
	"bytes"
	"context"
	"fmt"
	"image"
	"image/color"
	"time"

	"gioui.org/f32"
	"gioui.org/font/opentype"
	"gioui.org/io/pointer"
	"gioui.org/io/system"
	"gioui.org/layout"
	"gioui.org/op"
	"gioui.org/op/clip"
	"gioui.org/op/paint"
	"gioui.org/text"
	"gioui.org/unit"
	"gioui.org/widget"
	"gioui.org/widget/material"
	qrcode "github.com/skip2/go-qrcode"
	"golang.org/x/exp/shiny/materialdesign/icons"
	"inet.af/netaddr"
	"tailscale.com/client/tailscale/apitype"
	"tailscale.com/ipn"
	"tailscale.com/tailcfg"

	_ "embed"

	"eliasnaur.com/font/roboto/robotobold"
	"eliasnaur.com/font/roboto/robotoregular"

	_ "image/png"
)

type UI struct {
	theme *material.Theme
	store *stateStore

	// root is the scrollable list of the main UI.
	root layout.List
	// enabled is the switch for enabling or disabling the VPN.
	enabled widget.Bool
	search  widget.Editor

	exitLAN widget.Bool

	// webSigin is the button for the web-based sign-in flow.
	webSignin widget.Clickable

	// googleSignin is the button for native Google Sign-in.
	googleSignin widget.Clickable

	// openExitDialog opens the exit node picker.
	openExitDialog widget.Clickable

	signinType signinType

	setLoginServer    bool
	loginServer       widget.Editor
	loginServerSave   widget.Clickable
	loginServerCancel widget.Clickable

	self  widget.Clickable
	peers []widget.Clickable

	// exitDialog is state for the exit node dialog.
	exitDialog struct {
		show    bool
		dismiss Dismiss
		exits   widget.Enum
		list    layout.List
	}

	runningExit   bool // are we an exit node now?

	qr struct {
		show bool
		op   paint.ImageOp
	}

	intro struct {
		list  layout.List
		start widget.Clickable
		show  bool
	}

	menu struct {
		open    widget.Clickable
		dismiss Dismiss
		show    bool

		useLoginServer widget.Clickable
		copy           widget.Clickable
		reauth         widget.Clickable
		bug            widget.Clickable
		beExit         widget.Clickable
		exits          widget.Clickable
		logout         widget.Clickable
	}

	// The current pop-up message, if any
	message struct {
		text string
		// t0 is the time when the most recent message appeared.
		t0 time.Time
	}

	shareDialog struct {
		show    bool
		dismiss Dismiss
		list    layout.List
		// peers are the nodes ready to receive files.
		targets []shareTarget
		loaded  bool
		error   error
	}

	icons struct {
		search     *widget.Icon
		more       *widget.Icon
		exitStatus *widget.Icon
		done       *widget.Icon
		error      *widget.Icon
		logo       paint.ImageOp
		google     paint.ImageOp
	}
}

type shareTarget struct {
	btn     widget.Clickable
	target  *apitype.FileTarget
	info    FileSendInfo
	cancel  func()
	updates <-chan FileSendInfo
}

type signinType uint8

// An UIPeer is either a peer or a section header
// with the user information.
type UIPeer struct {
	// Owner of the peer.
	Owner tailcfg.UserID
	// Name is the owner's name in all caps (for section headers).
	Name string
	// Peer is nil for section headers.
	Peer *tailcfg.Node
}

// menuItem describes an item in a popup menu.
type menuItem struct {
	title string
	btn   *widget.Clickable
}

const (
	headerColor = 0x496495
	infoColor   = 0x3a517b
	white       = 0xffffff
)

const (
	keyShowIntro = "ui.showintro"
)

const (
	noSignin signinType = iota
	webSignin
	googleSignin
)

type (
	C = layout.Context
	D = layout.Dimensions
)

var (
	//go:embed tailscale.png
	tailscaleLogo []byte
	//go:embed google.png
	googleLogo []byte
)

func newUI(store *stateStore) (*UI, error) {
	searchIcon, err := widget.NewIcon(icons.ActionSearch)
	if err != nil {
		return nil, err
	}
	moreIcon, err := widget.NewIcon(icons.NavigationMoreVert)
	if err != nil {
		return nil, err
	}
	exitStatus, err := widget.NewIcon(icons.NavigationMenu)
	if err != nil {
		return nil, err
	}
	doneIcon, err := widget.NewIcon(icons.ActionCheckCircle)
	if err != nil {
		return nil, err
	}
	errorIcon, err := widget.NewIcon(icons.AlertErrorOutline)
	if err != nil {
		return nil, err
	}
	logo, _, err := image.Decode(bytes.NewReader(tailscaleLogo))
	if err != nil {
		return nil, err
	}
	google, _, err := image.Decode(bytes.NewReader(googleLogo))
	if err != nil {
		return nil, err
	}
	face, err := opentype.Parse(robotoregular.TTF)
	if err != nil {
		panic(fmt.Sprintf("failed to parse font: %v", err))
	}
	faceBold, err := opentype.Parse(robotobold.TTF)
	if err != nil {
		panic(fmt.Sprintf("failed to parse font: %v", err))
	}
	fonts := []text.FontFace{
		{Font: text.Font{Typeface: "Roboto"}, Face: face},
		{Font: text.Font{Typeface: "Roboto", Weight: text.Bold}, Face: faceBold},
	}
	ui := &UI{
		theme: material.NewTheme(fonts),
		store: store,
	}
	ui.intro.show, _ = store.ReadBool(keyShowIntro, true)
	ui.icons.search = searchIcon
	ui.icons.more = moreIcon
	ui.icons.exitStatus = exitStatus
	ui.icons.done = doneIcon
	ui.icons.error = errorIcon
	ui.icons.logo = paint.NewImageOp(logo)
	ui.icons.google = paint.NewImageOp(google)
	ui.root.Axis = layout.Vertical
	ui.intro.list.Axis = layout.Vertical
	ui.search.SingleLine = true
	ui.loginServer.SingleLine = true
	ui.exitDialog.list.Axis = layout.Vertical
	ui.shareDialog.list.Axis = layout.Vertical
	return ui, nil
}

func mulAlpha(c color.NRGBA, alpha uint8) color.NRGBA {
	c.A = uint8(uint32(c.A) * uint32(alpha) / 0xff)
	return c
}

func (ui *UI) onBack() bool {
	b := ui.activeDialog()
	if b == nil {
		return false
	}
	*b = false
	return true
}

func (ui *UI) activeDialog() *bool {
	switch {
	case ui.qr.show:
		return &ui.qr.show
	case ui.menu.show:
		return &ui.menu.show
	case ui.shareDialog.show:
		return &ui.shareDialog.show
	case ui.exitDialog.show:
		return &ui.exitDialog.show
	}
	return nil
}

func (ui *UI) layout(gtx layout.Context, sysIns system.Insets, state *clientState) []UIEvent {
	// "Get started".
	if ui.intro.show {
		if ui.intro.start.Clicked() {
			ui.store.WriteBool(keyShowIntro, false)
			ui.intro.show = false
		}
		ui.layoutIntro(gtx, sysIns)
		return nil
	}

	var events []UIEvent

	if ui.enabled.Changed() {
		events = append(events, ConnectEvent{Enable: ui.enabled.Value})
	}

	for _, e := range ui.search.Events() {
		if _, ok := e.(widget.ChangeEvent); ok {
			events = append(events, SearchEvent{Query: ui.search.Text()})
			break
		}
	}
	for ui.menu.open.Clicked() {
		ui.menu.show = !ui.menu.show
	}

	netmap := state.backend.NetworkMap
	var (
		localName, localAddr string
		expiry               time.Time
		userID               tailcfg.UserID
		exitID               tailcfg.StableNodeID
	)
	if netmap != nil {
		userID = netmap.User
		expiry = netmap.Expiry
		localName = netmap.SelfNode.DisplayName(false)
		if addrs := netmap.Addresses; len(addrs) > 0 {
			localAddr = addrs[0].IP().String()
		}
	}
	if p := state.backend.Prefs; p != nil {
		exitID = p.ExitNodeID
	}
	if d := &ui.exitDialog; d.show {
		if newID := tailcfg.StableNodeID(d.exits.Value); newID != exitID {
			d.show = false
			events = append(events, RouteAllEvent{newID})
		}
	} else {
		d.exits.Value = string(exitID)
	}
	if ui.exitLAN.Changed() {
		events = append(events, ExitAllowLANEvent(ui.exitLAN.Value))
	}

	if ui.googleSignin.Clicked() {
		ui.signinType = googleSignin
		events = append(events, GoogleAuthEvent{})
	}

	if ui.webSignin.Clicked() {
		ui.signinType = webSignin
		events = append(events, WebAuthEvent{})
	}

	if ui.loginServerSave.Clicked() {
		text := ui.loginServer.Text()
		ui.showMessage(gtx, "Login server saved, relaunch the app")
		events = append(events, SetLoginServerEvent{URL: text})
	}
	if ui.loginServerCancel.Clicked() {
		ui.setLoginServer = false
	}

	if ui.menuClicked(&ui.menu.useLoginServer) {
		ui.setLoginServer = true
		savedLoginServer, _ := ui.store.ReadString(customLoginServerPrefKey, "")
		ui.loginServer.SetText(savedLoginServer)
	}

	if ui.menuClicked(&ui.menu.copy) && localAddr != "" {
		events = append(events, CopyEvent{Text: localAddr})
		ui.showCopied(gtx, localAddr)
	}

	if ui.menuClicked(&ui.menu.reauth) {
		events = append(events, ReauthEvent{})
	}

	if ui.menuClicked(&ui.menu.bug) {
		events = append(events, BugEvent{})
		ui.showCopied(gtx, "bug report marker to clipboard")
	}

	if ui.menuClicked(&ui.menu.beExit) {
		ui.runningExit = !ui.runningExit
		events = append(events, BeExitNodeEvent(ui.runningExit))
		if ui.runningExit {
			ui.showMessage(gtx, "Running exit node")
		} else {
			ui.showMessage(gtx, "Stopped running exit node")
		}
	}

	if ui.menuClicked(&ui.menu.exits) || ui.openExitDialog.Clicked() {
		ui.exitDialog.show = true
	}

	if ui.menuClicked(&ui.menu.logout) {
		events = append(events, LogoutEvent{})
	}

	for i := range ui.shareDialog.targets {
		t := &ui.shareDialog.targets[i]
		select {
		case t.info = <-t.updates:
		default:
		}
		if !t.btn.Clicked() {
			continue
		}
		switch t.info.State {
		case FileSendTransferring, FileSendConnecting:
			t.cancel()
			t.info.State = FileSendNotStarted
			t.updates = nil
			continue
		}
		t.info = FileSendInfo{
			State: FileSendConnecting,
		}
		ctx, cancel := context.WithCancel(context.Background())
		t.cancel = cancel
		updates := make(chan FileSendInfo, 1)
		t.updates = updates
		events = append(events, FileSendEvent{
			Target:  t.target,
			Context: ctx,
			Updates: func(info FileSendInfo) {
				select {
				case <-updates:
				default:
				}
				updates <- info
			},
		})
	}

	for len(ui.peers) < len(state.Peers) {
		ui.peers = append(ui.peers, widget.Clickable{})
	}
	if max := len(state.Peers); len(ui.peers) > max {
		ui.peers = ui.peers[:max]
	}

	const numHeaders = 6
	n := numHeaders + len(state.Peers)
	needsLogin := state.backend.State == ipn.NeedsLogin
	if !needsLogin {
		ui.qr.show = false
	}
	rootGtx := gtx
	if ui.activeDialog() != nil {
		rootGtx.Queue = nil
	}
	ui.root.Layout(rootGtx, n, func(gtx C, idx int) D {
		var in layout.Inset
		if idx == n-1 {
			// The last list element includes the bottom system
			// inset.
			in.Bottom = sysIns.Bottom
		}
		return in.Layout(gtx, func(gtx C) D {
			switch idx {
			case 0:
				return ui.layoutTop(gtx, sysIns, &state.backend)
			case 1:
				if netmap == nil || state.backend.State < ipn.Stopped {
					return D{}
				}
				for ui.self.Clicked() {
					events = append(events, CopyEvent{Text: localAddr})
					ui.showCopied(gtx, localAddr)
				}
				return ui.layoutLocal(gtx, sysIns, localName, localAddr)
			case 2:
				return ui.layoutExitStatus(gtx, &state.backend)
			case 3:
				if state.backend.State < ipn.Stopped {
					return D{}
				}
				return ui.layoutSearchbar(gtx, sysIns)
			case 4:
				if !needsLogin || state.backend.LostInternet {
					return D{}
				}
				return ui.layoutSignIn(gtx, &state.backend)
			case 5:
				if !state.backend.LostInternet {
					return D{}
				}
				return ui.layoutDisconnected(gtx)
			default:
				if needsLogin {
					return D{}
				}
				pidx := idx - numHeaders
				p := &state.Peers[pidx]
				if p.Peer == nil {
					name := p.Name
					if p.Owner == userID {
						name = "MY DEVICES"
					}
					return ui.layoutSection(gtx, sysIns, name)
				} else {
					clk := &ui.peers[pidx]
					if clk.Clicked() {
						if addrs := p.Peer.Addresses; len(addrs) > 0 {
							a := addrs[0].IP().String()
							events = append(events, CopyEvent{Text: a})
							ui.showCopied(gtx, a)
						}
					}
					return ui.layoutPeer(gtx, sysIns, p, userID, clk)
				}
			}
		})
	})

	ui.layoutExitNodeDialog(gtx, sysIns, state.backend.Exits)

	ui.layoutShareDialog(gtx, sysIns)

	// Popup messages.
	ui.layoutMessage(gtx, sysIns)

	// 3-dots menu.
	if ui.menu.show {
		ui.layoutMenu(gtx, sysIns, expiry, exitID != "" || len(state.backend.Exits) > 0, needsLogin)
	}

	if ui.qr.show {
		ui.layoutQR(gtx, sysIns)
	}

	return events
}

func (ui *UI) layoutQR(gtx layout.Context, sysIns system.Insets) layout.Dimensions {
	fill{rgb(0x232323)}.Layout(gtx, gtx.Constraints.Max)
	return layout.Center.Layout(gtx, func(gtx C) D {
		return drawImage(gtx, ui.qr.op, unit.Dp(300))
	})
}

func (ui *UI) FillShareDialog(targets []*apitype.FileTarget, err error) {
	ui.shareDialog.error = err
	ui.shareDialog.loaded = true
	targetSet := make(map[tailcfg.NodeID]int)
	if ui.shareDialog.show {
		// Update rather than replace list.
		for i, t := range ui.shareDialog.targets {
			targetSet[t.target.Node.ID] = i
		}
	} else {
		ui.shareDialog.targets = nil
	}
	for _, t := range targets {
		if i, ok := targetSet[t.Node.ID]; ok {
			ui.shareDialog.targets[i].target = t
		} else {
			ui.shareDialog.targets = append(ui.shareDialog.targets, shareTarget{target: t})
		}
	}
}

func (ui *UI) ShowShareDialog() {
	ui.shareDialog.show = true
}

func (ui *UI) ShowMessage(msg string) {
	ui.message.text = msg
	ui.message.t0 = time.Now()
}

func (ui *UI) ShowQRCode(url string) {
	ui.qr.show = true
	q, err := qrcode.New(url, qrcode.Medium)
	if err != nil {
		fatalErr(err)
		return
	}
	ui.qr.op = paint.NewImageOp(q.Image(512))
}

// Dismiss is a widget that detects pointer presses.
type Dismiss struct {
}

func (d *Dismiss) Add(gtx layout.Context, color color.NRGBA) {
	defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Min}).Push(gtx.Ops).Pop()
	pointer.InputOp{Tag: d, Types: pointer.Press}.Add(gtx.Ops)
	paint.Fill(gtx.Ops, color)
}

func (d *Dismiss) Dismissed(gtx layout.Context) bool {
	for _, e := range gtx.Events(d) {
		if e, ok := e.(pointer.Event); ok {
			if e.Type == pointer.Press {
				return true
			}
		}
	}
	return false
}

func (ui *UI) layoutExitStatus(gtx layout.Context, state *BackendState) layout.Dimensions {
	var bg color.NRGBA
	var text string
	switch state.ExitStatus {
	case ExitNone:
		return D{}
	case ExitOffline:
		text = "Exit node offline"
		bg = rgb(0xc65835)
	case ExitOnline:
		text = "Using exit node"
		bg = rgb(0x338b51)
	}
	paint.Fill(gtx.Ops, bg)
	return material.Clickable(gtx, &ui.openExitDialog, func(gtx C) D {
		gtx.Constraints.Min.X = gtx.Constraints.Max.X
		return layout.Inset{
			Top:    unit.Dp(12),
			Bottom: unit.Dp(12),
			Right:  unit.Dp(24),
			Left:   unit.Dp(24),
		}.Layout(gtx, func(gtx C) D {
			return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
				layout.Flexed(1, func(gtx C) D {
					return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
						layout.Rigid(func(gtx C) D {
							lbl := material.Body2(ui.theme, text)
							lbl.Color = rgb(white)
							return lbl.Layout(gtx)
						}),
						layout.Rigid(func(gtx C) D {
							node := material.Body2(ui.theme, state.Exit.Label)
							node.Color = argb(0x88ffffff)
							return node.Layout(gtx)
						}),
					)
				}),
				layout.Rigid(func(gtx C) D {
					return ui.icons.exitStatus.Layout(gtx, rgb(white))
				}),
			)
		})
	})
}

// layoutSignIn lays out the sign in button(s).
func (ui *UI) layoutSignIn(gtx layout.Context, state *BackendState) layout.Dimensions {
	return layout.Inset{Top: unit.Dp(48), Left: unit.Dp(48), Right: unit.Dp(48)}.Layout(gtx, func(gtx C) D {
		const (
			textColor = 0x555555
		)

		border := widget.Border{Color: rgb(textColor), CornerRadius: unit.Dp(4), Width: unit.Px(1)}

		if ui.setLoginServer {
			return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx,
				layout.Rigid(func(gtx C) D {
					return layout.Inset{Bottom: unit.Dp(16)}.Layout(gtx, func(gtx C) D {
						return Background{Color: rgb(0xe3e2ea), CornerRadius: unit.Dp(8)}.Layout(gtx, func(gtx C) D {
							return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx C) D {
								return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
									layout.Flexed(1,
										material.Editor(ui.theme, &ui.loginServer, "https://controlplane.tailscale.com").Layout,
									),
								)
							})
						})
					})
				}),
				layout.Rigid(func(gtx C) D {
					return layout.Inset{Bottom: unit.Dp(16)}.Layout(gtx, func(gtx C) D {
						return border.Layout(gtx, func(gtx C) D {
							button := material.Button(ui.theme, &ui.loginServerSave, "Save and restart")
							button.Background = color.NRGBA{} // transparent
							button.Color = rgb(textColor)
							return button.Layout(gtx)
						})
					})
				}),
				layout.Rigid(func(gtx C) D {
					return border.Layout(gtx, func(gtx C) D {
						button := material.Button(ui.theme, &ui.loginServerCancel, "Cancel")
						button.Background = color.NRGBA{} // transparent
						button.Color = rgb(textColor)
						return button.Layout(gtx)
					})
				}),
			)
		}

		return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx,
			layout.Rigid(func(gtx C) D {
				if !googleSignInEnabled() {
					return D{}
				}
				return layout.Inset{Bottom: unit.Dp(16)}.Layout(gtx, func(gtx C) D {
					signin := material.ButtonLayout(ui.theme, &ui.googleSignin)
					signin.Background = color.NRGBA{} // transparent

					return ui.withLoader(gtx, ui.signinType == googleSignin, func(gtx C) D {
						return border.Layout(gtx, func(gtx C) D {
							if ui.signinType != noSignin {
								gtx.Queue = nil
							}
							return signin.Layout(gtx, func(gtx C) D {
								gtx.Constraints.Max.Y = gtx.Px(unit.Dp(48))
								return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
									layout.Rigid(func(gtx C) D {
										return layout.Inset{Right: unit.Dp(4)}.Layout(gtx, func(gtx C) D {
											return drawImage(gtx, ui.icons.google, unit.Dp(16))
										})
									}),
									layout.Rigid(func(gtx C) D {
										return layout.Inset{Top: unit.Dp(10), Bottom: unit.Dp(10)}.Layout(gtx, func(gtx C) D {
											l := material.Body2(ui.theme, "Sign in with Google")
											l.Color = rgb(textColor)
											return l.Layout(gtx)
										})
									}),
								)
							})
						})
					})
				})
			}),
			layout.Rigid(func(gtx C) D {
				label := "Sign in with other"
				if !googleSignInEnabled() {
					label = "Sign in"
				}
				return ui.withLoader(gtx, ui.signinType == webSignin, func(gtx C) D {
					return border.Layout(gtx, func(gtx C) D {
						if ui.signinType != noSignin {
							gtx.Queue = nil
						}
						signin := material.Button(ui.theme, &ui.webSignin, label)
						signin.Background = color.NRGBA{} // transparent
						signin.Color = rgb(textColor)
						return signin.Layout(gtx)
					})
				})
			}),
		)
	})
}

func (ui *UI) withLoader(gtx layout.Context, loading bool, w layout.Widget) layout.Dimensions {
	cons := gtx.Constraints
	return layout.Stack{Alignment: layout.W}.Layout(gtx,
		layout.Stacked(func(gtx C) D {
			gtx.Constraints = cons
			return w(gtx)
		}),
		layout.Stacked(func(gtx C) D {
			if !loading {
				return D{}
			}
			return layout.Inset{Left: unit.Dp(16)}.Layout(gtx, func(gtx C) D {
				gtx.Constraints.Min = image.Point{
					X: gtx.Px(unit.Dp(16)),
				}
				return material.Loader(ui.theme).Layout(gtx)
			})
		}),
	)
}

// layoutDisconnected lays out the "please connect to the internet"
// message.
func (ui *UI) layoutDisconnected(gtx layout.Context) layout.Dimensions {
	return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx C) D {
		return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
			layout.Rigid(func(gtx C) D {
				return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, func(gtx C) D {
					title := material.H6(ui.theme, "No internet connection")
					title.Alignment = text.Middle
					return title.Layout(gtx)
				})
			}),
			layout.Rigid(func(gtx C) D {
				return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, func(gtx C) D {
					msg := material.Body2(ui.theme, "Tailscale is paused while your device is offline. Please reconnect to the internet.")
					msg.Alignment = text.Middle
					return msg.Layout(gtx)
				})
			}),
		)
	})
}

// layoutIntro lays out the intro page with the logo and terms.
func (ui *UI) layoutIntro(gtx layout.Context, sysIns system.Insets) {
	fill{rgb(0x232323)}.Layout(gtx, gtx.Constraints.Max)
	ui.intro.list.Layout(gtx, 1, func(gtx C, idx int) D {
		return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
			// 9 dot logo.
			layout.Rigid(func(gtx C) D {
				return layout.Inset{Top: unit.Dp(80), Bottom: unit.Dp(48)}.Layout(gtx, func(gtx C) D {
					return layout.N.Layout(gtx, func(gtx C) D {
						sz := gtx.Px(unit.Dp(72))
						drawLogo(gtx.Ops, sz)
						return layout.Dimensions{Size: image.Pt(sz, sz)}
					})
				})
			}),
			// "tailscale".
			layout.Rigid(func(gtx C) D {
				return layout.N.Layout(gtx, func(gtx C) D {
					return drawImage(gtx, ui.icons.logo, unit.Dp(200))
				})
			}),
			// Terms.
			layout.Rigid(func(gtx C) D {
				return layout.Inset{
					Top:   unit.Dp(48),
					Left:  unit.Dp(32),
					Right: unit.Dp(32),
				}.Layout(gtx, func(gtx C) D {
					terms := material.Body2(ui.theme, termsText)
					terms.Color = rgb(0xbfbfbf)
					terms.Alignment = text.Middle
					return terms.Layout(gtx)
				})
			}),
			// "Get started".
			layout.Rigid(func(gtx C) D {
				return layout.Inset{
					Top:    unit.Dp(16),
					Left:   unit.Dp(16),
					Right:  unit.Dp(16),
					Bottom: unit.Add(gtx.Metric, sysIns.Bottom),
				}.Layout(gtx, func(gtx C) D {
					start := material.Button(ui.theme, &ui.intro.start, "Get Started")
					start.Inset = layout.UniformInset(unit.Dp(16))
					start.CornerRadius = unit.Dp(16)
					start.Background = rgb(0x496495)
					start.TextSize = unit.Sp(20)
					return start.Layout(gtx)
				})
			}),
		)
	})
}

// menuClicked is like btn.Clicked, but also closes the menu if true.
func (ui *UI) menuClicked(btn *widget.Clickable) bool {
	cl := btn.Clicked()
	if cl {
		ui.menu.show = false
	}
	return cl
}

// layoutShareDialog lays out the file sharing dialog shown on file send intents (ACTION_SEND, ACTION_SEND_MULTIPLE).
func (ui *UI) layoutShareDialog(gtx layout.Context, sysIns system.Insets) {
	d := &ui.shareDialog
	if d.dismiss.Dismissed(gtx) {
		ui.shareDialog.show = false
	}
	if !d.show {
		return
	}
	d.dismiss.Add(gtx, argb(0x66000000))
	layout.Inset{
		Top:    unit.Add(gtx.Metric, sysIns.Top, unit.Dp(16)),
		Right:  unit.Add(gtx.Metric, sysIns.Right, unit.Dp(16)),
		Bottom: unit.Add(gtx.Metric, sysIns.Bottom, unit.Dp(16)),
		Left:   unit.Add(gtx.Metric, sysIns.Left, unit.Dp(16)),
	}.Layout(gtx, func(gtx C) D {
		return layout.Center.Layout(gtx, func(gtx C) D {
			gtx.Constraints.Min.X = gtx.Px(unit.Dp(250))
			gtx.Constraints.Max.X = gtx.Constraints.Min.X
			return layoutDialog(gtx, func(gtx C) D {
				return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
					layout.Rigid(func(gtx C) D {
						// Header.
						d := layout.Inset{
							Top:    unit.Dp(16),
							Right:  unit.Dp(20),
							Left:   unit.Dp(20),
							Bottom: unit.Dp(16),
						}.Layout(gtx, func(gtx C) D {
							l := material.Body1(ui.theme, "Share via Tailscale")
							l.Font.Weight = text.Bold
							return l.Layout(gtx)
						})
						// Swallow clicks to title.
						var c widget.Clickable
						gtx.Queue = nil
						return c.Layout(gtx, func(gtx C) D { return d })
					}),
					layout.Rigid(func(gtx C) D {
						if d.loaded {
							return D{}
						}
						return layout.UniformInset(unit.Dp(50)).Layout(gtx, func(gtx C) D {
							return layout.Center.Layout(gtx, func(gtx C) D {
								sz := gtx.Px(unit.Dp(32))
								gtx.Constraints.Min = image.Pt(sz, sz)
								gtx.Constraints.Max = gtx.Constraints.Min
								return material.Loader(ui.theme).Layout(gtx)
							})
						})
					}),
					layout.Rigid(func(gtx C) D {
						if d.error == nil {
							return D{}
						}
						sz := gtx.Px(unit.Dp(50))
						gtx.Constraints.Min.Y = sz
						return layout.UniformInset(unit.Dp(20)).Layout(gtx, func(gtx C) D {
							return layout.W.Layout(gtx, func(gtx C) D {
								return material.Body2(ui.theme, d.error.Error()).Layout(gtx)
							})
						})
					}),
					layout.Flexed(1, func(gtx C) D {
						gtx.Constraints.Min.Y = 0
						return d.list.Layout(gtx, len(d.targets), func(gtx C, idx int) D {
							node := &d.targets[idx]
							target := node.target.Node
							lbl := target.ComputedName
							offline := target.Online != nil && !*target.Online
							if offline {
								lbl = lbl + " (offline)"
							}
							w := material.Body2(ui.theme, lbl)
							if offline {
								w.Color = rgb(0xbbbbbb)
								gtx.Queue = nil
							}
							return material.Clickable(gtx, &node.btn, func(gtx C) D {
								return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx C) D {
									return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
										layout.Flexed(1, w.Layout),
										layout.Rigid(func(gtx C) D {
											sz := gtx.Px(unit.Dp(16))
											gtx.Constraints.Min = image.Pt(sz, sz)
											switch node.info.State {
											case FileSendConnecting:
												return material.Loader(ui.theme).Layout(gtx)
											case FileSendTransferring:
												return material.ProgressCircle(ui.theme, float32(node.info.Progress)).Layout(gtx)
											case FileSendFailed:
												return ui.icons.error.Layout(gtx, rgb(0xcc6539))
											case FileSendComplete:
												return ui.icons.done.Layout(gtx, ui.theme.Palette.ContrastBg)
											default:
												return D{}
											}
										}),
									)
								})
							})
						})
					}),
				)
			})
		})
	})
}

// layoutExitNodeDialog lays out the exit node selection dialog.
func (ui *UI) layoutExitNodeDialog(gtx layout.Context, sysIns system.Insets, exits []Peer) {
	d := &ui.exitDialog
	if d.dismiss.Dismissed(gtx) {
		d.show = false
	}
	if !d.show {
		return
	}
	d.dismiss.Add(gtx, argb(0x66000000))
	layout.Inset{
		Top:    unit.Add(gtx.Metric, sysIns.Top, unit.Dp(16)),
		Right:  unit.Add(gtx.Metric, sysIns.Right, unit.Dp(16)),
		Bottom: unit.Add(gtx.Metric, sysIns.Bottom, unit.Dp(16)),
		Left:   unit.Add(gtx.Metric, sysIns.Left, unit.Dp(16)),
	}.Layout(gtx, func(gtx C) D {
		return layout.Center.Layout(gtx, func(gtx C) D {
			gtx.Constraints.Min.X = gtx.Px(unit.Dp(250))
			gtx.Constraints.Max.X = gtx.Constraints.Min.X
			return layoutDialog(gtx, func(gtx C) D {
				return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
					layout.Rigid(func(gtx C) D {
						// Header.
						return layout.Inset{
							Top:    unit.Dp(16),
							Right:  unit.Dp(20),
							Left:   unit.Dp(20),
							Bottom: unit.Dp(16),
						}.Layout(gtx, func(gtx C) D {
							l := material.Body1(ui.theme, "Use exit node...")
							l.Font.Weight = text.Bold
							return l.Layout(gtx)
						})
					}),
					layout.Flexed(1, func(gtx C) D {
						gtx.Constraints.Min.Y = 0
						// Add "none" exit node, then "Allow LAN" checkbox, then the exit nodes.
						n := len(exits) + 2
						return d.list.Layout(gtx, n, func(gtx C, idx int) D {
							if idx == 0 {
								btn := material.CheckBox(ui.theme, &ui.exitLAN, "Allow LAN access")
								return layout.Inset{
									Right:  unit.Dp(16),
									Left:   unit.Dp(16),
									Bottom: unit.Dp(16),
								}.Layout(gtx, btn.Layout)
							}
							node := Peer{Label: "None", Online: true}
							if idx >= 2 {
								node = exits[idx-2]
							}
							lbl := node.Label
							if !node.Online {
								lbl = lbl + " (offline)"
							}
							btn := material.RadioButton(ui.theme, &d.exits, string(node.ID), lbl)
							if !node.Online {
								btn.Color = rgb(0xbbbbbb)
								btn.IconColor = btn.Color
							}
							return layout.Inset{
								Right:  unit.Dp(16),
								Left:   unit.Dp(16),
								Bottom: unit.Dp(16),
							}.Layout(gtx, btn.Layout)
						})
					}),
				)
			})
		})
	})
}

func layoutMenu(th *material.Theme, gtx layout.Context, items []menuItem, header layout.Widget) layout.Dimensions {
	return layoutDialog(gtx, func(gtx C) D {
		// Lay out menu items twice; once for
		// measuring the widest item, once for actual layout.
		var maxWidth int
		var minWidth int
		children := []layout.FlexChild{
			layout.Rigid(func(gtx C) D {
				return layout.Inset{
					Top:    unit.Dp(16),
					Right:  unit.Dp(16),
					Left:   unit.Dp(16),
					Bottom: unit.Dp(4),
				}.Layout(gtx, func(gtx C) D {
					gtx.Constraints.Min.X = minWidth
					dims := header(gtx)
					if w := dims.Size.X; w > maxWidth {
						maxWidth = w
					}
					return dims
				})
			}),
		}
		for i := 0; i < len(items); i++ {
			it := &items[i]
			children = append(children, layout.Rigid(func(gtx C) D {
				return material.Clickable(gtx, it.btn, func(gtx C) D {
					return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx C) D {
						gtx.Constraints.Min.X = minWidth
						dims := material.Body1(th, it.title).Layout(gtx)
						if w := dims.Size.X; w > maxWidth {
							maxWidth = w
						}
						return dims
					})
				})
			}))
		}
		f := layout.Flex{Axis: layout.Vertical}
		// First pass: record and discard operations
		// and determine widest item.
		m := op.Record(gtx.Ops)
		f.Layout(gtx, children...)
		m.Stop()
		// Second pass: layout items with equal width.
		minWidth = maxWidth
		return f.Layout(gtx, children...)
	})
}

func layoutDialog(gtx layout.Context, w layout.Widget) layout.Dimensions {
	return widget.Border{Color: argb(0x33000000), CornerRadius: unit.Dp(2), Width: unit.Px(1)}.Layout(gtx, func(gtx C) D {
		return Background{Color: rgb(0xfafafa), CornerRadius: unit.Dp(2)}.Layout(gtx, w)
	})
}

// layoutMenu lays out the menu activated by the 3 dots button.
func (ui *UI) layoutMenu(gtx layout.Context, sysIns system.Insets, expiry time.Time, showExits bool, needsLogin bool) {
	ui.menu.dismiss.Add(gtx, color.NRGBA{})
	if ui.menu.dismiss.Dismissed(gtx) {
		ui.menu.show = false
	}
	layout.Inset{
		Top:   unit.Add(gtx.Metric, sysIns.Top, unit.Dp(2)),
		Right: unit.Add(gtx.Metric, sysIns.Right, unit.Dp(2)),
	}.Layout(gtx, func(gtx C) D {
		return layout.NE.Layout(gtx, func(gtx C) D {
			menu := &ui.menu
			if ui.setLoginServer {
				return D{}
			}
			if needsLogin {
				items := []menuItem{
					{title: "Use login server", btn: &menu.useLoginServer},
				}
				return layoutMenu(ui.theme, gtx, items, func(gtx C) D {
					l := material.Caption(ui.theme, "Advanced settings")
					return l.Layout(gtx)
				})
			}
			items := []menuItem{
				{title: "Copy my IP address", btn: &menu.copy},
			}
			if showExits {
				items = append(items, menuItem{title: "Use exit node...", btn: &menu.exits})
			}
			items = append(items,
				menuItem{title: "Bug report", btn: &menu.bug},
				menuItem{title: "Reauthenticate", btn: &menu.reauth},
				menuItem{title: "Log out", btn: &menu.logout},
			)

			var title string
			if ui.runningExit {
				title = "Stop running exit node"
			} else {
				title = "Run exit node"
			}
			items = append(items, menuItem{title: title, btn: &menu.beExit})

			return layoutMenu(ui.theme, gtx, items, func(gtx C) D {
				var expiryStr string
				const fmtStr = time.Stamp
				switch {
				case expiry.IsZero():
					expiryStr = "Expires: (never)"
				case time.Now().After(expiry):
					expiryStr = fmt.Sprintf("Expired: %s", expiry.Format(fmtStr))
				default:
					expiryStr = fmt.Sprintf("Expires: %s", expiry.Format(fmtStr))
				}
				l := material.Caption(ui.theme, expiryStr)
				l.Color = rgb(0x8f8f8f)
				return l.Layout(gtx)
			})
		})
	})
}

func (ui *UI) layoutMessage(gtx layout.Context, sysIns system.Insets) layout.Dimensions {
	s := ui.message.text
	if s == "" {
		return D{}
	}
	now := gtx.Now
	d := now.Sub(ui.message.t0)
	rem := 4*time.Second - d
	if rem < 0 {
		return D{}
	}
	op.InvalidateOp{At: now.Add(rem)}.Add(gtx.Ops)
	return layout.S.Layout(gtx, func(gtx C) D {
		return layout.Inset{
			Left:   unit.Add(gtx.Metric, sysIns.Left, unit.Dp(8)),
			Right:  unit.Add(gtx.Metric, sysIns.Right, unit.Dp(8)),
			Bottom: unit.Add(gtx.Metric, sysIns.Bottom, unit.Dp(8)),
		}.Layout(gtx, func(gtx C) D {
			return Background{Color: rgb(0x323232), CornerRadius: unit.Dp(5)}.Layout(gtx, func(gtx C) D {
				return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx C) D {
					l := material.Body2(ui.theme, s)
					l.Color = rgb(0xdddddd)
					return l.Layout(gtx)
				})
			})
		})
	})
}

func (ui *UI) showMessage(gtx layout.Context, msg string) {
	ui.message.text = msg
	ui.message.t0 = gtx.Now
	op.InvalidateOp{}.Add(gtx.Ops)
}

// layoutPeer lays out a peer name and IP address (e.g.
// "localhost\n100.100.100.101")
func (ui *UI) layoutPeer(gtx layout.Context, sysIns system.Insets, p *UIPeer, user tailcfg.UserID, clk *widget.Clickable) layout.Dimensions {
	return material.Clickable(gtx, clk, func(gtx C) D {
		return layout.Inset{
			Top:    unit.Dp(8),
			Right:  unit.Max(gtx.Metric, sysIns.Right, unit.Dp(16)),
			Left:   unit.Max(gtx.Metric, sysIns.Left, unit.Dp(16)),
			Bottom: unit.Dp(8),
		}.Layout(gtx, func(gtx C) D {
			gtx.Constraints.Min.X = gtx.Constraints.Max.X
			return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
				layout.Rigid(func(gtx C) D {
					return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx C) D {
						name := p.Peer.DisplayName(p.Peer.User == user)
						return material.H6(ui.theme, name).Layout(gtx)
					})
				}),
				layout.Rigid(func(gtx C) D {
					var bestIP netaddr.IP // IP to show; first IPv4, or first IPv6 if no IPv4
					for _, addr := range p.Peer.Addresses {
						if ip := addr.IP(); bestIP.IsZero() || bestIP.Is6() && ip.Is4() {
							bestIP = ip
						}
					}
					l := material.Body2(ui.theme, bestIP.String())
					l.Color = rgb(0x434343)
					return l.Layout(gtx)
				}),
			)
		})
	})
}

// layoutSection lays out a section title (e.g. "My devices").
func (ui *UI) layoutSection(gtx layout.Context, sysIns system.Insets, title string) layout.Dimensions {
	return Background{Color: rgb(0xe1e0e9)}.Layout(gtx, func(gtx C) D {
		return layout.Inset{
			Top:    unit.Dp(16),
			Right:  unit.Max(gtx.Metric, sysIns.Right, unit.Dp(16)),
			Left:   unit.Max(gtx.Metric, sysIns.Left, unit.Dp(16)),
			Bottom: unit.Dp(16),
		}.Layout(gtx, func(gtx C) D {
			l := material.Body1(ui.theme, title)
			l.Color = rgb(0x6f797d)
			return l.Layout(gtx)
		})
	})
}

// layoutTop lays out the top controls: toggle, status and menu dots.
func (ui *UI) layoutTop(gtx layout.Context, sysIns system.Insets, state *BackendState) layout.Dimensions {
	in := layout.Inset{
		Top:    unit.Dp(16),
		Bottom: unit.Dp(16),
	}
	return Background{Color: rgb(headerColor)}.Layout(gtx, func(gtx C) D {
		return layout.Inset{
			Top:   sysIns.Top,
			Right: unit.Max(gtx.Metric, sysIns.Right, unit.Dp(8)),
			Left:  unit.Max(gtx.Metric, sysIns.Left, unit.Dp(16)),
		}.Layout(gtx, func(gtx C) D {
			return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
				layout.Rigid(func(gtx C) D {
					return in.Layout(gtx, func(gtx C) D {
						if state.State <= ipn.NeedsLogin {
							return D{}
						}
						sw := material.Switch(ui.theme, &ui.enabled, "Enable VPN")
						sw.Color.Enabled = rgb(white)
						if state.State < ipn.Stopped {
							sw.Color.Enabled = rgb(0xbbbbbb)
							sw.Color.Disabled = rgb(0xbbbbbb)
						}
						return sw.Layout(gtx)
					})
				}),
				layout.Flexed(1, func(gtx C) D {
					return in.Layout(gtx, func(gtx C) D {
						return layout.Inset{Left: unit.Dp(16)}.Layout(gtx, func(gtx C) D {
							lbl := material.Body1(ui.theme, statusString(state.State))
							lbl.Color = rgb(0xffffff)
							return lbl.Layout(gtx)
						})
					})
				}),
				layout.Rigid(func(gtx C) D {
					btn := material.IconButton(ui.theme, &ui.menu.open, ui.icons.more, "Open menu")
					btn.Color = rgb(white)
					btn.Background = color.NRGBA{}
					return btn.Layout(gtx)
				}),
			)
		})
	})
}

func statusString(state ipn.State) string {
	switch state {
	case ipn.Stopped:
		return "Stopped"
	case ipn.Starting:
		return "Starting..."
	case ipn.Running:
		return "Active"
	case ipn.NeedsMachineAuth:
		return "Awaiting Approval"
	case ipn.NeedsLogin:
		return "Tailscale"
	default:
		return "Loading..."
	}
}

func (ui *UI) showCopied(gtx layout.Context, addr string) {
	ui.showMessage(gtx, fmt.Sprintf("Copied %s", addr))
}

// layoutLocal lays out the information box about the local node's
// name and IP address.
func (ui *UI) layoutLocal(gtx layout.Context, sysIns system.Insets, host, addr string) layout.Dimensions {
	return Background{Color: rgb(headerColor)}.Layout(gtx, func(gtx C) D {
		return layout.Inset{
			Right:  unit.Max(gtx.Metric, sysIns.Right, unit.Dp(8)),
			Left:   unit.Max(gtx.Metric, sysIns.Left, unit.Dp(8)),
			Bottom: unit.Dp(8),
		}.Layout(gtx, func(gtx C) D {
			return Background{Color: rgb(infoColor), CornerRadius: unit.Dp(8)}.Layout(gtx, func(gtx C) D {
				return material.Clickable(gtx, &ui.self, func(gtx C) D {
					return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx C) D {
						gtx.Constraints.Min.X = gtx.Constraints.Max.X
						return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
							layout.Rigid(func(gtx C) D {
								return layout.Inset{Bottom: unit.Dp(4)}.Layout(gtx, func(gtx C) D {
									name := material.H6(ui.theme, host)
									name.Color = rgb(0xffffff)
									return name.Layout(gtx)
								})
							}),
							layout.Rigid(func(gtx C) D {
								name := material.Body2(ui.theme, addr)
								name.Color = rgb(0xc5ccd9)
								return name.Layout(gtx)
							}),
						)
					})
				})
			})
		})
	})
}

func (ui *UI) layoutSearchbar(gtx layout.Context, sysIns system.Insets) layout.Dimensions {
	return Background{Color: rgb(0xf0eff6)}.Layout(gtx, func(gtx C) D {
		return layout.Inset{
			Top:    unit.Dp(8),
			Right:  unit.Max(gtx.Metric, sysIns.Right, unit.Dp(8)),
			Left:   unit.Max(gtx.Metric, sysIns.Left, unit.Dp(8)),
			Bottom: unit.Dp(8),
		}.Layout(gtx, func(gtx C) D {
			return Background{Color: rgb(0xe3e2ea), CornerRadius: unit.Dp(8)}.Layout(gtx, func(gtx C) D {
				return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx C) D {
					return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
						layout.Rigid(func(gtx C) D {
							col := mulAlpha(ui.theme.Palette.Fg, 0xbb)
							return ui.icons.search.Layout(gtx, col)
						}),
						layout.Flexed(1,
							material.Editor(ui.theme, &ui.search, "Search by machine name...").Layout,
						),
					)
				})
			})
		})
	})
}

// drawLogo draws the Tailscale logo using vector operations.
func drawLogo(ops *op.Ops, size int) {
	scale := float32(size) / 680
	discDia := 170 * scale
	off := 172 * 1.5 * scale
	tx := op.Offset(f32.Pt(off, 0))
	ty := op.Offset(f32.Pt(0, off))

	defer op.Offset(f32.Point{}).Push(ops).Pop()

	// First row of discs.
	row := op.Offset(f32.Point{}).Push(ops)
	drawDisc(ops, discDia, rgb(0x54514d))
	tx.Add(ops)
	drawDisc(ops, discDia, rgb(0x54514d))
	tx.Add(ops)
	drawDisc(ops, discDia, rgb(0x54514d))
	row.Pop()

	ty.Add(ops)
	// Second row.
	row = op.Offset(f32.Point{}).Push(ops)
	drawDisc(ops, discDia, rgb(0xfffdfa))
	tx.Add(ops)
	drawDisc(ops, discDia, rgb(0xfffdfa))
	tx.Add(ops)
	drawDisc(ops, discDia, rgb(0xfffdfa))
	row.Pop()

	ty.Add(ops)
	// Third row.
	row = op.Offset(f32.Point{}).Push(ops)
	drawDisc(ops, discDia, rgb(0xfffdfa))
	drawDisc(ops, discDia, rgb(0x54514d))
	tx.Add(ops)
	drawDisc(ops, discDia, rgb(0xfffdfa))
	tx.Add(ops)
	drawDisc(ops, discDia, rgb(0x54514d))
	row.Pop()
}

func drawImage(gtx layout.Context, img paint.ImageOp, size unit.Value) layout.Dimensions {
	img.Add(gtx.Ops)
	sz := img.Size()
	aspect := float32(sz.Y) / float32(sz.X)
	w := gtx.Px(size)
	h := int(float32(w)*aspect + .5)
	scale := float32(w) / float32(sz.X)
	defer op.Affine(f32.Affine2D{}.Scale(f32.Point{}, f32.Point{X: scale, Y: scale})).Push(gtx.Ops).Pop()
	paint.PaintOp{}.Add(gtx.Ops)
	return layout.Dimensions{Size: image.Pt(w, h)}
}

func drawDisc(ops *op.Ops, radius float32, col color.NRGBA) {
	r2 := radius * .5
	dr := f32.Rectangle{Max: f32.Pt(radius, radius)}
	defer clip.RRect{
		Rect: dr,
		NE:   r2, NW: r2, SE: r2, SW: r2,
	}.Push(ops).Pop()
	paint.ColorOp{Color: col}.Add(ops)
	paint.PaintOp{}.Add(ops)
}

// Background lays out a widget and draws a color background behind it.
type Background struct {
	Color        color.NRGBA
	CornerRadius unit.Value
}

func (b Background) Layout(gtx layout.Context, w layout.Widget) layout.Dimensions {
	m := op.Record(gtx.Ops)
	dims := w(gtx)
	sz := dims.Size
	call := m.Stop()
	// Clip corners, if any.
	if r := gtx.Px(b.CornerRadius); r > 0 {
		rr := float32(r)
		defer clip.RRect{
			Rect: f32.Rectangle{Max: f32.Point{
				X: float32(sz.X),
				Y: float32(sz.Y),
			}},
			NE: rr, NW: rr, SE: rr, SW: rr,
		}.Push(gtx.Ops).Pop()
	}
	fill{b.Color}.Layout(gtx, sz)
	call.Add(gtx.Ops)
	return dims
}

type fill struct {
	col color.NRGBA
}

func (f fill) Layout(gtx layout.Context, sz image.Point) layout.Dimensions {
	defer clip.Rect(image.Rectangle{Max: sz}).Push(gtx.Ops).Pop()
	paint.ColorOp{Color: f.col}.Add(gtx.Ops)
	paint.PaintOp{}.Add(gtx.Ops)
	return layout.Dimensions{Size: sz}
}

func rgb(c uint32) color.NRGBA {
	return argb((0xff << 24) | c)
}

func argb(c uint32) color.NRGBA {
	return color.NRGBA{A: uint8(c >> 24), R: uint8(c >> 16), G: uint8(c >> 8), B: uint8(c)}
}

const termsText = `Tailscale is a mesh VPN for securely connecting your devices. All connections are device-to-device, so we never see your data.

We collect and use your email address and name, as well as your device name, OS version, and IP address in order to help you to connect your devices and manage your settings. We log when you are connected to your network.`


================================================
FILE: flake.nix
================================================
{
  description = "Tailscale build environment";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs";
    android.url = "github:tadfisher/android-nixpkgs";
    android.inputs.nixpkgs.follows = "nixpkgs";
  };

  outputs = { self, nixpkgs, android }:
    let
      supportedSystems = [ "x86_64-linux" "x86_64-darwin" "aarch64-darwin" ];
      forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f system);
    in
    {
      devShells = forAllSystems
        (system:
          let
            pkgs = import nixpkgs {
              inherit system;
            };
            android-sdk = android.sdk.${system} (sdkPkgs: with sdkPkgs;
              [
                build-tools-30-0-2
                cmdline-tools-latest
                platform-tools
                platforms-android-31
                platforms-android-30
                ndk-23-1-7779620
                patcher-v4
              ]);
          in
          {
            default = (with pkgs; buildFHSUserEnv {
              name = "tailscale";
              profile = ''
                export ANDROID_SDK_ROOT="${android-sdk}/share/android-sdk"
                export JAVA_HOME="${jdk8.home}"
              '';
              targetPkgs = pkgs: with pkgs; [
                android-sdk
                jdk8
                clang
              ] ++ (if stdenv.isLinux then [
                vulkan-headers
                libxkbcommon
                wayland
                xorg.libX11
                xorg.libXcursor
                xorg.libXfixes
                libGL
                pkgconfig
              ] else [ ]);
            }).env;
          }
        );
    };
}


================================================
FILE: go.mod
================================================
module github.com/tailscale/tailscale-android

go 1.18

require (
	eliasnaur.com/font v0.0.0-20220124212145-832bb8fc08c3
	gioui.org v0.0.0-20220331105829-a1b5ff059c07
	gioui.org/cmd v0.0.0-20210925100615-41f3a7e74ee6
	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
	golang.org/x/exp v0.0.0-20210722180016-6781d3edade3
	golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d
	golang.zx2c4.com/wireguard v0.0.0-20220703234212-c31a7b1ab478
	inet.af/netaddr v0.0.0-20220617031823-097006376321
	tailscale.com v1.1.1-0.20220718172352-3c892d106c2e
)

require (
	gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 // indirect
	gioui.org/shader v1.0.6 // indirect
	github.com/akavel/rsrc v0.10.1 // indirect
	github.com/akutz/memconn v0.1.0 // indirect
	github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 // indirect
	github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
	github.com/benoitkugler/textlayout v0.0.10 // indirect
	github.com/coreos/go-iptables v0.6.0 // indirect
	github.com/creack/pty v1.1.17 // indirect
	github.com/gioui/uax v0.2.1-0.20220325163150-e3d987515a12 // indirect
	github.com/go-ole/go-ole v1.2.6 // indirect
	github.com/go-text/typesetting v0.0.0-20220112121102-58fe93c84506 // indirect
	github.com/godbus/dbus/v5 v5.0.6 // indirect
	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
	github.com/google/btree v1.0.1 // indirect
	github.com/google/go-cmp v0.5.8 // indirect
	github.com/insomniacslk/dhcp v0.0.0-20211209223715-7d93572ebe8e // indirect
	github.com/josharian/native v1.0.0 // indirect
	github.com/jsimonetti/rtnetlink v1.1.2-0.20220408201609-d380b505068b // indirect
	github.com/klauspost/compress v1.15.4 // indirect
	github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect
	github.com/mdlayher/genetlink v1.2.0 // indirect
	github.com/mdlayher/netlink v1.6.0 // indirect
	github.com/mdlayher/sdnotify v1.0.0 // indirect
	github.com/mdlayher/socket v0.2.3 // indirect
	github.com/mitchellh/go-ps v1.0.0 // indirect
	github.com/pkg/errors v0.9.1 // indirect
	github.com/tailscale/certstore v0.1.1-0.20220316223106-78d6e1c49d8d // indirect
	github.com/tailscale/golang-x-crypto v0.0.0-20220428210705-0b941c09a5e1 // indirect
	github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect
	github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 // indirect
	github.com/tcnksm/go-httpstat v0.2.0 // indirect
	github.com/u-root/u-root v0.8.0 // indirect
	github.com/u-root/uio v0.0.0-20210528151154-e40b768296a7 // indirect
	github.com/vishvananda/netlink v1.1.1-0.20211118161826-650dca95af54 // indirect
	github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 // indirect
	go4.org/intern v0.0.0-20211027215823-ae77deb06f29 // indirect
	go4.org/mem v0.0.0-20210711025021-927187094b94 // indirect
	go4.org/unsafe/assume-no-moving-gc v0.0.0-20220617031537-928513b29760 // indirect
	golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f // indirect
	golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d // indirect
	golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
	golang.org/x/net v0.0.0-20220607020251-c690dde0001d // indirect
	golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f // indirect
	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
	golang.org/x/text v0.3.7 // indirect
	golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 // indirect
	golang.org/x/tools v0.1.11 // indirect
	golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f // indirect
	golang.zx2c4.com/wintun v0.0.0-20211104114900-415007cec224 // indirect
	golang.zx2c4.com/wireguard/windows v0.4.10 // indirect
	gvisor.dev/gvisor v0.0.0-20220407223209-21871174d445 // indirect
	nhooyr.io/websocket v1.8.7 // indirect
)


================================================
FILE: go.sum
================================================
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
eliasnaur.com/font v0.0.0-20220124212145-832bb8fc08c3 h1:djFprmHZgrSepsHAIRMp5UJn3PzsoTg9drI+BDmif5Q=
eliasnaur.com/font v0.0.0-20220124212145-832bb8fc08c3/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
filippo.io/mkcert v1.4.3 h1:axpnmtrZMM8u5Hf4N3UXxboGemMOV+Tn+e+pkHM6E3o=
gioui.org v0.0.0-20210910062418-d5d0a75a9bcb/go.mod h1:BTldRXnY5mrUrYZCdWyDwyMzyUzpfZN1cF4MMRrOt9w=
gioui.org v0.0.0-20220331105829-a1b5ff059c07 h1:9BdOhoKqRiItOl87wZfQUL2YZNm+6Yyp1f1iEiJUh+U=
gioui.org v0.0.0-20220331105829-a1b5ff059c07/go.mod h1:b8vBukexG6eYuXZa14asjLAWJ+JjbZ/ophEnS2FjYUg=
gioui.org/cmd v0.0.0-20210925100615-41f3a7e74ee6 h1:SkAdohDhTUjl+ZtM417Xeu+uFd7SQubwR9uAyqJqC8c=
gioui.org/cmd v0.0.0-20210925100615-41f3a7e74ee6/go.mod h1:qrH3h4nt/PyIqx/XabL/eJ5cXQnQ0ERHqC3VEXx/Rmg=
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 h1:AGDDxsJE1RpcXTAxPG2B4jrwVUJGFDjINIPi1jtO6pc=
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.2/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
gioui.org/shader v1.0.6 h1:cvZmU+eODFR2545X+/8XucgZdTtEjR3QWW6W65b0q5Y=
gioui.org/shader v1.0.6/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw=
github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
github.com/akavel/rsrc v0.10.1 h1:hCCPImjmFKVNGpeLZyTDRHEFC283DzyTXTo0cO0Rq9o=
github.com/akavel/rsrc v0.10.1/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA=
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU=
github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
github.com/benoitkugler/pstokenizer v1.0.0/go.mod h1:l1G2Voirz0q/jj0TQfabNxVsa8HZXh/VMxFSRALWTiE=
github.com/benoitkugler/textlayout v0.0.5/go.mod h1:puH4v13Uz7uIhIH0XMk5jgc8U3MXcn5r3VlV9K8n0D8=
github.com/benoitkugler/textlayout v0.0.10 h1:uIaQgH4pBFw1LQ0tPkfjgxo94WYcckzzQaB41L2X84w=
github.com/benoitkugler/textlayout v0.0.10/go.mod h1:puH4v13Uz7uIhIH0XMk5jgc8U3MXcn5r3VlV9K8n0D8=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4 h1:QD3KxSJ59L2lxG6MXBjNHxiQO2RmxTQ3XcK+wO44WOg=
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g=
github.com/chromedp/chromedp v0.5.2 h1:W8xBXQuUnd2dZK0SN/lyVwsQM7KgW+kY5HGnntms194=
github.com/chromedp/chromedp v0.5.2/go.mod h1:rsTo/xRo23KZZwFmWk2Ui79rBaVRRATCjLzNQlOFSiA=
github.com/cilium/ebpf v0.5.0/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs=
github.com/cilium/ebpf v0.7.0 h1:1k/q3ATgxSXRdrmPfH8d7YK0GfqVsEKZAX9dQZvs56k=
github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2usCA=
github.com/cilium/ebpf v0.8.1 h1:bLSSEbBLqGPXxls55pGr5qWZaTqcmfDJHhou7t254ao=
github.com/cilium/ebpf v0.8.1/go.mod h1:f5zLIM0FSNuAkSyLAN7X+Hy6yznlF1mNiWUMfxMtrgk=
github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21/go.mod h1:po7NpZ/QiTKzBKyrsEAxwnTamCoh8uDk/egRpQ7siIc=
github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
github.com/coreos/go-iptables v0.6.0 h1:is9qnZMPYjLd8LYqmm/qlE+wwEgJIkTYdhV3rfZo4jk=
github.com/coreos/go-iptables v0.6.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dvyukov/go-fuzz v0.0.0-20210103155950-6a8e9d1f2415/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fanliao/go-promise v0.0.0-20141029170127-1890db352a72/go.mod h1:PjfxuH4FZdUyfMdtBio2lsRr1AKEaVPwelzuHuh8Lqc=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k=
github.com/frankban/quicktest v1.14.0 h1:+cqqvzZV87b4adx/5ayVOaYZ2CrvM4ejQvUdBzPPUss=
github.com/frankban/quicktest v1.14.0/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09mUdL/ijj8og=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnV
Download .txt
gitextract_xph_jeqj/

├── .github/
│   └── workflows/
│       └── main.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── Makefile
├── PATENTS
├── README.md
├── android/
│   ├── build.gradle
│   ├── gradle/
│   │   └── wrapper/
│   │       ├── gradle-wrapper.jar
│   │       └── gradle-wrapper.properties
│   ├── gradle.properties
│   ├── gradlew
│   ├── gradlew.bat
│   └── src/
│       ├── main/
│       │   ├── AndroidManifest.xml
│       │   ├── java/
│       │   │   └── com/
│       │   │       └── tailscale/
│       │   │           └── ipn/
│       │   │               ├── App.java
│       │   │               ├── DnsConfig.java
│       │   │               ├── IPNActivity.java
│       │   │               ├── IPNService.java
│       │   │               ├── Peer.java
│       │   │               └── QuickToggleService.java
│       │   └── res/
│       │       ├── drawable/
│       │       │   ├── ic_launcher_foreground.xml
│       │       │   └── ic_tile.xml
│       │       ├── mipmap-anydpi-v26/
│       │       │   ├── ic_launcher.xml
│       │       │   └── ic_launcher_round.xml
│       │       └── values/
│       │           ├── ic_launcher_background.xml
│       │           └── strings.xml
│       ├── play/
│       │   └── java/
│       │       └── com/
│       │           └── tailscale/
│       │               └── ipn/
│       │                   └── Google.java
│       └── test/
│           └── java/
│               └── com/
│                   └── tailscale/
│                       └── ipn/
│                           └── DnsConfigTest.java
├── cmd/
│   └── tailscale/
│       ├── backend.go
│       ├── callbacks.go
│       ├── main.go
│       ├── multitun.go
│       ├── pprof.go
│       ├── store.go
│       ├── tools.go
│       └── ui.go
├── flake.nix
├── go.mod
├── go.sum
├── jni/
│   └── jni.go
├── metadata/
│   └── en-US/
│       ├── full_description.txt
│       └── short_description.txt
└── version/
    └── tailscale-version.sh
Download .txt
SYMBOL INDEX (322 symbols across 16 files)

FILE: android/src/main/java/com/tailscale/ipn/App.java
  class App (line 73) | public class App extends Application {
    method getDnsConfigObj (line 88) | public DnsConfig getDnsConfigObj() { return this.dns; }
    method onCreate (line 90) | @Override public void onCreate() {
    method registerNetworkCallback (line 102) | private void registerNetworkCallback() {
    method startVPN (line 126) | public void startVPN() {
    method stopVPN (line 132) | public void stopVPN() {
    method encryptToPref (line 140) | public void encryptToPref(String prefKey, String plaintext) throws IOE...
    method decryptFromPref (line 146) | public String decryptFromPref(String prefKey) throws IOException, Gene...
    method getEncryptedPrefs (line 150) | private SharedPreferences getEncryptedPrefs() throws IOException, Gene...
    method setTileReady (line 164) | void setTileReady(boolean ready) {
    method setTileStatus (line 171) | void setTileStatus(boolean status) {
    method getHostname (line 178) | String getHostname() {
    method getModelName (line 185) | String getModelName() {
    method getOSVersion (line 197) | String getOSVersion() {
    method getUserConfiguredDeviceName (line 203) | private String getUserConfiguredDeviceName() {
    method isEmpty (line 214) | private static boolean isEmpty(String str) {
    method attachPeer (line 220) | void attachPeer(Activity act) {
    method isChromeOS (line 231) | boolean isChromeOS() {
    method prepareVPN (line 235) | void prepareVPN(Activity act, int reqCode) {
    method startActivityForResult (line 248) | static void startActivityForResult(Activity act, Intent intent, int re...
    method showURL (line 253) | void showURL(Activity act, String url) {
    method getPackageCertificate (line 266) | byte[] getPackageCertificate() throws Exception {
    method requestWriteStoragePermission (line 275) | void requestWriteStoragePermission(Activity act) {
    method insertMedia (line 286) | String insertMedia(String name, String mimeType) throws IOException {
    method openUri (line 304) | int openUri(String uri, String mode) throws IOException {
    method deleteUri (line 309) | void deleteUri(String uri) {
    method notifyFile (line 314) | public void notifyFile(String uri, String msg) {
    method createNotificationChannel (line 336) | private void createNotificationChannel(String id, String name, int imp...
    method onVPNPrepared (line 345) | static native void onVPNPrepared();
    method onConnectivityChanged (line 346) | private static native void onConnectivityChanged(boolean connected);
    method onShareIntent (line 347) | static native void onShareIntent(int nfiles, int[] types, String[] mim...
    method onWriteStorageGranted (line 348) | static native void onWriteStorageGranted();
    method getInterfacesAsString (line 366) | String getInterfacesAsString() {
    method isTV (line 400) | boolean isTV() {

FILE: android/src/main/java/com/tailscale/ipn/DnsConfig.java
  class DnsConfig (line 88) | public class DnsConfig {
    method DnsConfig (line 91) | public DnsConfig(Context ctx) {
    method getDnsConfigAsString (line 104) | String getDnsConfigAsString() {
    method getDnsConfigFromLinkProperties (line 142) | String getDnsConfigFromLinkProperties() {
    method getDnsServersFromSystemProperties (line 212) | String getDnsServersFromSystemProperties() {
    method intToInetString (line 232) | public String intToInetString(int hostAddress) {
    method getDnsServersFromNetworkInfo (line 251) | String getDnsServersFromNetworkInfo() {
    method getPreferabilityForNetwork (line 340) | int getPreferabilityForNetwork(ConnectivityManager cMgr, Network netwo...

FILE: android/src/main/java/com/tailscale/ipn/IPNActivity.java
  class IPNActivity (line 22) | public final class IPNActivity extends Activity {
    method onCreate (line 27) | @Override public void onCreate(Bundle state) {
    method onNewIntent (line 34) | @Override public void onNewIntent(Intent i) {
    method handleIntent (line 39) | private void handleIntent() {
    method onRequestPermissionsResult (line 95) | @Override public void onRequestPermissionsResult(int reqCode, String[]...
    method onDestroy (line 104) | @Override public void onDestroy() {
    method onStart (line 109) | @Override public void onStart() {
    method onStop (line 114) | @Override public void onStop() {
    method onConfigurationChanged (line 119) | @Override public void onConfigurationChanged(Configuration c) {
    method onLowMemory (line 124) | @Override public void onLowMemory() {
    method onBackPressed (line 129) | @Override public void onBackPressed() {

FILE: android/src/main/java/com/tailscale/ipn/IPNService.java
  class IPNService (line 19) | public class IPNService extends VpnService {
    method onStartCommand (line 23) | @Override public int onStartCommand(Intent intent, int flags, int star...
    method close (line 32) | private void close() {
    method onDestroy (line 37) | @Override public void onDestroy() {
    method onRevoke (line 42) | @Override public void onRevoke() {
    method configIntent (line 47) | private PendingIntent configIntent() {
    method disallowApp (line 51) | private void disallowApp(VpnService.Builder b, String name) {
    method newBuilder (line 59) | protected VpnService.Builder newBuilder() {
    method notify (line 81) | public void notify(String title, String message) {
    method updateStatusNotification (line 95) | public void updateStatusNotification(String title, String message) {
    method connect (line 106) | private native void connect();
    method disconnect (line 107) | private native void disconnect();

FILE: android/src/main/java/com/tailscale/ipn/Peer.java
  class Peer (line 11) | public class Peer extends Fragment {
    method onActivityResult (line 12) | @Override public void onActivityResult(int requestCode, int resultCode...
    method onActivityResult0 (line 16) | private static native void onActivityResult0(Activity act, int reqCode...

FILE: android/src/main/java/com/tailscale/ipn/QuickToggleService.java
  class QuickToggleService (line 16) | public class QuickToggleService extends TileService {
    method onStartListening (line 27) | @Override public void onStartListening() {
    method onStopListening (line 34) | @Override public void onStopListening() {
    method onClick (line 40) | @Override public void onClick() {
    method updateTile (line 54) | private static void updateTile() {
    method setReady (line 68) | static void setReady(Context ctx, boolean rdy) {
    method setStatus (line 75) | static void setStatus(Context ctx, boolean act) {
    method onTileClick (line 82) | private static native void onTileClick();

FILE: android/src/play/java/com/tailscale/ipn/Google.java
  class Google (line 17) | public final class Google {
    method getIdTokenForActivity (line 18) | static String getIdTokenForActivity(Activity act) {
    method googleSignIn (line 23) | static void googleSignIn(Activity act, String serverOAuthID, int reqCo...
    method googleSignOut (line 37) | static void googleSignOut(Context ctx) {

FILE: android/src/test/java/com/tailscale/ipn/DnsConfigTest.java
  class DnsConfigTest (line 7) | public class DnsConfigTest {
    method setup (line 10) | @Before
    method dnsConfig_intToInetStringTest (line 15) | @Test

FILE: cmd/tailscale/backend.go
  type backend (line 36) | type backend struct
    method Start (line 149) | func (b *backend) Start(notify func(n ipn.Notify)) error {
    method LinkChange (line 156) | func (b *backend) LinkChange() {
    method setCfg (line 162) | func (b *backend) setCfg(rcfg *router.Config, dcfg *dns.OSConfig) error {
    method updateTUN (line 166) | func (b *backend) updateTUN(service jni.Object, rcfg *router.Config, d...
    method CloseTUNs (line 302) | func (b *backend) CloseTUNs() {
    method SetupLogs (line 308) | func (b *backend) SetupLogs(logDir string, logID logtail.PrivateID) {
    method logDNSConfigMechanisms (line 364) | func (b *backend) logDNSConfigMechanisms() {
    method getPlatformDNSConfig (line 395) | func (b *backend) getPlatformDNSConfig() string {
    method getDNSBaseConfig (line 417) | func (b *backend) getDNSBaseConfig() (dns.OSConfig, error) {
  type settingsFunc (line 54) | type settingsFunc
  constant defaultMTU (line 56) | defaultMTU = 1280
  constant logPrefKey (line 59) | logPrefKey               = "privatelogid"
  constant loginMethodPrefKey (line 60) | loginMethodPrefKey       = "loginmethod"
  constant customLoginServerPrefKey (line 61) | customLoginServerPrefKey = "customloginserver"
  constant loginMethodGoogle (line 65) | loginMethodGoogle = "google"
  constant loginMethodWeb (line 66) | loginMethodWeb    = "web"
  function newBackend (line 81) | func newBackend(dataDir string, jvm *jni.JVM, appCtx jni.Object, store *...

FILE: cmd/tailscale/callbacks.go
  constant requestSignin (line 50) | requestSignin C.jint = 1000 + iota
  constant requestPrepareVPN (line 53) | requestPrepareVPN
  constant resultOK (line 57) | resultOK = -1
  function Java_com_tailscale_ipn_App_onVPNPrepared (line 60) | func Java_com_tailscale_ipn_App_onVPNPrepared(env *C.JNIEnv, class C.jcl...
  function Java_com_tailscale_ipn_App_onWriteStorageGranted (line 65) | func Java_com_tailscale_ipn_App_onWriteStorageGranted(env *C.JNIEnv, cla...
  function notifyVPNPrepared (line 72) | func notifyVPNPrepared() {
  function notifyVPNRevoked (line 79) | func notifyVPNRevoked() {
  function notifyVPNClosed (line 86) | func notifyVPNClosed() {
  function Java_com_tailscale_ipn_IPNService_connect (line 94) | func Java_com_tailscale_ipn_IPNService_connect(env *C.JNIEnv, this C.job...
  function Java_com_tailscale_ipn_IPNService_disconnect (line 100) | func Java_com_tailscale_ipn_IPNService_disconnect(env *C.JNIEnv, this C....
  function Java_com_tailscale_ipn_App_onConnectivityChanged (line 106) | func Java_com_tailscale_ipn_App_onConnectivityChanged(env *C.JNIEnv, cls...
  function Java_com_tailscale_ipn_QuickToggleService_onTileClick (line 115) | func Java_com_tailscale_ipn_QuickToggleService_onTileClick(env *C.JNIEnv...
  function Java_com_tailscale_ipn_Peer_onActivityResult0 (line 120) | func Java_com_tailscale_ipn_Peer_onActivityResult0(env *C.JNIEnv, cls C....
  function Java_com_tailscale_ipn_App_onShareIntent (line 148) | func Java_com_tailscale_ipn_App_onShareIntent(env *C.JNIEnv, cls C.jclas...

FILE: cmd/tailscale/main.go
  type App (line 48) | type App struct
    method runBackend (line 248) | func (a *App) runBackend() error {
    method processWaitingFiles (line 524) | func (a *App) processWaitingFiles(b *ipnlocal.LocalBackend) error {
    method downloadFile (line 538) | func (a *App) downloadFile(b *ipnlocal.LocalBackend, f apitype.Waiting...
    method openURI (line 591) | func (a *App) openURI(uri, mode string) (*os.File, error) {
    method isChromeOS (line 608) | func (a *App) isChromeOS() bool {
    method hostname (line 675) | func (a *App) hostname() string {
    method osVersion (line 692) | func (a *App) osVersion() string {
    method modelName (line 709) | func (a *App) modelName() string {
    method updateNotification (line 729) | func (a *App) updateNotification(service jni.Object, state ipn.State) ...
    method notifyExpiry (line 751) | func (a *App) notifyExpiry(service jni.Object, expiry time.Time) *time...
    method notifyFile (line 781) | func (a *App) notifyFile(uri, msg string) error {
    method pushNotify (line 791) | func (a *App) pushNotify(service jni.Object, title, msg string) error {
    method notify (line 801) | func (a *App) notify(state BackendState) {
    method setPrefs (line 813) | func (a *App) setPrefs(prefs *ipn.Prefs) {
    method setURL (line 825) | func (a *App) setURL(url string) {
    method runUI (line 833) | func (a *App) runUI() error {
    method isTV (line 966) | func (a *App) isTV() bool {
    method isReleaseSigned (line 983) | func (a *App) isReleaseSigned() bool {
    method attachPeer (line 1009) | func (a *App) attachPeer(act jni.Object) {
    method updateState (line 1016) | func (a *App) updateState(act jni.Object, state *clientState) {
    method prepareVPN (line 1086) | func (a *App) prepareVPN(act jni.Object) error {
    method processUIEvents (line 1097) | func (a *App) processUIEvents(w *app.Window, events []UIEvent, act jni...
    method sendFiles (line 1144) | func (a *App) sendFiles(e FileSendEvent, files []File) {
    method invalidate (line 1180) | func (a *App) invalidate() {
    method sendFile (line 1187) | func (a *App) sendFile(ctx context.Context, target *apitype.FileTarget...
    method signOut (line 1243) | func (a *App) signOut() {
    method googleSignIn (line 1257) | func (a *App) googleSignIn(act jni.Object) {
    method browseToURL (line 1273) | func (a *App) browseToURL(act jni.Object, url string) {
    method callVoidMethod (line 1286) | func (a *App) callVoidMethod(obj jni.Object, name, sig string, args .....
    method contextForView (line 1299) | func (a *App) contextForView(view jni.Object) jni.Object {
    method getInterfaces (line 1319) | func (a *App) getInterfaces() ([]interfaces.Interface, error) {
  type FileTargets (line 73) | type FileTargets struct
  type File (line 78) | type File struct
  type FileSendInfo (line 90) | type FileSendInfo struct
  type clientState (line 97) | type clientState struct
  type FileType (line 106) | type FileType
  constant FileTypeText (line 110) | FileTypeText FileType = 1
  constant FileTypeURI (line 111) | FileTypeURI  FileType = 2
  type ExitStatus (line 114) | type ExitStatus
  constant ExitNone (line 118) | ExitNone ExitStatus = iota
  constant ExitOffline (line 120) | ExitOffline
  constant ExitOnline (line 122) | ExitOnline
  type FileSendState (line 125) | type FileSendState
  constant FileSendNotStarted (line 128) | FileSendNotStarted FileSendState = iota
  constant FileSendConnecting (line 129) | FileSendConnecting
  constant FileSendTransferring (line 130) | FileSendTransferring
  constant FileSendComplete (line 131) | FileSendComplete
  constant FileSendFailed (line 132) | FileSendFailed
  type Peer (line 135) | type Peer struct
  type BackendState (line 141) | type BackendState struct
    method updateExitNodes (line 623) | func (s *BackendState) updateExitNodes() {
  type UIEvent (line 155) | type UIEvent interface
  type RouteAllEvent (line 157) | type RouteAllEvent struct
  type ConnectEvent (line 161) | type ConnectEvent struct
  type CopyEvent (line 165) | type CopyEvent struct
  type SearchEvent (line 169) | type SearchEvent struct
  type OAuth2Event (line 173) | type OAuth2Event struct
  type FileSendEvent (line 177) | type FileSendEvent struct
  type SetLoginServerEvent (line 183) | type SetLoginServerEvent struct
  type ToggleEvent (line 189) | type ToggleEvent struct
  type ReauthEvent (line 190) | type ReauthEvent struct
  type BugEvent (line 191) | type BugEvent struct
  type WebAuthEvent (line 192) | type WebAuthEvent struct
  type GoogleAuthEvent (line 193) | type GoogleAuthEvent struct
  type LogoutEvent (line 194) | type LogoutEvent struct
  type BeExitNodeEvent (line 195) | type BeExitNodeEvent
  type ExitAllowLANEvent (line 196) | type ExitAllowLANEvent
  constant serverOAuthID (line 201) | serverOAuthID = "744055068597-hv4opg0h7vskq1hv37nq3u26t8c15qk0.apps.goog...
  constant releaseCertFingerprint (line 205) | releaseCertFingerprint = "86:9D:11:8B:63:1E:F8:35:C6:D9:C2:66:53:BC:28:2...
  function main (line 210) | func main() {
  function googleSignInEnabled (line 724) | func googleSignInEnabled() bool {
  function requestBackend (line 1091) | func requestBackend(e UIEvent) {
  type progressReader (line 1222) | type progressReader struct
    method Read (line 1230) | func (r *progressReader) Read(p []byte) (int, error) {
  function fatalErr (line 1398) | func fatalErr(err error) {
  function randHex (line 1403) | func randHex(n int) string {

FILE: cmd/tailscale/multitun.go
  type multiTUN (line 17) | type multiTUN struct
    method run (line 84) | func (d *multiTUN) run() {
    method readFrom (line 172) | func (d *multiTUN) readFrom(dev *tunDevice) {
    method runDevice (line 199) | func (d *multiTUN) runDevice(dev *tunDevice) {
    method add (line 235) | func (d *multiTUN) add(dev tun.Device) {
    method File (line 239) | func (d *multiTUN) File() *os.File {
    method Read (line 245) | func (d *multiTUN) Read(data []byte, offset int) (int, error) {
    method Write (line 252) | func (d *multiTUN) Write(data []byte, offset int) (int, error) {
    method Flush (line 259) | func (d *multiTUN) Flush() error {
    method MTU (line 265) | func (d *multiTUN) MTU() (int, error) {
    method Name (line 272) | func (d *multiTUN) Name() (string, error) {
    method Events (line 279) | func (d *multiTUN) Events() chan tun.Event {
    method Shutdown (line 283) | func (d *multiTUN) Shutdown() {
    method Close (line 288) | func (d *multiTUN) Close() error {
  type tunDevice (line 36) | type tunDevice struct
  type ioRequest (line 45) | type ioRequest struct
  type ioReply (line 51) | type ioReply struct
  type mtuReply (line 56) | type mtuReply struct
  type nameReply (line 61) | type nameReply struct
  function newTUNDevices (line 66) | func newTUNDevices() *multiTUN {

FILE: cmd/tailscale/pprof.go
  function init (line 15) | func init() {

FILE: cmd/tailscale/store.go
  type stateStore (line 18) | type stateStore struct
    method ReadString (line 52) | func (s *stateStore) ReadString(key string, def string) (string, error) {
    method WriteString (line 63) | func (s *stateStore) WriteString(key string, val string) error {
    method ReadBool (line 67) | func (s *stateStore) ReadBool(key string, def bool) (bool, error) {
    method WriteBool (line 78) | func (s *stateStore) WriteBool(key string, val bool) error {
    method ReadState (line 86) | func (s *stateStore) ReadState(id ipn.StateKey) ([]byte, error) {
    method WriteState (line 97) | func (s *stateStore) WriteState(id ipn.StateKey, bs []byte) error {
    method read (line 102) | func (s *stateStore) read(key string) ([]byte, error) {
    method write (line 121) | func (s *stateStore) write(key string, value []byte) error {
  function newStateStore (line 28) | func newStateStore(jvm *jni.JVM, appCtx jni.Object) *stateStore {
  function prefKeyFor (line 48) | func prefKeyFor(id ipn.StateKey) string {

FILE: cmd/tailscale/ui.go
  type UI (line 42) | type UI struct
    method onBack (line 258) | func (ui *UI) onBack() bool {
    method activeDialog (line 267) | func (ui *UI) activeDialog() *bool {
    method layout (line 281) | func (ui *UI) layout(gtx layout.Context, sysIns system.Insets, state *...
    method layoutQR (line 531) | func (ui *UI) layoutQR(gtx layout.Context, sysIns system.Insets) layou...
    method FillShareDialog (line 538) | func (ui *UI) FillShareDialog(targets []*apitype.FileTarget, err error) {
    method ShowShareDialog (line 559) | func (ui *UI) ShowShareDialog() {
    method ShowMessage (line 563) | func (ui *UI) ShowMessage(msg string) {
    method ShowQRCode (line 568) | func (ui *UI) ShowQRCode(url string) {
    method layoutExitStatus (line 599) | func (ui *UI) layoutExitStatus(gtx layout.Context, state *BackendState...
    method layoutSignIn (line 645) | func (ui *UI) layoutSignIn(gtx layout.Context, state *BackendState) la...
    method withLoader (line 745) | func (ui *UI) withLoader(gtx layout.Context, loading bool, w layout.Wi...
    method layoutDisconnected (line 768) | func (ui *UI) layoutDisconnected(gtx layout.Context) layout.Dimensions {
    method layoutIntro (line 790) | func (ui *UI) layoutIntro(gtx layout.Context, sysIns system.Insets) {
    method menuClicked (line 844) | func (ui *UI) menuClicked(btn *widget.Clickable) bool {
    method layoutShareDialog (line 853) | func (ui *UI) layoutShareDialog(gtx layout.Context, sysIns system.Inse...
    method layoutExitNodeDialog (line 962) | func (ui *UI) layoutExitNodeDialog(gtx layout.Context, sysIns system.I...
    method layoutMenu (line 1091) | func (ui *UI) layoutMenu(gtx layout.Context, sysIns system.Insets, exp...
    method layoutMessage (line 1153) | func (ui *UI) layoutMessage(gtx layout.Context, sysIns system.Insets) ...
    method showMessage (line 1182) | func (ui *UI) showMessage(gtx layout.Context, msg string) {
    method layoutPeer (line 1190) | func (ui *UI) layoutPeer(gtx layout.Context, sysIns system.Insets, p *...
    method layoutSection (line 1223) | func (ui *UI) layoutSection(gtx layout.Context, sysIns system.Insets, ...
    method layoutTop (line 1239) | func (ui *UI) layoutTop(gtx layout.Context, sysIns system.Insets, stat...
    method showCopied (line 1302) | func (ui *UI) showCopied(gtx layout.Context, addr string) {
    method layoutLocal (line 1308) | func (ui *UI) layoutLocal(gtx layout.Context, sysIns system.Insets, ho...
    method layoutSearchbar (line 1340) | func (ui *UI) layoutSearchbar(gtx layout.Context, sysIns system.Insets...
  type shareTarget (line 136) | type shareTarget struct
  type signinType (line 144) | type signinType
  type UIPeer (line 148) | type UIPeer struct
  type menuItem (line 158) | type menuItem struct
  constant headerColor (line 164) | headerColor = 0x496495
  constant infoColor (line 165) | infoColor   = 0x3a517b
  constant white (line 166) | white       = 0xffffff
  constant keyShowIntro (line 170) | keyShowIntro = "ui.showintro"
  constant noSignin (line 174) | noSignin signinType = iota
  constant webSignin (line 175) | webSignin
  constant googleSignin (line 176) | googleSignin
  function newUI (line 191) | func newUI(store *stateStore) (*UI, error) {
  function mulAlpha (line 253) | func mulAlpha(c color.NRGBA, alpha uint8) color.NRGBA {
  type Dismiss (line 579) | type Dismiss struct
    method Add (line 582) | func (d *Dismiss) Add(gtx layout.Context, color color.NRGBA) {
    method Dismissed (line 588) | func (d *Dismiss) Dismissed(gtx layout.Context) bool {
  function layoutMenu (line 1034) | func layoutMenu(th *material.Theme, gtx layout.Context, items []menuItem...
  function layoutDialog (line 1084) | func layoutDialog(gtx layout.Context, w layout.Widget) layout.Dimensions {
  function statusString (line 1285) | func statusString(state ipn.State) string {
  function drawLogo (line 1366) | func drawLogo(ops *op.Ops, size int) {
  function drawImage (line 1406) | func drawImage(gtx layout.Context, img paint.ImageOp, size unit.Value) l...
  function drawDisc (line 1418) | func drawDisc(ops *op.Ops, radius float32, col color.NRGBA) {
  type Background (line 1430) | type Background struct
    method Layout (line 1435) | func (b Background) Layout(gtx layout.Context, w layout.Widget) layout...
  type fill (line 1456) | type fill struct
    method Layout (line 1460) | func (f fill) Layout(gtx layout.Context, sz image.Point) layout.Dimens...
  function rgb (line 1467) | func rgb(c uint32) color.NRGBA {
  function argb (line 1471) | func argb(c uint32) color.NRGBA {
  constant termsText (line 1475) | termsText = `Tailscale is a mesh VPN for securely connecting your device...

FILE: jni/jni.go
  type JVM (line 167) | type JVM
  type Env (line 169) | type Env
  type Class (line 172) | type Class
  type Object (line 173) | type Object
  type MethodID (line 174) | type MethodID
  type String (line 175) | type String
  type ByteArray (line 176) | type ByteArray
  type ObjectArray (line 177) | type ObjectArray
  type BooleanArray (line 178) | type BooleanArray
  type LongArray (line 179) | type LongArray
  type IntArray (line 180) | type IntArray
  type Boolean (line 181) | type Boolean
  type Value (line 182) | type Value
  function env (line 193) | func env(e *Env) *C.JNIEnv {
  function javavm (line 197) | func javavm(vm *JVM) *C.JavaVM {
  function Do (line 203) | func Do(vm *JVM, f func(env *Env) error) error {
  function Bool (line 220) | func Bool(b bool) Boolean {
  function varArgs (line 227) | func varArgs(args []Value) *C.jvalue {
  function IsSameObject (line 234) | func IsSameObject(e *Env, ref1, ref2 Object) bool {
  function CallStaticIntMethod (line 239) | func CallStaticIntMethod(e *Env, cls Class, method MethodID, args ...Val...
  function CallStaticVoidMethod (line 244) | func CallStaticVoidMethod(e *Env, cls Class, method MethodID, args ...Va...
  function CallVoidMethod (line 249) | func CallVoidMethod(e *Env, obj Object, method MethodID, args ...Value) ...
  function CallStaticObjectMethod (line 254) | func CallStaticObjectMethod(e *Env, cls Class, method MethodID, args ......
  function CallObjectMethod (line 259) | func CallObjectMethod(e *Env, obj Object, method MethodID, args ...Value...
  function CallBooleanMethod (line 264) | func CallBooleanMethod(e *Env, obj Object, method MethodID, args ...Valu...
  function CallIntMethod (line 269) | func CallIntMethod(e *Env, obj Object, method MethodID, args ...Value) (...
  function GetByteArrayElements (line 275) | func GetByteArrayElements(e *Env, jarr ByteArray) []byte {
  function GetBooleanArrayElements (line 289) | func GetBooleanArrayElements(e *Env, jarr BooleanArray) []bool {
  function GetStringArrayElements (line 305) | func GetStringArrayElements(e *Env, jarr ObjectArray) []string {
  function GetIntArrayElements (line 315) | func GetIntArrayElements(e *Env, jarr IntArray) []int {
  function GetLongArrayElements (line 331) | func GetLongArrayElements(e *Env, jarr LongArray) []int64 {
  function iterateObjectArray (line 346) | func iterateObjectArray(e *Env, jarr ObjectArray, f func(e *Env, idx int...
  function NewByteArray (line 360) | func NewByteArray(e *Env, content []byte) ByteArray {
  function ClassLoaderFor (line 374) | func ClassLoaderFor(e *Env, obj Object) Object {
  function LoadClass (line 387) | func LoadClass(e *Env, loader Object, class string) (Class, error) {
  function exception (line 401) | func exception(e *Env) error {
  function GetObjectClass (line 417) | func GetObjectClass(e *Env, obj Object) Class {
  function GetStaticMethodID (line 431) | func GetStaticMethodID(e *Env, cls Class, name, signature string) Method...
  function GetMethodID (line 445) | func GetMethodID(e *Env, cls Class, name, signature string) MethodID {
  function NewGlobalRef (line 457) | func NewGlobalRef(e *Env, obj Object) Object {
  function DeleteGlobalRef (line 461) | func DeleteGlobalRef(e *Env, obj Object) {
  function JavaString (line 466) | func JavaString(e *Env, str string) String {
  function GoString (line 476) | func GoString(e *Env, str String) string {
Condensed preview — 43 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (305K chars).
[
  {
    "path": ".github/workflows/main.yml",
    "chars": 1790,
    "preview": "# This is a basic workflow to help you get started with Actions\n\nname: CI\n\n# Controls when the workflow will run\non:\n  #"
  },
  {
    "path": ".gitignore",
    "chars": 218,
    "preview": "# Ignore Gradle project-specific cache directory\n.gradle\n\n# Ignore Gradle build output directory\nbuild\n\n# The destinatio"
  },
  {
    "path": "Dockerfile",
    "chars": 1892,
    "preview": "# This is a Dockerfile for creating a build environment for\n# tailscale-android.\n\nFROM openjdk:8-jdk\n\n# To enable runnin"
  },
  {
    "path": "LICENSE",
    "chars": 1487,
    "preview": "Copyright (c) 2020 Tailscale & AUTHORS. All rights reserved.\n\nRedistribution and use in source and binary forms, with or"
  },
  {
    "path": "Makefile",
    "chars": 4317,
    "preview": "# Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n# Use of this source code is governed by a BSD-style\n#"
  },
  {
    "path": "PATENTS",
    "chars": 1377,
    "preview": "Additional IP Rights Grant (Patents)\n\n\"This implementation\" means the copyrightable works distributed by\nTailscale Inc. "
  },
  {
    "path": "README.md",
    "chars": 4279,
    "preview": "# Tailscale Android Client\n\nhttps://tailscale.com\n\nPrivate WireGuard® networks made easy\n\n## Overview\n\nThis repository c"
  },
  {
    "path": "android/build.gradle",
    "chars": 1107,
    "preview": "buildscript {\n\trepositories {\n\t\tgoogle()\n\t\tjcenter()\n\t}\n\tdependencies {\n\t\tclasspath 'com.android.tools.build:gradle:4.2."
  },
  {
    "path": "android/gradle/wrapper/gradle-wrapper.properties",
    "chars": 289,
    "preview": "distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionSha256Sum=3239b5ed86c3838a37d983ac100573f64"
  },
  {
    "path": "android/gradle.properties",
    "chars": 25,
    "preview": "android.useAndroidX=true\n"
  },
  {
    "path": "android/gradlew",
    "chars": 5764,
    "preview": "#!/usr/bin/env sh\n\n#\n# Copyright 2015 the original author or authors.\n#\n# Licensed under the Apache License, Version 2.0"
  },
  {
    "path": "android/gradlew.bat",
    "chars": 3056,
    "preview": "@rem\r\n@rem Copyright 2015 the original author or authors.\r\n@rem\r\n@rem Licensed under the Apache License, Version 2.0 (th"
  },
  {
    "path": "android/src/main/AndroidManifest.xml",
    "chars": 3243,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\"\n\tpackage=\"co"
  },
  {
    "path": "android/src/main/java/com/tailscale/ipn/App.java",
    "chars": 14774,
    "preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
  },
  {
    "path": "android/src/main/java/com/tailscale/ipn/DnsConfig.java",
    "chars": 13991,
    "preview": "// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
  },
  {
    "path": "android/src/main/java/com/tailscale/ipn/IPNActivity.java",
    "chars": 3565,
    "preview": "// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
  },
  {
    "path": "android/src/main/java/com/tailscale/ipn/IPNService.java",
    "chars": 3397,
    "preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
  },
  {
    "path": "android/src/main/java/com/tailscale/ipn/Peer.java",
    "chars": 569,
    "preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
  },
  {
    "path": "android/src/main/java/com/tailscale/ipn/QuickToggleService.java",
    "chars": 1947,
    "preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
  },
  {
    "path": "android/src/main/res/drawable/ic_launcher_foreground.xml",
    "chars": 1378,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"108dp\"\n    android:height=\"108dp\"\n"
  },
  {
    "path": "android/src/main/res/drawable/ic_tile.xml",
    "chars": 1320,
    "preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"24dp\"\n    android:height=\"24dp\"\n  "
  },
  {
    "path": "android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml",
    "chars": 267,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <b"
  },
  {
    "path": "android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml",
    "chars": 267,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <b"
  },
  {
    "path": "android/src/main/res/values/ic_launcher_background.xml",
    "chars": 120,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n    <color name=\"ic_launcher_background\">#1F2125</color>\n</resources>"
  },
  {
    "path": "android/src/main/res/values/strings.xml",
    "chars": 159,
    "preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n    <string name=\"app_name\">Tailscale</string>\n    <string name=\"tile"
  },
  {
    "path": "android/src/play/java/com/tailscale/ipn/Google.java",
    "chars": 1538,
    "preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
  },
  {
    "path": "android/src/test/java/com/tailscale/ipn/DnsConfigTest.java",
    "chars": 476,
    "preview": "import org.junit.Before;\nimport org.junit.Test;\n\nimport static org.junit.Assert.assertEquals;\nimport com.tailscale.ipn.D"
  },
  {
    "path": "cmd/tailscale/backend.go",
    "chars": 12878,
    "preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
  },
  {
    "path": "cmd/tailscale/callbacks.go",
    "chars": 5186,
    "preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
  },
  {
    "path": "cmd/tailscale/main.go",
    "chars": 36735,
    "preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
  },
  {
    "path": "cmd/tailscale/multitun.go",
    "chars": 6315,
    "preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
  },
  {
    "path": "cmd/tailscale/pprof.go",
    "chars": 334,
    "preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
  },
  {
    "path": "cmd/tailscale/store.go",
    "chars": 3027,
    "preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
  },
  {
    "path": "cmd/tailscale/tools.go",
    "chars": 256,
    "preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
  },
  {
    "path": "cmd/tailscale/ui.go",
    "chars": 41106,
    "preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
  },
  {
    "path": "flake.nix",
    "chars": 1665,
    "preview": "{\n  description = \"Tailscale build environment\";\n\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs\";\n    android.url"
  },
  {
    "path": "go.mod",
    "chars": 3781,
    "preview": "module github.com/tailscale/tailscale-android\n\ngo 1.18\n\nrequire (\n\teliasnaur.com/font v0.0.0-20220124212145-832bb8fc08c3"
  },
  {
    "path": "go.sum",
    "chars": 82598,
    "preview": "cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=\ncloud.google.com/go v0.34.0/go.mod h1"
  },
  {
    "path": "jni/jni.go",
    "chars": 15016,
    "preview": "// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n// Use of this source code is governed by a BSD-style"
  },
  {
    "path": "metadata/en-US/full_description.txt",
    "chars": 433,
    "preview": "Tailscale is a mesh VPN alternative that makes it easy to connect your devices, wherever they are. No more fighting conf"
  },
  {
    "path": "metadata/en-US/short_description.txt",
    "chars": 28,
    "preview": "Mesh VPN based on WireGuard\n"
  },
  {
    "path": "version/tailscale-version.sh",
    "chars": 1145,
    "preview": "#!/usr/bin/env bash\n\n# Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.\n# Use of this source code is gove"
  }
]

// ... and 1 more files (download for full content)

About this extraction

This page contains the full source code of the ppyyr/tailscale-android GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 43 files (276.5 KB), approximately 103.9k tokens, and a symbol index with 322 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!