android: add main screen device details and basic nav (#191)

updates tailscale/corp#18202
updates ENG-2835
updates ENG-2859

Adds the peer details view and some supporting utilities. Eliminates all of the singletons.

None of this is styled correctly, but the layouts match iOS.

Signed-off-by: Jonathan Nobels jonathan@tailscale.com

---------

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
pull/196/head
Jonathan Nobels 9 months ago committed by GitHub
parent 87a8003d39
commit 3926cf4b56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

1
.gitignore vendored

@ -13,6 +13,7 @@ android_legacy/.idea
android_legacy/local.properties
android/.idea
android/local.properties
.idea
# Output files from the Makefile:
tailscale-debug.apk

@ -1,25 +1,24 @@
buildscript {
ext.kotlin_version = "1.9.22"
ext.kotlin_compose_version = "1.5.10"
ext.kotlin_version = "1.9.22"
ext.kotlin_compose_version = "1.5.10"
repositories {
google()
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:8.1.0"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:8.1.4"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
}
}
repositories {
google()
mavenCentral()
flatDir {
dirs 'libs'
}
google()
mavenCentral()
flatDir {
dirs 'libs'
}
}
apply plugin: 'kotlin-android'
@ -27,69 +26,82 @@ apply plugin: 'com.android.application'
apply plugin: 'org.jetbrains.kotlin.plugin.serialization'
android {
ndkVersion "23.1.7779620"
compileSdkVersion 33
defaultConfig {
minSdkVersion 22
targetSdkVersion 33
versionCode 198
versionName "1.59.53-t0f042b981-g1017015de26"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
buildFeatures {
compose true
}
composeOptions {
ndkVersion "23.1.7779620"
compileSdkVersion 34
defaultConfig {
minSdkVersion 22
targetSdkVersion 34
versionCode 198
versionName "1.59.53-t0f042b981-g1017015de26"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion = "$kotlin_compose_version"
}
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.
}
}
flavorDimensions "version"
productFlavors {
fdroid {
// The fdroid flavor contains only free dependencies and is suitable
// for the F-Droid app store.
}
play {
// The play flavor contains all features and is for the Play Store.
}
}
namespace 'com.tailscale.ipn'
}
dependencies {
// Android dependencies.
implementation "androidx.core:core:1.9.0"
implementation 'androidx.core:core-ktx:1.9.0'
implementation "androidx.browser:browser:1.5.0"
implementation "androidx.security:security-crypto:1.1.0-alpha06"
implementation "androidx.work:work-runtime:2.8.1"
// Android dependencies.
implementation "androidx.core:core:1.12.0"
implementation 'androidx.core:core-ktx:1.12.0'
implementation "androidx.browser:browser:1.8.0"
implementation "androidx.security:security-crypto:1.1.0-alpha06"
implementation "androidx.work:work-runtime:2.9.0"
// Kotlin dependencies.
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
// Kotlin dependencies.
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
// Compose dependencies.
def composeBom = platform('androidx.compose:compose-bom:2023.06.01')
// Compose dependencies.
def composeBom = platform('androidx.compose:compose-bom:2023.06.01')
implementation composeBom
androidTestImplementation composeBom
implementation 'androidx.compose.material3:material3:1.0.0'
implementation "androidx.compose.ui:ui:1.4.3"
implementation "androidx.compose.ui:ui-tooling:1.4.3"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
implementation 'androidx.activity:activity-compose:1.7.2'
implementation 'androidx.compose.material3:material3:1.2.1'
implementation 'androidx.compose.material:material-icons-core:1.6.3'
implementation "androidx.compose.ui:ui:1.6.3"
implementation "androidx.compose.ui:ui-tooling:1.6.3"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
implementation 'androidx.activity:activity-compose:1.8.2'
// Navigation dependencies.
def nav_version = "2.7.7"
implementation "androidx.navigation:navigation-fragment:$nav_version"
implementation "androidx.navigation:navigation-ui:$nav_version"
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
implementation "androidx.navigation:navigation-dynamic-features-fragment:$nav_version"
implementation "androidx.navigation:navigation-compose:$nav_version"
// Tailscale dependencies.
implementation ':ipn@aar'
// Tailscale dependencies.
implementation ':ipn@aar'
// Tests
testImplementation "junit:junit:4.12"
// Tests
testImplementation "junit:junit:4.12"
androidTestImplementation "androidx.navigation:navigation-testing:$nav_version"
// Non-free dependencies.
playImplementation 'com.google.android.gms:play-services-auth:20.7.0'
// Non-free dependencies.
playImplementation 'com.google.android.gms:play-services-auth:20.7.0'
}

@ -17,7 +17,7 @@
<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"
<activity android:name="MainActivity"
android:label="@string/app_name"
android:theme="@style/Theme.GioApp"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"

@ -66,6 +66,7 @@ import androidx.security.crypto.EncryptedSharedPreferences;
import androidx.security.crypto.MasterKey;
import androidx.browser.customtabs.CustomTabsIntent;
import com.tailscale.ipn.ui.service.IpnManager;
import org.gioui.Gio;
@ -87,6 +88,12 @@ public class App extends Application {
public DnsConfig dns = new DnsConfig();
public DnsConfig getDnsConfigObj() { return this.dns; }
static App _application;
public static App getApplication() {
return _application;
}
@Override public void onCreate() {
super.onCreate();
// Load and initialize the Go library.
@ -98,6 +105,8 @@ public class App extends Application {
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);
_application = this;
}
// requestNetwork attempts to find the best network that matches the passed NetworkRequest. It is possible that

@ -3,6 +3,7 @@
package com.tailscale.ipn;
import android.util.Log;
import android.os.Build;
import android.app.PendingIntent;
import android.content.Intent;

@ -0,0 +1,64 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.tailscale.ipn.ui.service.IpnManager
import com.tailscale.ipn.ui.theme.AppTheme
import com.tailscale.ipn.ui.view.ExitNodePicker
import com.tailscale.ipn.ui.view.MainView
import com.tailscale.ipn.ui.view.MainViewNavigation
import com.tailscale.ipn.ui.view.PeerDetails
import com.tailscale.ipn.ui.view.Settings
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
import com.tailscale.ipn.ui.viewModel.SettingsViewModel
class MainActivity : ComponentActivity() {
private val manager = IpnManager()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AppTheme {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "main") {
val mainViewNav = MainViewNavigation(
onNavigateToSettings = { navController.navigate("settings") },
onNavigateToPeerDetails = {
navController.navigate("peerDetails/${it.StableID}")
},
onNavigateToExitNodes = { navController.navigate("exitNodes") }
)
composable("main") {
MainView(viewModel = MainViewModel(manager.model, manager.actions), navigation = mainViewNav)
}
composable("settings") {
Settings(SettingsViewModel(manager.model))
}
composable("exitNodes") {
ExitNodePicker(ExitNodePickerViewModel(manager.model))
}
composable("peerDetails/{nodeId}", arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) {
PeerDetails(PeerDetailsViewModel(manager.model, nodeId = it.arguments?.getString("nodeId")
?: ""))
}
}
}
}
}
}

