diff --git a/android/build.gradle b/android/build.gradle index 593ff8b..181a0bc 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -29,7 +29,7 @@ android { ndkVersion "23.1.7779620" compileSdkVersion 34 defaultConfig { - minSdkVersion 22 + minSdkVersion 26 targetSdkVersion 34 versionCode 198 versionName "1.59.53-t0f042b981-g1017015de26" diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 085c028..acc39b1 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -4,9 +4,13 @@ package com.tailscale.ipn + +import android.content.Intent +import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.lifecycle.lifecycleScope import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -14,15 +18,20 @@ 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.AboutView +import com.tailscale.ipn.ui.view.BugReportView 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.view.SettingsNav 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 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { @@ -43,11 +52,16 @@ class MainActivity : ComponentActivity() { onNavigateToExitNodes = { navController.navigate("exitNodes") } ) + val settingsNav = SettingsNav( + onNavigateToBugReport = { navController.navigate("bugReport") }, + onNavigateToAbout = { navController.navigate("about") } + ) + composable("main") { MainView(viewModel = MainViewModel(manager.model, manager.actions), navigation = mainViewNav) } composable("settings") { - Settings(SettingsViewModel(manager.model)) + Settings(SettingsViewModel(manager.model, manager.actions, settingsNav)) } composable("exitNodes") { ExitNodePicker(ExitNodePickerViewModel(manager.model)) @@ -56,9 +70,37 @@ class MainActivity : ComponentActivity() { PeerDetails(PeerDetailsViewModel(manager.model, nodeId = it.arguments?.getString("nodeId") ?: "")) } + composable("bugReport") { + BugReportView() + } + composable("about") { + AboutView() + } } } } } + + init { + // Watch the model's browseToURL and launch the browser when it changes + // This will trigger the login flow + lifecycleScope.launch { + manager.model.browseToURL.collect { url -> + url?.let { + Dispatchers.Main.run { + login(it) + } + } + } + } + } + + fun login(url: String) { + // (jonathan) TODO: This is functional, but the navigation doesn't quite work + // as expected. There's probably a better built in way to do this. This will + // unblock in dev for the time being though. + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + startActivity(browserIntent) + } } 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 12b371e..d5db0ef 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 @@ -22,7 +22,6 @@ class LocalApiClient(private val scope: CoroutineScope) { Log.d("LocalApiClient", "LocalApiClient created") } - companion object { val isReady = CompletableDeferred() @@ -89,6 +88,11 @@ class LocalApiClient(private val scope: CoroutineScope) { executeRequest(req) } + fun editPrefs(prefs: Ipn.MaskedPrefs, responseHandler: (Result) -> Unit) { + val req = LocalAPIRequest.editPrefs(prefs, responseHandler) + executeRequest(req) + } + fun getProfiles(responseHandler: (Result>) -> Unit) { val req = LocalAPIRequest.profiles(responseHandler) executeRequest(req) @@ -107,6 +111,14 @@ class LocalApiClient(private val scope: CoroutineScope) { executeRequest(req) } + fun logout() { + val req = LocalAPIRequest.logout { result -> + result.success?.let { Log.d("LocalApiClient", "Logout started: $it") } + ?: run { Log.e("LocalApiClient", "Error starting logout: ${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, @@ -123,8 +135,6 @@ class LocalApiClient(private val scope: CoroutineScope) { // start // startLoginInteractive // logout - // profiles - // currentProfile // addProfile // switchProfile // deleteProfile 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 65b9a06..8203e21 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 @@ -9,6 +9,7 @@ import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnState import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import java.util.UUID @@ -46,8 +47,11 @@ private object Endpoint { // // (jonathan) TODO: Audit local API for all of the possible error results and clean // it up if possible. -enum class APIErrorVals(private val rawValue: String) { - UNPARSEABLE_RESPONSE("Unparseable localAPI response"), NOT_READY("Not Ready"); + +enum class APIErrorVals(val rawValue: String) { + UNPARSEABLE_RESPONSE("Unparseable localAPI response"), + NOT_READY("Not Ready"), + NO_PREFS("Current prefs not available"); fun toError(): Error { return Error(rawValue) @@ -55,31 +59,49 @@ enum class APIErrorVals(private val rawValue: String) { } class LocalAPIRequest( - path: String, - val method: String, - val body: ByteArray? = null, - val parser: (ByteArray) -> Unit, + path: String, + val method: String, + val body: ByteArray? = null, + val parser: (ByteArray) -> Unit, ) { val path = "/localapi/v0/$path" val cookie = UUID.randomUUID().toString() companion object { + val decoder = Json { ignoreUnknownKeys = true } fun get(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) = - LocalAPIRequest( - method = "GET", path = path, body = body, parser = parser - ) + LocalAPIRequest( + method = "GET", + path = path, + body = body, + parser = parser + ) fun put(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) = - LocalAPIRequest( - method = "PUT", path = path, body = body, parser = parser - ) - - private fun post(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) = - LocalAPIRequest( - method = "POST", path = path, body = body, parser = parser - ) + LocalAPIRequest( + method = "PUT", + path = path, + body = body, + parser = parser + ) + + fun post(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) = + LocalAPIRequest( + method = "POST", + path = path, + body = body, + parser = parser + ) + + fun patch(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) = + LocalAPIRequest( + method = "PATCH", + path = path, + body = body, + parser = parser + ) fun status(responseHandler: StatusResponseHandler): LocalAPIRequest { return get(Endpoint.STATUS) { resp -> @@ -99,6 +121,14 @@ class LocalAPIRequest( } } + fun editPrefs(prefs: Ipn.MaskedPrefs, responseHandler: (Result) -> Unit): LocalAPIRequest { + val body = Json.encodeToString(prefs).toByteArray() + return patch(Endpoint.PREFS, body) { resp -> + responseHandler(decode(resp)) + } + } + + fun profiles(responseHandler: (Result>) -> Unit): LocalAPIRequest> { return get(Endpoint.PROFILES) { resp -> responseHandler(decode>(resp)) @@ -117,6 +147,12 @@ class LocalAPIRequest( } } + fun logout(responseHandler: (Result) -> Unit): LocalAPIRequest { + return post(Endpoint.LOGOUT) { 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/model/IpnState.kt b/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt index 20416ff..cf39c50 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 @@ -112,5 +112,9 @@ class IpnLocal { val UserProfile: Tailcfg.UserProfile, val NetworkProfile: Tailcfg.NetworkProfile? = null, val LocalUserID: String, - ) + ) { + fun isEmpty(): Boolean { + return ID.isEmpty() + } + } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt b/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt index 57ce19d..24aa244 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/NetMap.kt @@ -31,6 +31,13 @@ class Netmap { return UserProfiles[id.toString()] } + fun getPeer(id: StableNodeID): Tailcfg.Node? { + if(id == SelfNode.StableID) { + return SelfNode + } + return Peers?.find { it.StableID == id } + } + override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is NetworkMap) return false 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 70f4fd8..287d478 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 @@ -71,7 +71,10 @@ class Tailcfg { var Capabilities: List? = null, var ComputedName: String, var ComputedNameWithHost: String - ) + ) { + val isAdmin: Boolean + get() = (Capabilities ?: emptyList()).contains("https://tailscale.com/cap/is-admin") + } @Serializable data class Service(var Proto: String, var Port: Int, var Description: String? = null) 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 0a3164a..3e2d7a4 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 @@ -37,12 +37,12 @@ class Notifier() { // 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(0), - initialState(1), - prefs(2), - netmap(4), - noPrivateKey(8), - initialTailFSShares(16) + engineUpdates(1), + initialState(2), + prefs(4), + netmap(8), + noPrivateKey(16), + initialTailFSShares(32) } companion object { 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 34c3819..99da3a0 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 @@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob + typealias PrefChangeCallback = (Result) -> Unit // Abstracts the actions that can be taken by the UI so that the concept of an IPNManager @@ -22,6 +23,8 @@ data class IpnActions( val startVPN: () -> Unit, val stopVPN: () -> Unit, val login: () -> Unit, + val logout: () -> Unit, + val openAdminConsole: () -> Unit, val updatePrefs: (Ipn.MaskedPrefs, PrefChangeCallback) -> Unit ) @@ -35,30 +38,27 @@ class IpnManager { val actions = IpnActions( startVPN = { startVPN() }, stopVPN = { stopVPN() }, - login = { login() }, + login = { apiClient.startLoginInteractive() }, + logout = { apiClient.logout() }, + openAdminConsole = { /* TODO */ }, 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" + intent.action = IPNReceiver.INTENT_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" + intent.action = IPNReceiver.INTENT_DISCONNECT_VPN context.sendBroadcast(intent) } - fun login() { - apiClient.startLoginInteractive() - } - 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 56505e1..3af7c48 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,24 +10,21 @@ 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 import kotlinx.coroutines.launch class IpnModel( - notifier: Notifier, - private val apiClient: LocalApiClient, - scope: CoroutineScope + notifier: Notifier, + val apiClient: LocalApiClient, + val scope: CoroutineScope ) { private var notifierSessions: MutableList = mutableListOf() private val _state: MutableStateFlow = MutableStateFlow(Ipn.State.NoState) private val _netmap: MutableStateFlow = MutableStateFlow(null) - private val _prefs: MutableStateFlow = MutableStateFlow(null) + protected val _prefs: MutableStateFlow = MutableStateFlow(null) private val _engineStatus: MutableStateFlow = MutableStateFlow(null) private val _tailFSShares: MutableStateFlow?> = MutableStateFlow(null) private val _browseToURL: MutableStateFlow = MutableStateFlow(null) @@ -36,7 +33,7 @@ class IpnModel( private val _loggedInUser: MutableStateFlow = MutableStateFlow(null) private val _loginProfiles: MutableStateFlow?> = - MutableStateFlow(null) + MutableStateFlow(null) val state: StateFlow = _state @@ -55,7 +52,6 @@ class IpnModel( return prefs.value != null } - // Backend Observation private suspend fun loadUserProfiles() { @@ -63,17 +59,23 @@ class IpnModel( apiClient.getProfiles { result -> result.success?.let { users -> _loginProfiles.value = users } - ?: run { Log.e("IpnManager", "Error loading profiles: ${result.error}") } + ?: run { Log.e("IpnManager", "Error loading profiles: ${result.error}") } } apiClient.getCurrentProfile { result -> result.success?.let { user -> _loggedInUser.value = user } - ?: run { Log.e("IpnManager", "Error loading profiles: ${result.error}") } + ?: run { Log.e("IpnManager", "Error loading profiles: ${result.error}") } } } private fun onNotifyChange(notify: Ipn.Notify) { notify.State?.let { state -> + // Refresh the user profiles if we're transitioning out of the + // NeedsLogin state. + if (_state.value == Ipn.State.NeedsLogin) { + scope.launch { loadUserProfiles() } + } + Log.d("IpnModel", "State changed: $state") _state.value = Ipn.State.fromInt(state) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/service/PrefsEditor.kt b/android/src/main/java/com/tailscale/ipn/ui/service/PrefsEditor.kt new file mode 100644 index 0000000..0e6669f --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/service/PrefsEditor.kt @@ -0,0 +1,76 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.service + +import com.tailscale.ipn.ui.localapi.APIErrorVals +import com.tailscale.ipn.ui.localapi.Result +import com.tailscale.ipn.ui.model.Ipn + + +// Handles all types of preference modifications typically invoked by the UI. +// Callers generally shouldn't care about the returned prefs value - the source of +// truth is the IPNModel, who's prefs flow will change in value to reflect the true +// value of the pref setting in the back end (and will match the value returned here). +// Generally, you will want to inspect the returned value in the callback for errors +// to indicate why a particular setting did not change in the interface. +// +// Usage: +// - User/Interface changed to new value. Render the new value. +// - Submit the new value to the PrefsEditor +// - Observe the prefs on the IpnModel and update the UI when/if the value changes. +// For a typical flow, the changed value should reflect the value already shown. +// - Inform the user of any error which may have occurred +// +// The "toggle' functions here will attempt to set the pref value to the inverse of +// what is currently known in the IpnModel.prefs. If IpnModel.prefs is not available, +// the callback will be called with a NO_PREFS error + +fun IpnModel.setWantRunning(wantRunning: Boolean, callback: (Result) -> Unit) { + Ipn.MaskedPrefs().WantRunning = wantRunning + apiClient.editPrefs(Ipn.MaskedPrefs(), callback) +} + +fun IpnModel.toggleCorpDNS(callback: (Result) -> Unit) { + val prefs = prefs.value ?: run { + callback(Result(Error(APIErrorVals.NO_PREFS.rawValue))) + return@toggleCorpDNS + } + + val prefsOut = Ipn.MaskedPrefs() + prefsOut.CorpDNS = !prefs.CorpDNS + apiClient.editPrefs(prefsOut, callback) +} + +fun IpnModel.toggleShieldsUp(callback: (Result) -> Unit) { + val prefs = prefs.value ?: run { + callback(Result(Error(APIErrorVals.NO_PREFS.rawValue))) + return@toggleShieldsUp + } + + val prefsOut = Ipn.MaskedPrefs() + prefsOut.ShieldsUp = !prefs.ShieldsUp + apiClient.editPrefs(prefsOut, callback) +} + +fun IpnModel.setExitNodeId(id: String, callback: (Result) -> Unit) { + val prefsOut = Ipn.MaskedPrefs() + prefsOut.ExitNodeId = id + apiClient.editPrefs(prefsOut, callback) +} + +fun IpnModel.toggleRouteAll(callback: (Result) -> Unit) { + val prefs = prefs.value ?: run { + callback(Result(Error(APIErrorVals.NO_PREFS.rawValue))) + return@toggleRouteAll + } + + val prefsOut = Ipn.MaskedPrefs() + prefsOut.RouteAll = !prefs.RouteAll + apiClient.editPrefs(prefsOut, callback) +} + + + + + 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 index c4b1b16..de98904 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt @@ -32,10 +32,16 @@ class PeerCategorizer(val model: IpnModel) { } grouped[userId]?.add(peer) } - val selfPeers = grouped[selfNode.User] ?: emptyList() + var selfPeers = (grouped[selfNode.User] ?: emptyList()).sortedBy { it.ComputedName } grouped.remove(selfNode.User) - var sorted = grouped.map { (userId, peers) -> + val currentNode = selfPeers.first { it.ID == selfNode.ID } + currentNode.let { + selfPeers = selfPeers.filter { it.ID != currentNode.ID } + selfPeers = listOf(currentNode) + selfPeers + } + + val sorted = grouped.map { (userId, peers) -> val profile = netmap.userProfile(userId) PeerSet(profile, peers) }.sortedBy { diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/Styles.kt b/android/src/main/java/com/tailscale/ipn/ui/util/Styles.kt new file mode 100644 index 0000000..17f8be2 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/Styles.kt @@ -0,0 +1,28 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.util + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp + + +@Composable +fun settingsRowModifier(): Modifier { + return Modifier + .clip(shape = RoundedCornerShape(8.dp)) + .background(color = MaterialTheme.colorScheme.secondaryContainer) + .fillMaxWidth() +} + +@Composable +fun defaultPaddingModifier(): Modifier { + return Modifier.padding(8.dp) +} \ 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 index 27d1df2..6e6150c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/TimeUtil.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/TimeUtil.kt @@ -3,9 +3,43 @@ package com.tailscale.ipn.ui.util +import java.time.Instant +import java.time.format.DateTimeFormatter +import java.util.Date + + 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" + + val time = goTime ?: return "" + val expTime = epochMillisFromGoTime(time) + val now = Instant.now().toEpochMilli() + + val diff = (expTime - now) / 1000 + + if(diff < 0){ + return "expired" + } + + return when (diff) { + in 0..60 -> "under a minute" + in 61..3600 -> "in ${diff / 60} minutes" + in 3601..86400 -> "in ${diff / 3600} hours" + in 86401..2592000 -> "in ${diff / 86400} days" + in 2592001..31536000 -> "in ${diff / 2592000} months" + else -> "in ${diff / 31536000} years" + } + } + + fun epochMillisFromGoTime(goTime: String): Long { + val ta = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(goTime) + val i = Instant.from(ta) + return i.toEpochMilli() + } + + fun dateFromGoString(goTime: String): Date { + val ta = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(goTime) + val i = Instant.from(ta) + return Date.from(i) } } \ 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 index 7611cbc..9945a98 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -173,7 +173,7 @@ fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAc horizontalAlignment = Alignment.CenterHorizontally ) { Text(text = "Not Connected", style = MaterialTheme.typography.titleMedium) - if (user != null) { + if (user != null && !user.isEmpty()) { val tailnetName = user.NetworkProfile?.DomainName ?: "" Text( "Connect to your ${tailnetName} tailnet", 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 index 859ec05..5baf479 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt @@ -22,10 +22,10 @@ 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.util.settingsRowModifier import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel @@ -51,10 +51,7 @@ fun PeerDetails(viewModel: PeerDetailsViewModel) { Text(text = "TAILSCALE ADDRESSES", style = MaterialTheme.typography.titleMedium) - Column(modifier = Modifier - .clip(shape = RoundedCornerShape(8.dp)) - .background(color = MaterialTheme.colorScheme.secondaryContainer) - .fillMaxWidth()) { + Column(modifier = settingsRowModifier()) { viewModel.addresses.forEach { AddressRow(address = it.address, type = it.typeString) } @@ -62,10 +59,7 @@ fun PeerDetails(viewModel: PeerDetailsViewModel) { Spacer(modifier = Modifier.size(16.dp)) - Column(modifier = Modifier - .clip(shape = RoundedCornerShape(8.dp)) - .background(color = MaterialTheme.colorScheme.secondaryContainer) - .fillMaxWidth()) { + Column(modifier = settingsRowModifier()) { viewModel.info.forEach { ValueRow(title = it.title, value = it.value) } @@ -78,7 +72,6 @@ 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 { @@ -102,3 +95,5 @@ fun ValueRow(title: String, value: String) { } } } + + 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 index 625983e..c1c5612 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt @@ -4,15 +4,137 @@ package com.tailscale.ipn.ui.view +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import com.tailscale.ipn.ui.model.IpnLocal +import com.tailscale.ipn.ui.util.defaultPaddingModifier +import com.tailscale.ipn.ui.util.settingsRowModifier +import com.tailscale.ipn.ui.viewModel.Setting +import com.tailscale.ipn.ui.viewModel.SettingType import com.tailscale.ipn.ui.viewModel.SettingsViewModel +data class SettingsNav( + val onNavigateToBugReport: () -> Unit, + val onNavigateToAbout: () -> Unit +) + @Composable fun Settings(viewModel: SettingsViewModel) { - Column { - Text(text = "Future Home of Settings") + Column(modifier = defaultPaddingModifier()) { + viewModel.user?.let { user -> + UserView(profile = user, viewModel.isAdmin, viewModel.adminText(), onClick = { viewModel.ipnActions.openAdminConsole() }) + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { viewModel.ipnActions.logout() }) { + Text(text = "Log Out") + } + } ?: run { + Button(onClick = { viewModel.ipnActions.login() }) { + Text(text = "Sign In") + } + } + + + Spacer(modifier = Modifier.height(8.dp)) + + viewModel.settings.forEach { settingBundle -> + Column(modifier = settingsRowModifier()) { + settingBundle.title?.let { + Text(text = it, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(8.dp)) + } + settingBundle.settings.forEach { setting -> + when (setting.type) { + SettingType.NAV -> { + SettingsNavRow(setting) + } + + SettingType.SWITCH -> { + SettingsSwitchRow(setting) + } + + SettingType.NAV_WITH_TEXT -> { + SettingsNavRow(setting) + } + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + } + } +} + +@Composable +fun UserView(profile: IpnLocal.LoginProfile?, isAdmin: Boolean, adminText: AnnotatedString, onClick: () -> Unit) { + Column(modifier = defaultPaddingModifier()) { + Column(modifier = settingsRowModifier().padding(8.dp)) { + Text(text = profile?.UserProfile?.DisplayName + ?: "", style = MaterialTheme.typography.titleMedium) + Text(text = profile?.Name ?: "", style = MaterialTheme.typography.bodyMedium) + } + + if (isAdmin) { + Column(modifier = Modifier.padding(horizontal = 12.dp)) { + ClickableText(text = adminText, style = MaterialTheme.typography.bodySmall, onClick = { + onClick() + }) + } + } + + } +} + +@Composable +fun SettingsNavRow(setting: Setting) { + val txtVal = setting.value?.collectAsState()?.value ?: "" + val enabled = setting.enabled.collectAsState().value + + Row(modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }) { + Text(text = setting.title) + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { + Text(text = txtVal, style = MaterialTheme.typography.bodyMedium) + } + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null) + } +} + +@Composable +fun SettingsSwitchRow(setting: Setting) { + val swVal = setting.isOn?.collectAsState()?.value ?: false + val enabled = setting.enabled.collectAsState().value + + Row(modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }, verticalAlignment = Alignment.CenterVertically) { + Text(text = setting.title) + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { + Switch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled) + } } } + +@Composable +fun BugReportView() { + Text(text = "Future Home of Bug Reporting") +} + +@Composable +fun AboutView() { + Text(text = "Future Home of About") +} 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 index 901c1af..6fb2e4f 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt @@ -6,13 +6,14 @@ package com.tailscale.ipn.ui.viewModel import androidx.compose.ui.graphics.Color import androidx.lifecycle.ViewModel +import com.tailscale.ipn.ui.model.StableNodeID 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() { +class PeerDetailsViewModel(val model: IpnModel, val nodeId: StableNodeID) : ViewModel() { var addresses: List = emptyList() var info: List = emptyList() @@ -22,7 +23,7 @@ class PeerDetailsViewModel(val model: IpnModel, val nodeId: String) : ViewModel( val connectedColor: Color init { - val peer = model.netmap.value?.Peers?.find { it.StableID == nodeId } + val peer = model.netmap.value?.getPeer(nodeId) peer?.Addresses?.let { addresses = it.map { addr -> DisplayAddress(addr) @@ -36,7 +37,6 @@ class PeerDetailsViewModel(val model: IpnModel, val nodeId: String) : ViewModel( ) } - nodeName = peer?.ComputedName ?: "" connectedStr = if (peer?.Online == true) "Connected" else "Not Connected" connectedColor = if (peer?.Online == true) Color.Green else Color.Gray 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 index 7325a71..9701e6c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt @@ -1,12 +1,97 @@ // 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.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.tailscale.ipn.ui.service.IpnActions import com.tailscale.ipn.ui.service.IpnModel +import com.tailscale.ipn.ui.service.toggleCorpDNS +import com.tailscale.ipn.ui.view.SettingsNav +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + +enum class SettingType { NAV, SWITCH, NAV_WITH_TEXT } + +// Represents a UI setting. +// title: The title of the setting +// type: The type of setting +// enabled: Whether the setting is enabled +// value: The value of the setting for textual settings +// isOn: The value of the setting for switch settings +// onClick: The action to take when the setting is clicked (typicall for navigation) +// onToggle: The action to take when the setting is toggled (typically for switches) +// +// Behavior is undefined if you mix the types here. Switch settings should supply an +// isOn and onToggle, while navigation settings should supply an onClick and an optional +// value +data class Setting( + val title: String, + val type: SettingType, + val enabled: MutableStateFlow = MutableStateFlow(false), + val value: MutableStateFlow? = null, + val isOn: MutableStateFlow? = null, + val onClick: () -> Unit = {}, + val onToggle: (Boolean) -> Unit = {}) + +data class SettingBundle(val title: String? = null, val settings: List) + +class SettingsViewModel(val model: IpnModel, val ipnActions: IpnActions, val navigation: SettingsNav) : ViewModel() { + // The logged in user + val user = model.loggedInUser.value + + // Display name for the logged in user + val userName = user?.UserProfile?.DisplayName ?: "" + val tailnetName = user?.Name ?: "" + val isAdmin = model.netmap.value?.SelfNode?.isAdmin ?: false + + val useDNSSetting = Setting( + "Use Tailscale DNS", + SettingType.SWITCH, + isOn = MutableStateFlow(model.prefs.value?.CorpDNS), + onToggle = { + model.toggleCorpDNS { + // (jonathan) TODO: Error handling + } + }) + + init { + viewModelScope.launch { + // Monitor our prefs for changes and update the displayed values accordingly + model.prefs.collect { prefs -> + useDNSSetting.isOn?.value = prefs?.CorpDNS + useDNSSetting.enabled?.value = prefs != null + } + } + } + + val settings: List = listOf( + SettingBundle(settings = listOf( + useDNSSetting, + )), + // General settings, always enabled + SettingBundle(settings = listOf( + Setting("About", SettingType.NAV, onClick = { navigation.onNavigateToAbout() }, enabled = MutableStateFlow(true)), + Setting("Bug Report", SettingType.NAV, onClick = { navigation.onNavigateToBugReport() }, enabled = MutableStateFlow(true)) + )) + ) -class SettingsViewModel(val model: IpnModel) : ViewModel() { + fun adminText(): AnnotatedString { + val annotatedString = buildAnnotatedString { + append("You can manage your account from the admin console. ") + pushStringAnnotation(tag = "policy", annotation = "https://google.com/policy") + withStyle(style = SpanStyle(color = Color.Blue)) { + append("View admin console...") + } + pop() + } + return annotatedString + } } \ No newline at end of file