diff --git a/.gitignore b/.gitignore index cd4e8c6..b5d5ae6 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/android/build.gradle b/android/build.gradle index d84bbcd..593ff8b 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -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' } diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 8523e9d..6c00544 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -17,7 +17,7 @@ - = emptyList() +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIClient.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIClient.kt index d9421b3..12b371e 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIClient.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIClient.kt @@ -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) -> Unit typealias PrefsHandler = (Result) -> Unit class LocalApiClient(private val scope: CoroutineScope) { + init { + Log.d("LocalApiClient", "LocalApiClient created") + } + companion object { - private val _isReady = MutableStateFlow(false) - val isReady: StateFlow = _isReady + val isReady = CompletableDeferred() // 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 executeRequest(request: LocalAPIRequest) { + fun executeRequest(request: LocalAPIRequest) { 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(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, diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIRequest.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIRequest.kt index 4829bbb..65b9a06 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIRequest.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIRequest.kt @@ -111,6 +111,12 @@ class LocalAPIRequest( } } + fun startLoginInteractive(responseHandler: (Result) -> Unit): LocalAPIRequest { + 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 { diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/Result.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/Result.kt index 5965089..ee6a31d 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/Result.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/Result.kt @@ -8,7 +8,7 @@ package com.tailscale.ipn.ui.localapi class Result { 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 { 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 } \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Dns.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Dns.kt index cf047a0..a655ab8 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Dns.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Dns.kt @@ -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?) + @Serializable + data class HostEntry(val addr: Addr?, val hosts: List?) @Serializable data class OSConfig( diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt index 32d76ac..9316986 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt @@ -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? = null, - var ClientVersion: Tailcfg.ClientVersion? = null, - var TailFSShares: Map? = 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? = null, - var AdvertiseTags: List? = 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? = null, + var ClientVersion: Tailcfg.ClientVersion? = null, + var TailFSShares: Map? = 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? = null, + var AdvertiseTags: List? = 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, - ) + @Serializable + data class EngineStatus( + val RBytes: Long, + val WBytes: Long, + val NumLive: Int, + val LivePeers: Map, + ) - @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 = "", + ) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt b/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt index 826ca42..20416ff 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt @@ -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) } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt index 5669300..70f4fd8 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt @@ -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) diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt index e0158c1..529ce10 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Types.kt @@ -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 diff --git a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt index df2acb4..0a3164a 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt @@ -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 = _isReady + private val isReady = CompletableDeferred() // 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 ) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt b/android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt index e4b8d36..34c3819 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt @@ -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) -> 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) } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/service/IpnModel.kt b/android/src/main/java/com/tailscale/ipn/ui/service/IpnModel.kt index 2f0d5e3..56505e1 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/service/IpnModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/service/IpnModel.kt @@ -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 = mutableListOf() - private val _state: MutableStateFlow = MutableStateFlow(null) + + private val _state: MutableStateFlow = MutableStateFlow(Ipn.State.NoState) private val _netmap: MutableStateFlow = MutableStateFlow(null) private val _prefs: MutableStateFlow = MutableStateFlow(null) private val _engineStatus: MutableStateFlow = MutableStateFlow(null) @@ -36,7 +39,7 @@ class IpnModel( MutableStateFlow(null) - val state: StateFlow = _state + val state: StateFlow = _state val netmap: StateFlow = _netmap val prefs: StateFlow = _prefs val engineStatus: StateFlow = _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 } diff --git a/android/src/main/java/com/tailscale/ipn/ui/theme/Color.kt b/android/src/main/java/com/tailscale/ipn/ui/theme/Color.kt new file mode 100644 index 0000000..d8bae61 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/theme/Color.kt @@ -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) diff --git a/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt b/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt new file mode 100644 index 0000000..8ae74d1 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt @@ -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 + ) +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/DisplayAddress.kt b/android/src/main/java/com/tailscale/ipn/ui/util/DisplayAddress.kt new file mode 100644 index 0000000..5177806 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/DisplayAddress.kt @@ -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() + } +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt b/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt new file mode 100644 index 0000000..c4b1b16 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt @@ -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) + +class PeerCategorizer(val model: IpnModel) { + fun groupedAndFilteredPeers(searchTerm: String = ""): List { + val netmap: Netmap.NetworkMap = model.netmap.value ?: return emptyList() + val peers: List = netmap.Peers ?: return emptyList() + val selfNode = netmap.SelfNode + + val grouped = mutableMapOf>() + 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 + } +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/TimeUtil.kt b/android/src/main/java/com/tailscale/ipn/ui/util/TimeUtil.kt new file mode 100644 index 0000000..27d1df2 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/TimeUtil.kt @@ -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" + } +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt new file mode 100644 index 0000000..983514d --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt @@ -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") + } +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt new file mode 100644 index 0000000..7611cbc --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -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, 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, peers: StateFlow>, onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, onSearch: (String) -> Unit) { + val peerList = peers.collectAsState(initial = emptyList()) + 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) + } + ) + } + } + } + } +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt b/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt new file mode 100644 index 0000000..859ec05 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt @@ -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) + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt new file mode 100644 index 0000000..625983e --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt @@ -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") + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt new file mode 100644 index 0000000..eb30bc3 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt @@ -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() { +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt new file mode 100644 index 0000000..13e0ad6 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -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("") + private val _tailnetName = MutableStateFlow("") + private val _vpnToggleState = MutableStateFlow(false) + private val _peers = MutableStateFlow>(emptyList()) + + // 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("") + + + 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 -> "--" + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt new file mode 100644 index 0000000..901c1af --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt @@ -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 = emptyList() + var info: List = 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 + } +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt new file mode 100644 index 0000000..7325a71 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt @@ -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() { + +} \ No newline at end of file diff --git a/cmd/cmd.iml b/cmd/cmd.iml new file mode 100644 index 0000000..8021953 --- /dev/null +++ b/cmd/cmd.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/cmd/localapiservice/localapishim.go b/cmd/localapiservice/localapishim.go index d17c6db..a0b8553 100644 --- a/cmd/localapiservice/localapishim.go +++ b/cmd/localapiservice/localapishim.go @@ -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) }