@ -0,0 +1,14 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.mdm
enum class NetworkDevices(val value: String) {
currentUser("current-user"),
otherUsers("other-users"),
taggedDevices("tagged-devices"),
}
class MDMSettings {
val hiddenNetworkDevices: List<NetworkDevices> = emptyList()
}

@ -8,10 +8,8 @@ import com.tailscale.ipn.ui.model.BugReportID
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.IpnState
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
@ -20,16 +18,19 @@ typealias BugReportIdHandler = (Result<BugReportID>) -> Unit
typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
class LocalApiClient(private val scope: CoroutineScope) {
init {
Log.d("LocalApiClient", "LocalApiClient created")
}
companion object {
private val _isReady = MutableStateFlow(false)
val isReady: StateFlow<Boolean> = _isReady
val isReady = CompletableDeferred<Boolean>()
// Called by the backend when the localAPI is ready to accept requests.
@JvmStatic
@Suppress("unused")
fun onReady() {
_isReady.value = true
isReady.complete(true)
Log.d("LocalApiClient", "LocalApiClient is ready")
}
}
@ -47,9 +48,9 @@ class LocalApiClient(private val scope: CoroutineScope) {
// the corresponding request. Cookies must be unique for each request.
private external fun doRequest(request: String, method: String, body: ByteArray?, cookie: String)
private fun <T> executeRequest(request: LocalAPIRequest<T>) {
fun <T> executeRequest(request: LocalAPIRequest<T>) {
scope.launch {
isReady.first { it }
isReady.await()
Log.d("LocalApiClient", "Executing request:${request.method}:${request.path}")
requests[request.cookie] = request
doRequest(request.path, request.method, request.body, request.cookie)
@ -59,11 +60,11 @@ class LocalApiClient(private val scope: CoroutineScope) {
// This is called from the JNI layer to publish localAPIResponses. This should execute on the
// same thread that called doRequest.
@Suppress("unused")
fun onResponse(response: ByteArray, cookie: String) {
fun onResponse(response: String, cookie: String) {
requests.remove(cookie)?.let { request ->
Log.d("LocalApiClient", "Reponse for request:${request.path} cookie:${request.cookie}")
Log.d("LocalApiClient", "Response for request:${request.path} cookie:${request.cookie}")
// The response handler will invoked internally by the request parser
request.parser(response)
request.parser(response.encodeToByteArray())
} ?: { Log.e("LocalApiClient", "Received response for unknown request: $cookie") }
}
@ -98,6 +99,14 @@ class LocalApiClient(private val scope: CoroutineScope) {
executeRequest(req)
}
fun startLoginInteractive() {
val req = LocalAPIRequest.startLoginInteractive { result ->
result.success?.let { Log.d("LocalApiClient", "Login started: $it") }
?: run { Log.e("LocalApiClient", "Error starting login: ${result.error}") }
}
executeRequest<String>(req)
}
// (jonathan) TODO: A (likely) exhaustive list of localapi endpoints required for
// a fully functioning client. This is a work in progress and will be updated
// See: corp/xcode/Shared/LocalAPIClient.swift for the various verbs, parameters,

@ -111,6 +111,12 @@ class LocalAPIRequest<T>(
}
}
fun startLoginInteractive(responseHandler: (Result<String>) -> Unit): LocalAPIRequest<String> {
return post(Endpoint.LOGIN_INTERACTIVE) { resp ->
responseHandler(parseString(resp))
}
}
// Check if the response was a generic error
@OptIn(ExperimentalSerializationApi::class)
fun parseError(respData: ByteArray): Error {

@ -8,7 +8,7 @@ package com.tailscale.ipn.ui.localapi
class Result<T> {
val success: T?
val error: Error?
private constructor(success: T?, error: Error?) {
if (success != null && error != null) {
throw IllegalArgumentException("Result cannot have both a success and an error")
@ -20,13 +20,13 @@ class Result<T> {
this.success = success
this.error = error
}
constructor(success: T) : this(success, null) {}
constructor(error: Error) : this(null, error) {}
constructor(success: T) : this(success, null)
constructor(error: Error) : this(null, error)
var successful: Boolean = false
get() = success != null
var failed: Boolean = false
get() = error != null
}

@ -6,7 +6,8 @@ package com.tailscale.ipn.ui.model
import kotlinx.serialization.Serializable
class Dns {
@Serializable data class HostEntry(val addr: Addr?, val hosts: List<String>?)
@Serializable
data class HostEntry(val addr: Addr?, val hosts: List<String>?)
@Serializable
data class OSConfig(

@ -7,155 +7,156 @@ import kotlinx.serialization.Serializable
class Ipn {
// Represents the overall state of the Tailscale engine.
enum class State(val value: Int) {
NoState(0),
InUseOtherUser(1),
NeedsLogin(2),
NeedsMachineAuth(3),
Stopped(4),
Starting(5),
Running(6);
// Represents the overall state of the Tailscale engine.
enum class State(val value: Int) {
NoState(0),
InUseOtherUser(1),
NeedsLogin(2),
NeedsMachineAuth(3),
Stopped(4),
Starting(5),
Running(6);
companion object {
fun fromInt(value: Int): State? {
return State.values().first { s -> s.value == value }
}
}
companion object {
fun fromInt(value: Int): State {
return State.values().firstOrNull { it.value == value } ?: NoState
}
}
// A nofitication message recieved on the Notify bus. Fields will be populated based
// on which NotifyWatchOpts were set when the Notifier was created.
@Serializable
data class Notify(
val Version: String? = null,
val ErrMessage: String? = null,
val LoginFinished: Empty.Message? = null,
val FilesWaiting: Empty.Message? = null,
val State: Int? = null,
var Prefs: Prefs? = null,
var NetMap: Netmap.NetworkMap? = null,
var Engine: EngineStatus? = null,
var BrowseToURL: String? = null,
var BackendLogId: String? = null,
var LocalTCPPort: Int? = null,
var IncomingFiles: List<PartialFile>? = null,
var ClientVersion: Tailcfg.ClientVersion? = null,
var TailFSShares: Map<String, String>? = null,
)
}
@Serializable
data class Prefs(
var ControlURL: String = "",
var RouteAll: Boolean = false,
var AllowsSingleHosts: Boolean = false,
var CorpDNS: Boolean = false,
var WantRunning: Boolean = false,
var LoggedOut: Boolean = false,
var ShieldsUp: Boolean = false,
var AdvertiseRoutes: List<String>? = null,
var AdvertiseTags: List<String>? = null,
var ExitNodeId: StableNodeID? = null,
var ExitNodeAllowLanAccess: Boolean = false,
var Config: Persist.Persist? = null,
var ForceDaemon: Boolean = false,
var HostName: String = "",
var AutoUpdate: AutoUpdatePrefs? = AutoUpdatePrefs(true, true),
)
// A nofitication message recieved on the Notify bus. Fields will be populated based
// on which NotifyWatchOpts were set when the Notifier was created.
@Serializable
data class Notify(
val Version: String? = null,
val ErrMessage: String? = null,
val LoginFinished: Empty.Message? = null,
val FilesWaiting: Empty.Message? = null,
val State: Int? = null,
var Prefs: Prefs? = null,
var NetMap: Netmap.NetworkMap? = null,
var Engine: EngineStatus? = null,
var BrowseToURL: String? = null,
var BackendLogId: String? = null,
var LocalTCPPort: Int? = null,
var IncomingFiles: List<PartialFile>? = null,
var ClientVersion: Tailcfg.ClientVersion? = null,
var TailFSShares: Map<String, String>? = null,
)
@Serializable
data class MaskedPrefs(
var RouteAllSet: Boolean? = null,
var CorpDNSSet: Boolean? = null,
var ExitNodeIDSet: Boolean? = null,
var ExitNodeAllowLANAccessSet: Boolean? = null,
var WantRunningSet: Boolean? = null,
var ShieldsUpSet: Boolean? = null,
var AdvertiseRoutesSet: Boolean? = null,
var ForceDaemonSet: Boolean? = null,
var HostnameSet: Boolean? = null,
) {
var RouteAll: Boolean? = null
set(value) {
field = value
RouteAllSet = true
}
var CorpDNS: Boolean? = null
set(value) {
field = value
CorpDNSSet = true
}
var ExitNodeId: StableNodeID? = null
set(value) {
field = value
ExitNodeIDSet = true
}
var ExitNodeAllowLanAccess: Boolean? = null
set(value) {
field = value
ExitNodeAllowLANAccessSet = true
}
var WantRunning: Boolean? = null
set(value) {
field = value
WantRunningSet = true
}
var ShieldsUp: Boolean? = null
set(value) {
field = value
ShieldsUpSet = true
}
var AdvertiseRoutes: Boolean? = null
set(value) {
field = value
AdvertiseRoutesSet = true
}
var ForceDaemon: Boolean? = null
set(value) {
field = value
ForceDaemonSet = true
}
var Hostname: Boolean? = null
set(value) {
field = value
HostnameSet = true
}
}
@Serializable
data class Prefs(
var ControlURL: String = "",
var RouteAll: Boolean = false,
var AllowsSingleHosts: Boolean = false,
var CorpDNS: Boolean = false,
var WantRunning: Boolean = false,
var LoggedOut: Boolean = false,
var ShieldsUp: Boolean = false,
var AdvertiseRoutes: List<String>? = null,
var AdvertiseTags: List<String>? = null,
var ExitNodeId: StableNodeID? = null,
var ExitNodeAllowLanAccess: Boolean = false,
var Config: Persist.Persist? = null,
var ForceDaemon: Boolean = false,
var HostName: String = "",
var AutoUpdate: AutoUpdatePrefs? = AutoUpdatePrefs(true, true),
)
@Serializable
data class MaskedPrefs(
var RouteAllSet: Boolean? = null,
var CorpDNSSet: Boolean? = null,
var ExitNodeIDSet: Boolean? = null,
var ExitNodeAllowLANAccessSet: Boolean? = null,
var WantRunningSet: Boolean? = null,
var ShieldsUpSet: Boolean? = null,
var AdvertiseRoutesSet: Boolean? = null,
var ForceDaemonSet: Boolean? = null,
var HostnameSet: Boolean? = null,
) {
var RouteAll: Boolean? = null
set(value) {
field = value
RouteAllSet = true
}
var CorpDNS: Boolean? = null
set(value) {
field = value
CorpDNSSet = true
}
var ExitNodeId: StableNodeID? = null
set(value) {
field = value
ExitNodeIDSet = true
}
var ExitNodeAllowLanAccess: Boolean? = null
set(value) {
field = value
ExitNodeAllowLANAccessSet = true
}
var WantRunning: Boolean? = null
set(value) {
field = value
WantRunningSet = true
}
var ShieldsUp: Boolean? = null
set(value) {
field = value
ShieldsUpSet = true
}
var AdvertiseRoutes: Boolean? = null
set(value) {
field = value
AdvertiseRoutesSet = true
}
var ForceDaemon: Boolean? = null
set(value) {
field = value
ForceDaemonSet = true
}
var Hostname: Boolean? = null
set(value) {
field = value
HostnameSet = true
}
}
@Serializable
data class AutoUpdatePrefs(
var Check: Boolean? = null,
var Apply: Boolean? = null,
)
@Serializable
data class AutoUpdatePrefs(
var Check: Boolean? = null,
var Apply: Boolean? = null,
)
@Serializable
data class EngineStatus(
val RBytes: Long,
val WBytes: Long,
val NumLive: Int,
val LivePeers: Map<String, IpnState.PeerStatusLite>,
)
@Serializable
data class EngineStatus(
val RBytes: Long,
val WBytes: Long,
val NumLive: Int,
val LivePeers: Map<String, IpnState.PeerStatusLite>,
)
@Serializable
data class PartialFile(
val Name: String,
val Started: String,
val DeclaredSize: Long,
val Received: Long,
val PartialPath: String? = null,
var FinalPath: String? = null,
val Done: Boolean? = null,
)
@Serializable
data class PartialFile(
val Name: String,
val Started: String,
val DeclaredSize: Long,
val Received: Long,
val PartialPath: String? = null,
var FinalPath: String? = null,
val Done: Boolean? = null,
)
}
class Persist {
@Serializable
data class Persist(
var PrivateMachineKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
var PrivateNodeKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
var OldPrivateNodeKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
var Provider: String = "",
)
@Serializable
data class Persist(
var PrivateMachineKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
var PrivateNodeKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
var OldPrivateNodeKey: String =
"privkey:0000000000000000000000000000000000000000000000000000000000000000",
var Provider: String = "",
)
}

@ -36,13 +36,13 @@ class IpnState {
fun computedName(status: Status): String {
val name = DNSName
val suffix = status.CurrentTailnet?.MagicDNSSuffix
suffix ?: return name
if (!(name.endsWith("." + suffix + "."))) {
return name
}
return name.dropLast(suffix.count() + 2)
}
}

@ -74,7 +74,7 @@ class Tailcfg {
)
@Serializable
data class Service(var Proto: String, var Port: Int, var Description: String? = null)
data class Service(var Proto: String, var Port: Int, var Description: String? = null)
@Serializable
data class NetworkProfile(var MagicDNSName: String? = null, var DomainName: String? = null)

@ -17,14 +17,14 @@ typealias BugReportID = String
// Represents and empty message with a single 'property' field.
class Empty {
@Serializable
@Serializable
data class Message(val property: String)
}
// Parsable errors returned by localApiService
class Errors {
@Serializable
data class GenericError(val error: String)
data class GenericError(val error: String)
}
// Returned on successful operations with no explicit response body

@ -5,23 +5,20 @@ package com.tailscale.ipn.ui.notifier
import android.util.Log
import com.tailscale.ipn.ui.model.Ipn.Notify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.serialization.decodeFromString
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
typealias NotifierCallback = (Notify) -> Unit
class Watcher(
val sessionId: String,
val mask: Int,
val callback: NotifierCallback
val sessionId: String,
val mask: Int,
val callback: NotifierCallback
)
// Notifier is a wrapper around the IPN Bus notifier. It provides a way to watch
@ -34,30 +31,30 @@ class Watcher(
// unwatchIPNBus with the sessionId.
class Notifier() {
// (jonathan) TODO: We should be using a lifecycle aware scope here
private val scope = CoroutineScope(Dispatchers.IO + Job())
// NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which
// what we want to see on the Noitfy bus
enum class NotifyWatchOpt(val value: Int) {
engineUpdates(1 shl 0),
initialState(1 shl 1),
prefs(1 shl 2),
netmap(1 shl 3),
noPrivateKeys(1 shl 4),
initialTailFSShares(1 shl 5)
engineUpdates(0),
initialState(1),
prefs(2),
netmap(4),
noPrivateKey(8),
initialTailFSShares(16)
}
companion object {
private val sessionIdLock = Any()
private var sessionId: Int = 0
private val decoder = Json { ignoreUnknownKeys = true }
private val _isReady = MutableStateFlow(false)
val isReady: StateFlow<Boolean> = _isReady
private val isReady = CompletableDeferred<Boolean>()
// Called by the backend when the localAPI is ready to accept requests.
@JvmStatic
fun onReady() {
_isReady.value = true
isReady.complete(true)
Log.d("Notifier", "Notifier is ready")
}
@ -96,7 +93,7 @@ class Notifier() {
watchers[sessionId] = watcher
scope.launch {
// Wait for the notifier to be ready
isReady.first { it == true }
isReady.await()
Log.d("Notifier", "Starting IPN Bus watcher for sessionid: ${sessionId}")
startIPNBusWatcher(sessionId, mask)
watchers.remove(sessionId)
@ -140,9 +137,8 @@ class Notifier() {
fun watchAll(callback: NotifierCallback): String {
return watchIPNBus(
NotifyWatchOpt.netmap.value or
NotifyWatchOpt.prefs.value or
NotifyWatchOpt.engineUpdates.value or
NotifyWatchOpt.initialState.value,
NotifyWatchOpt.prefs.value or
NotifyWatchOpt.initialState.value,
callback
)
}

@ -3,26 +3,64 @@
package com.tailscale.ipn.ui.service
import android.content.Intent
import com.tailscale.ipn.App
import com.tailscale.ipn.IPNReceiver
import com.tailscale.ipn.ui.localapi.LocalApiClient
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
typealias PrefChangeCallback = (Result<Boolean>) -> Unit
// Abstracts the actions that can be taken by the UI so that the concept of an IPNManager
// itself is hidden from the viewModel implementations.
data class IpnActions(
val startVPN: () -> Unit,
val stopVPN: () -> Unit,
val login: () -> Unit,
val updatePrefs: (Ipn.MaskedPrefs, PrefChangeCallback) -> Unit
)
class IpnManager {
private var notifier = Notifier()
private var scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var apiClient = LocalApiClient(scope)
private val model = IpnModel(notifier, apiClient, scope)
// We share a single instance of the IPNManager across the entire application.
companion object {
@Volatile
private var instance: IpnManager? = null
var apiClient = LocalApiClient(scope)
val model = IpnModel(notifier, apiClient, scope)
val actions = IpnActions(
startVPN = { startVPN() },
stopVPN = { stopVPN() },
login = { login() },
updatePrefs = { prefs, callback -> updatePrefs(prefs, callback) }
)
fun startVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = "com.tailscale.ipn.CONNECT_VPN"
context.sendBroadcast(intent)
}
fun stopVPN() {
val context = App.getApplication().applicationContext
val intent = Intent(context, IPNReceiver::class.java)
intent.action = "com.tailscale.ipn.DISCONNECT_VPN"
context.sendBroadcast(intent)
}
fun login() {
apiClient.startLoginInteractive()
}
fun getInstance() = instance ?: synchronized(this) {
instance ?: IpnManager().also { instance = it }
}
fun updatePrefs(prefs: Ipn.MaskedPrefs, callback: PrefChangeCallback) {
// (jonathan) TODO: Implement this in localAPI
//apiClient.updatePrefs(prefs)
}
}

@ -10,6 +10,8 @@ import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.notifier.Notifier
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
@ -22,7 +24,8 @@ class IpnModel(
) {
private var notifierSessions: MutableList<String> = mutableListOf()
private val _state: MutableStateFlow<Ipn.State?> = MutableStateFlow(null)
private val _state: MutableStateFlow<Ipn.State> = MutableStateFlow(Ipn.State.NoState)
private val _netmap: MutableStateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
private val _prefs: MutableStateFlow<Ipn.Prefs?> = MutableStateFlow(null)
private val _engineStatus: MutableStateFlow<Ipn.EngineStatus?> = MutableStateFlow(null)
@ -36,7 +39,7 @@ class IpnModel(
MutableStateFlow(null)
val state: StateFlow<Ipn.State?> = _state
val state: StateFlow<Ipn.State> = _state
val netmap: StateFlow<Netmap.NetworkMap?> = _netmap
val prefs: StateFlow<Ipn.Prefs?> = _prefs
val engineStatus: StateFlow<Ipn.EngineStatus?> = _engineStatus
@ -56,7 +59,7 @@ class IpnModel(
// Backend Observation
private suspend fun loadUserProfiles() {
LocalApiClient.isReady.first { it }
LocalApiClient.isReady.await()
apiClient.getProfiles { result ->
result.success?.let { users -> _loginProfiles.value = users }
@ -70,8 +73,10 @@ class IpnModel(
}
private fun onNotifyChange(notify: Ipn.Notify) {
notify.State?.let { state -> _state.value = Ipn.State.fromInt(state) }
notify.State?.let { state ->
Log.d("IpnModel", "State changed: $state")
_state.value = Ipn.State.fromInt(state)
}
notify.NetMap?.let { netmap -> _netmap.value = netmap }

@ -0,0 +1,72 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.theme
import androidx.compose.ui.graphics.Color
val md_theme_light_primary = Color(0xFF006A61)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFF73F8E8)
val md_theme_light_onPrimaryContainer = Color(0xFF00201D)
val md_theme_light_secondary = Color(0xFF4A635F)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFCCE8E3)
val md_theme_light_onSecondaryContainer = Color(0xFF05201C)
val md_theme_light_tertiary = Color(0xFF46617A)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFFCDE5FF)
val md_theme_light_onTertiaryContainer = Color(0xFF001D32)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFFAFDFB)
val md_theme_light_onBackground = Color(0xFF191C1C)
val md_theme_light_surface = Color(0xFFFAFDFB)
val md_theme_light_onSurface = Color(0xFF191C1C)
val md_theme_light_surfaceVariant = Color(0xFFDAE5E2)
val md_theme_light_onSurfaceVariant = Color(0xFF3F4947)
val md_theme_light_outline = Color(0xFF6F7977)
val md_theme_light_inverseOnSurface = Color(0xFFEFF1EF)
val md_theme_light_inverseSurface = Color(0xFF2D3130)
val md_theme_light_inversePrimary = Color(0xFF52DBCB)
val md_theme_light_shadow = Color(0xFF000000)
val md_theme_light_surfaceTint = Color(0xFF006A61)
val md_theme_light_outlineVariant = Color(0xFFBEC9C6)
val md_theme_light_scrim = Color(0xFF000000)
val md_theme_dark_primary = Color(0xFF52DBCB)
val md_theme_dark_onPrimary = Color(0xFF003732)
val md_theme_dark_primaryContainer = Color(0xFF005049)
val md_theme_dark_onPrimaryContainer = Color(0xFF73F8E8)
val md_theme_dark_secondary = Color(0xFFB1CCC7)
val md_theme_dark_onSecondary = Color(0xFF1C3531)
val md_theme_dark_secondaryContainer = Color(0xFF324B48)
val md_theme_dark_onSecondaryContainer = Color(0xFFCCE8E3)
val md_theme_dark_tertiary = Color(0xFFAEC9E6)
val md_theme_dark_onTertiary = Color(0xFF163349)
val md_theme_dark_tertiaryContainer = Color(0xFF2E4961)
val md_theme_dark_onTertiaryContainer = Color(0xFFCDE5FF)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF191C1C)
val md_theme_dark_onBackground = Color(0xFFE0E3E1)
val md_theme_dark_surface = Color(0xFF191C1C)
val md_theme_dark_onSurface = Color(0xFFE0E3E1)
val md_theme_dark_surfaceVariant = Color(0xFF3F4947)
val md_theme_dark_onSurfaceVariant = Color(0xFFBEC9C6)
val md_theme_dark_outline = Color(0xFF899390)
val md_theme_dark_inverseOnSurface = Color(0xFF191C1C)
val md_theme_dark_inverseSurface = Color(0xFFE0E3E1)
val md_theme_dark_inversePrimary = Color(0xFF006A61)
val md_theme_dark_shadow = Color(0xFF000000)
val md_theme_dark_surfaceTint = Color(0xFF52DBCB)
val md_theme_dark_outlineVariant = Color(0xFF3F4947)
val md_theme_dark_scrim = Color(0xFF000000)
val seed = Color(0xFF006B62)

@ -0,0 +1,94 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
private val LightColors = lightColorScheme(
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
primaryContainer = md_theme_light_primaryContainer,
onPrimaryContainer = md_theme_light_onPrimaryContainer,
secondary = md_theme_light_secondary,
onSecondary = md_theme_light_onSecondary,
secondaryContainer = md_theme_light_secondaryContainer,
onSecondaryContainer = md_theme_light_onSecondaryContainer,
tertiary = md_theme_light_tertiary,
onTertiary = md_theme_light_onTertiary,
tertiaryContainer = md_theme_light_tertiaryContainer,
onTertiaryContainer = md_theme_light_onTertiaryContainer,
error = md_theme_light_error,
errorContainer = md_theme_light_errorContainer,
onError = md_theme_light_onError,
onErrorContainer = md_theme_light_onErrorContainer,
background = md_theme_light_background,
onBackground = md_theme_light_onBackground,
surface = md_theme_light_surface,
onSurface = md_theme_light_onSurface,
surfaceVariant = md_theme_light_surfaceVariant,
onSurfaceVariant = md_theme_light_onSurfaceVariant,
outline = md_theme_light_outline,
inverseOnSurface = md_theme_light_inverseOnSurface,
inverseSurface = md_theme_light_inverseSurface,
inversePrimary = md_theme_light_inversePrimary,
surfaceTint = md_theme_light_surfaceTint,
outlineVariant = md_theme_light_outlineVariant,
scrim = md_theme_light_scrim,
)
private val DarkColors = darkColorScheme(
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
primaryContainer = md_theme_dark_primaryContainer,
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
secondary = md_theme_dark_secondary,
onSecondary = md_theme_dark_onSecondary,
secondaryContainer = md_theme_dark_secondaryContainer,
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
tertiary = md_theme_dark_tertiary,
onTertiary = md_theme_dark_onTertiary,
tertiaryContainer = md_theme_dark_tertiaryContainer,
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
error = md_theme_dark_error,
errorContainer = md_theme_dark_errorContainer,
onError = md_theme_dark_onError,
onErrorContainer = md_theme_dark_onErrorContainer,
background = md_theme_dark_background,
onBackground = md_theme_dark_onBackground,
surface = md_theme_dark_surface,
onSurface = md_theme_dark_onSurface,
surfaceVariant = md_theme_dark_surfaceVariant,
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
outline = md_theme_dark_outline,
inverseOnSurface = md_theme_dark_inverseOnSurface,
inverseSurface = md_theme_dark_inverseSurface,
inversePrimary = md_theme_dark_inversePrimary,
surfaceTint = md_theme_dark_surfaceTint,
outlineVariant = md_theme_dark_outlineVariant,
scrim = md_theme_dark_scrim,
)
@Composable
fun AppTheme(
useDarkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable() () -> Unit
) {
val colors = if (!useDarkTheme) {
LightColors
} else {
DarkColors
}
MaterialTheme(
colorScheme = colors,
content = content
)
}

@ -0,0 +1,28 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
class DisplayAddress(val ip: String) {
enum class addrType {
V4, V6, MagicDNS
}
val type: addrType = when {
ip.contains(":") -> addrType.V6
ip.contains(".") -> addrType.V4
else -> addrType.MagicDNS
}
val typeString: String = when (type) {
addrType.V4 -> "IPv4"
addrType.V6 -> "IPv6"
addrType.MagicDNS -> "MagicDNS"
}
val address: String = when (type) {
addrType.MagicDNS -> ip
else -> ip.split("/").first()
}
}

@ -0,0 +1,48 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.model.UserID
import com.tailscale.ipn.ui.service.IpnModel
data class PeerSet(val user: Tailcfg.UserProfile?, val peers: List<Tailcfg.Node>)
class PeerCategorizer(val model: IpnModel) {
fun groupedAndFilteredPeers(searchTerm: String = ""): List<PeerSet> {
val netmap: Netmap.NetworkMap = model.netmap.value ?: return emptyList()
val peers: List<Tailcfg.Node> = netmap.Peers ?: return emptyList()
val selfNode = netmap.SelfNode
val grouped = mutableMapOf<UserID, MutableList<Tailcfg.Node>>()
for (peer in (peers + selfNode)) {
// (jonathan) TODO: MDM -> There are a number of MDM settings to hide devices from the user
// (jonathan) TODO: MDM -> currentUser, otherUsers, taggedDevices
val userId = peer.User
if (searchTerm.isNotEmpty() && !peer.ComputedName.contains(searchTerm, ignoreCase = true)) {
continue
}
if (!grouped.containsKey(userId)) {
grouped[userId] = mutableListOf()
}
grouped[userId]?.add(peer)
}
val selfPeers = grouped[selfNode.User] ?: emptyList()
grouped.remove(selfNode.User)
var sorted = grouped.map { (userId, peers) ->
val profile = netmap.userProfile(userId)
PeerSet(profile, peers)
}.sortedBy {
it.user?.DisplayName ?: "Unknown User"
}
val me = netmap.currentUserProfile()
return listOf(PeerSet(me, selfPeers)) + sorted
}
}

@ -0,0 +1,11 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
class TimeUtil {
fun keyExpiryFromGoTime(goTime: String?): String {
// (jonathan) TODO: Turn these time strings into 'in 4 months', 'in 2 days', 'in 1 year', etc
return goTime ?: "Never"
}
}

@ -0,0 +1,18 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
@Composable
fun ExitNodePicker(viewModel: ExitNodePickerViewModel) {
Column {
Text(text = "Future Home of Picking Exit Nodes")
}
}

@ -0,0 +1,254 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.viewModel.MainViewModel
import kotlinx.coroutines.flow.StateFlow
// Navigation actions for the MainView
data class MainViewNavigation(
val onNavigateToSettings: () -> Unit,
val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
val onNavigateToExitNodes: () -> Unit)
@Composable
fun MainView(viewModel: MainViewModel, navigation: MainViewNavigation) {
Surface(color = MaterialTheme.colorScheme.background) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.Center
) {
val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
val user = viewModel.loggedInUser.collectAsState(initial = null)
Row(modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically) {
val isOn = viewModel.vpnToggleState.collectAsState(initial = false)
Switch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value)
StateDisplay(viewModel.stateStr, viewModel.userName)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
SettingsButton(user.value, navigation.onNavigateToSettings)
}
}
// (jonathan) TODO: Show the selected exit node name here.
if (state.value == Ipn.State.Running) {
ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, "None")
}
when (state.value) {
Ipn.State.Running -> PeerList(
searchTerm = viewModel.searchTerm,
peers = viewModel.peers,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { viewModel.searchPeers(it) })
Ipn.State.Starting -> StartingView()
else ->
ConnectView(
user.value,
{ viewModel.toggleVpn() },
{ viewModel.login() }
)
}
}
}
}
@Composable
fun ExitNodeStatus(navAction: () -> Unit, exitNode: String = "None") {
Box(modifier = Modifier
.clickable { navAction() }
.padding(horizontal = 8.dp)
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
.background(MaterialTheme.colorScheme.secondaryContainer)
.fillMaxWidth()) {
Column(modifier = Modifier.padding(6.dp)) {
Text(text = "Exit Node", style = MaterialTheme.typography.titleMedium)
Row {
Text(text = exitNode, style = MaterialTheme.typography.bodyMedium)
Icon(
Icons.Outlined.ArrowDropDown,
null,
)
}
}
}
}
@Composable
fun StateDisplay(state: StateFlow<String>, tailnet: String) {
val stateStr = state.collectAsState(initial = "--")
Column(modifier = Modifier.padding(6.dp)) {
Text(text = "${tailnet}", style = MaterialTheme.typography.titleMedium)
Text(text = "${stateStr.value}", style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
fun SettingsButton(user: IpnLocal.LoginProfile?, action: () -> Unit) {
// (jonathan) TODO: On iOS this is the users avatar or a letter avatar.
IconButton(
modifier = Modifier.size(24.dp),
onClick = { action() }
) {
Icon(
Icons.Outlined.Settings,
null,
)
}
}
@Composable
fun StartingView() {
// (jonathan) TODO: On iOS this is the game-of-life Tailscale animation.
Column(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.secondaryContainer),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) { Text(text = "Starting...", style = MaterialTheme.typography.titleMedium) }
}
@Composable
fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAction: () -> Unit) {
Column(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.secondaryContainer),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "Not Connected", style = MaterialTheme.typography.titleMedium)
if (user != null) {
val tailnetName = user.NetworkProfile?.DomainName ?: ""
Text(
"Connect to your ${tailnetName} tailnet",
style = MaterialTheme.typography.bodyMedium
)
Button(onClick = connectAction) { Text(text = "Connect") }
} else {
Button(onClick = loginAction) { Text(text = "Log In") }
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PeerList(searchTerm: StateFlow<String>, peers: StateFlow<List<PeerSet>>, onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, onSearch: (String) -> Unit) {
val peerList = peers.collectAsState(initial = emptyList<PeerSet>())
var searching = false
val searchTermStr by searchTerm.collectAsState(initial = "")
SearchBar(
query = searchTermStr,
onQueryChange = onSearch,
onSearch = onSearch,
active = true,
onActiveChange = { searching = it },
shape = RoundedCornerShape(10.dp),
leadingIcon = { Icon(Icons.Outlined.Search, null) },
tonalElevation = 2.dp,
shadowElevation = 2.dp,
colors = SearchBarDefaults.colors(),
modifier = Modifier.fillMaxWidth()) {
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.background(MaterialTheme.colorScheme.secondaryContainer),
) {
peerList.value.forEach { peerSet ->
ListItem(headlineContent = {
Text(text = peerSet.user?.DisplayName
?: "Unknown User", style = MaterialTheme.typography.titleLarge)
})
peerSet.peers.forEach { peer ->
ListItem(
modifier = Modifier.clickable {
onNavigateToPeerDetails(peer)
},
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
val color: Color = if (peer.Online ?: false) {
Color.Green
} else {
Color.Gray
}
Box(modifier = Modifier
.size(8.dp)
.background(color = color, shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp))
Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium)
}
},
supportingContent = {
Text(
text = peer.Addresses?.first()?.split("/")?.first() ?: "",
style = MaterialTheme.typography.bodyMedium
)
},
trailingContent = {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
)
}
}
}
}
}

@ -0,0 +1,104 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
@Composable
fun PeerDetails(viewModel: PeerDetailsViewModel) {
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
Column(modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = viewModel.nodeName, style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier
.size(8.dp)
.background(color = viewModel.connectedColor, shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp))
Text(text = viewModel.connectedStr, style = MaterialTheme.typography.bodyMedium)
}
}
Spacer(modifier = Modifier.size(8.dp))
Text(text = "TAILSCALE ADDRESSES", style = MaterialTheme.typography.titleMedium)
Column(modifier = Modifier
.clip(shape = RoundedCornerShape(8.dp))
.background(color = MaterialTheme.colorScheme.secondaryContainer)
.fillMaxWidth()) {
viewModel.addresses.forEach {
AddressRow(address = it.address, type = it.typeString)
}
}
Spacer(modifier = Modifier.size(16.dp))
Column(modifier = Modifier
.clip(shape = RoundedCornerShape(8.dp))
.background(color = MaterialTheme.colorScheme.secondaryContainer)
.fillMaxWidth()) {
viewModel.info.forEach {
ValueRow(title = it.title, value = it.value)
}
}
}
}
@Composable
fun AddressRow(address: String, type: String) {
val localClipboardManager = LocalClipboardManager.current
Row(modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.clickable(onClick = { localClipboardManager.setText(AnnotatedString(address)) })) {
Column {
Text(text = address, style = MaterialTheme.typography.titleMedium)
Text(text = type, style = MaterialTheme.typography.bodyMedium)
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Icon(Icons.Outlined.Share, null)
}
}
}
@Composable
fun ValueRow(title: String, value: String) {
Row(modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.fillMaxWidth()) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = value, style = MaterialTheme.typography.bodyMedium)
}
}
}

@ -0,0 +1,18 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.tailscale.ipn.ui.viewModel.SettingsViewModel
@Composable
fun Settings(viewModel: SettingsViewModel) {
Column {
Text(text = "Future Home of Settings")
}
}

@ -0,0 +1,11 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.ViewModel
import com.tailscale.ipn.ui.service.IpnModel
class ExitNodePickerViewModel(val model: IpnModel) : ViewModel() {
}

@ -0,0 +1,99 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.ui.model.Ipn.State
import com.tailscale.ipn.ui.service.IpnActions
import com.tailscale.ipn.ui.service.IpnModel
import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class MainViewModel(val model: IpnModel, val actions: IpnActions) : ViewModel() {
private val _stateStr = MutableStateFlow<String>("")
private val _tailnetName = MutableStateFlow<String>("")
private val _vpnToggleState = MutableStateFlow<Boolean>(false)
private val _peers = MutableStateFlow<List<PeerSet>>(emptyList<PeerSet>())
// The user readable state of the system
val stateStr = _stateStr.asStateFlow()
// The current state of the IPN for determining view visibility
val ipnState = model.state
// The name of the tailnet
val tailnetName = _tailnetName.asStateFlow()
// The expected state of the VPN toggle
val vpnToggleState = _vpnToggleState.asStateFlow()
// The list of peers
val peers = _peers.asStateFlow()
// The logged in user
val loggedInUser = model.loggedInUser
// The active search term for filtering peers
val searchTerm = MutableStateFlow<String>("")
init {
viewModelScope.launch {
model.state.collect { state ->
_stateStr.value = state.userString()
_vpnToggleState.value = (state == State.Running || state == State.Starting)
}
}
viewModelScope.launch {
model.netmap.collect { netmap ->
_tailnetName.value = netmap?.Domain ?: ""
_peers.value = PeerCategorizer(model).groupedAndFilteredPeers(searchTerm.value)
}
}
}
fun searchPeers(searchTerm: String) {
this.searchTerm.value = searchTerm
viewModelScope.launch {
_peers.value = PeerCategorizer(model).groupedAndFilteredPeers(searchTerm)
}
}
val userName: String
get() {
return loggedInUser.value?.Name ?: ""
}
fun toggleVpn() {
when (model.state.value) {
State.Running -> actions.stopVPN()
else -> actions.startVPN()
}
}
fun login() {
actions.login()
}
}
private fun State?.userString(): String {
return when (this) {
State.NoState -> "Waiting..."
State.InUseOtherUser -> "--"
State.NeedsLogin -> "Please Login"
State.NeedsMachineAuth -> "--"
State.Stopped -> "Stopped"
State.Starting -> "Starting"
State.Running -> "Connected"
else -> "--"
}
}

@ -0,0 +1,44 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
import com.tailscale.ipn.ui.service.IpnModel
import com.tailscale.ipn.ui.util.DisplayAddress
import com.tailscale.ipn.ui.util.TimeUtil
data class PeerSettingInfo(val title: String, val value: String)
class PeerDetailsViewModel(val model: IpnModel, val nodeId: String) : ViewModel() {
var addresses: List<DisplayAddress> = emptyList()
var info: List<PeerSettingInfo> = emptyList()
val nodeName: String
val connectedStr: String
val connectedColor: Color
init {
val peer = model.netmap.value?.Peers?.find { it.StableID == nodeId }
peer?.Addresses?.let {
addresses = it.map { addr ->
DisplayAddress(addr)
}
}
peer?.let { p ->
info = listOf(
PeerSettingInfo("OS", p.Hostinfo?.OS ?: ""),
PeerSettingInfo("Key Expiry", TimeUtil().keyExpiryFromGoTime(p.KeyExpiry))
)
}
nodeName = peer?.ComputedName ?: ""
connectedStr = if (peer?.Online == true) "Connected" else "Not Connected"
connectedColor = if (peer?.Online == true) Color.Green else Color.Gray
}
}

@ -0,0 +1,12 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.ViewModel
import com.tailscale.ipn.ui.service.IpnModel
class SettingsViewModel(val model: IpnModel) : ViewModel() {
}

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

@ -69,7 +69,7 @@ func Java_com_tailscale_ipn_ui_localapi_LocalApiClient_doRequest(
jrespBody := jni.JavaString(jenv, resp)
respBody := jni.Value(jrespBody)
cookie := jni.Value(jcookie)
onResponse := jni.GetMethodID(jenv, shim.clientClass, "onResponse", "(Lbyte[];Ljava/lang/String;)V")
onResponse := jni.GetMethodID(jenv, shim.clientClass, "onResponse", "(Ljava/lang/String;Ljava/lang/String;)V")
jni.CallVoidMethod(jenv, jni.Object(cls), onResponse, respBody, cookie)
}

Loading…
Cancel
Save