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 7 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_legacy/local.properties
android/.idea android/.idea
android/local.properties android/local.properties
.idea
# Output files from the Makefile: # Output files from the Makefile:
tailscale-debug.apk tailscale-debug.apk

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

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

@ -3,6 +3,7 @@
package com.tailscale.ipn; package com.tailscale.ipn;
import android.util.Log;
import android.os.Build; import android.os.Build;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.content.Intent; 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.Ipn
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.IpnState import com.tailscale.ipn.ui.model.IpnState
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@ -20,16 +18,19 @@ typealias BugReportIdHandler = (Result<BugReportID>) -> Unit
typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit typealias PrefsHandler = (Result<Ipn.Prefs>) -> Unit
class LocalApiClient(private val scope: CoroutineScope) { class LocalApiClient(private val scope: CoroutineScope) {
init {
Log.d("LocalApiClient", "LocalApiClient created")
}
companion object { companion object {
private val _isReady = MutableStateFlow(false) val isReady = CompletableDeferred<Boolean>()
val isReady: StateFlow<Boolean> = _isReady
// Called by the backend when the localAPI is ready to accept requests. // Called by the backend when the localAPI is ready to accept requests.
@JvmStatic @JvmStatic
@Suppress("unused") @Suppress("unused")
fun onReady() { fun onReady() {
_isReady.value = true isReady.complete(true)
Log.d("LocalApiClient", "LocalApiClient is ready") 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. // the corresponding request. Cookies must be unique for each request.
private external fun doRequest(request: String, method: String, body: ByteArray?, cookie: String) 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 { scope.launch {
isReady.first { it } isReady.await()
Log.d("LocalApiClient", "Executing request:${request.method}:${request.path}") Log.d("LocalApiClient", "Executing request:${request.method}:${request.path}")
requests[request.cookie] = request requests[request.cookie] = request
doRequest(request.path, request.method, request.body, request.cookie) 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 // This is called from the JNI layer to publish localAPIResponses. This should execute on the
// same thread that called doRequest. // same thread that called doRequest.
@Suppress("unused") @Suppress("unused")
fun onResponse(response: ByteArray, cookie: String) { fun onResponse(response: String, cookie: String) {
requests.remove(cookie)?.let { request -> 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 // 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") } } ?: { Log.e("LocalApiClient", "Received response for unknown request: $cookie") }
} }
@ -98,6 +99,14 @@ class LocalApiClient(private val scope: CoroutineScope) {
executeRequest(req) 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 // (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 // 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, // 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 // Check if the response was a generic error
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
fun parseError(respData: ByteArray): Error { fun parseError(respData: ByteArray): Error {

@ -21,8 +21,8 @@ class Result<T> {
this.error = error this.error = error
} }
constructor(success: T) : this(success, null) {} constructor(success: T) : this(success, null)
constructor(error: Error) : this(null, error) {} constructor(error: Error) : this(null, error)
var successful: Boolean = false var successful: Boolean = false
get() = success != null get() = success != null

@ -6,7 +6,8 @@ package com.tailscale.ipn.ui.model
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
class Dns { 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 @Serializable
data class OSConfig( data class OSConfig(

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

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

@ -3,26 +3,64 @@
package com.tailscale.ipn.ui.service 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.localapi.LocalApiClient
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob 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 { class IpnManager {
private var notifier = Notifier() private var notifier = Notifier()
private var scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) 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. var apiClient = LocalApiClient(scope)
companion object { val model = IpnModel(notifier, apiClient, scope)
@Volatile
private var instance: IpnManager? = null 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) { fun updatePrefs(prefs: Ipn.MaskedPrefs, callback: PrefChangeCallback) {
instance ?: IpnManager().also { instance = it } // (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.model.Netmap
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -22,7 +24,8 @@ class IpnModel(
) { ) {
private var notifierSessions: MutableList<String> = mutableListOf() 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 _netmap: MutableStateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
private val _prefs: MutableStateFlow<Ipn.Prefs?> = MutableStateFlow(null) private val _prefs: MutableStateFlow<Ipn.Prefs?> = MutableStateFlow(null)
private val _engineStatus: MutableStateFlow<Ipn.EngineStatus?> = MutableStateFlow(null) private val _engineStatus: MutableStateFlow<Ipn.EngineStatus?> = MutableStateFlow(null)
@ -36,7 +39,7 @@ class IpnModel(
MutableStateFlow(null) MutableStateFlow(null)
val state: StateFlow<Ipn.State?> = _state val state: StateFlow<Ipn.State> = _state
val netmap: StateFlow<Netmap.NetworkMap?> = _netmap val netmap: StateFlow<Netmap.NetworkMap?> = _netmap
val prefs: StateFlow<Ipn.Prefs?> = _prefs val prefs: StateFlow<Ipn.Prefs?> = _prefs
val engineStatus: StateFlow<Ipn.EngineStatus?> = _engineStatus val engineStatus: StateFlow<Ipn.EngineStatus?> = _engineStatus
@ -56,7 +59,7 @@ class IpnModel(
// Backend Observation // Backend Observation
private suspend fun loadUserProfiles() { private suspend fun loadUserProfiles() {
LocalApiClient.isReady.first { it } LocalApiClient.isReady.await()
apiClient.getProfiles { result -> apiClient.getProfiles { result ->
result.success?.let { users -> _loginProfiles.value = users } result.success?.let { users -> _loginProfiles.value = users }
@ -70,8 +73,10 @@ class IpnModel(
} }
private fun onNotifyChange(notify: Ipn.Notify) { private fun onNotifyChange(notify: Ipn.Notify) {
notify.State?.let { state ->
notify.State?.let { state -> _state.value = Ipn.State.fromInt(state) } Log.d("IpnModel", "State changed: $state")
_state.value = Ipn.State.fromInt(state)
}
notify.NetMap?.let { netmap -> _netmap.value = netmap } 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) jrespBody := jni.JavaString(jenv, resp)
respBody := jni.Value(jrespBody) respBody := jni.Value(jrespBody)
cookie := jni.Value(jcookie) 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) jni.CallVoidMethod(jenv, jni.Object(cls), onResponse, respBody, cookie)
} }

Loading…
Cancel
Save