diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index a449bf0..a91325e 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -30,27 +30,29 @@ import com.tailscale.ipn.mdm.MDMSettingsChangedReceiver import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.localapi.Request import com.tailscale.ipn.ui.model.Ipn +import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.notifier.HealthNotifier import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory import com.tailscale.ipn.util.FeatureFlags import com.tailscale.ipn.util.TSLog +import java.io.File +import java.io.IOException +import java.net.NetworkInterface +import java.security.GeneralSecurityException +import java.util.Locale import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import libtailscale.Libtailscale -import java.io.File -import java.io.IOException -import java.net.NetworkInterface -import java.security.GeneralSecurityException -import java.util.Locale class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -165,10 +167,15 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { initViewModels() applicationScope.launch { Notifier.state.collect { _ -> - combine(Notifier.state, MDMSettings.forceEnabled.flow) { state, forceEnabled -> - Pair(state, forceEnabled) + combine(Notifier.state, MDMSettings.forceEnabled.flow, Notifier.prefs, Notifier.netmap) { + state, + forceEnabled, + prefs, + netmap -> + Triple(state, forceEnabled, getExitNodeName(prefs, netmap)) } - .collect { (state, hideDisconnectAction) -> + .distinctUntilChanged() + .collect { (state, hideDisconnectAction, exitNodeName) -> val ableToStartVPN = state > Ipn.State.NeedsMachineAuth // If VPN is stopped, show a disconnected notification. If it is running as a // foreground @@ -183,7 +190,10 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { // Update notification status when VPN is running if (vpnRunning) { - notifyStatus(vpnRunning = true, hideDisconnectAction = hideDisconnectAction.value) + notifyStatus( + vpnRunning = true, + hideDisconnectAction = hideDisconnectAction.value, + exitNodeName = exitNodeName) } } } @@ -391,6 +401,18 @@ open class UninitializedApp : Application() { fun get(): UninitializedApp { return appInstance } + + /** + * Return the name of the active (but not the selected/prior one) exit node based on the + * provided [Ipn.Prefs] and [Netmap.NetworkMap]. + * + * @return The name of the exit node or `null` if there isn't one. + */ + fun getExitNodeName(prefs: Ipn.Prefs?, netmap: Netmap.NetworkMap?): String? { + return prefs?.activeExitNodeID?.let { exitNodeID -> + netmap?.Peers?.find { it.StableID == exitNodeID }?.exitNodeName + } + } } protected fun setUnprotectedInstance(instance: UninitializedApp) { @@ -476,8 +498,12 @@ open class UninitializedApp : Application() { notificationManager.createNotificationChannel(channel) } - fun notifyStatus(vpnRunning: Boolean, hideDisconnectAction: Boolean) { - notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction)) + fun notifyStatus( + vpnRunning: Boolean, + hideDisconnectAction: Boolean, + exitNodeName: String? = null + ) { + notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction, exitNodeName)) } fun notifyStatus(notification: Notification) { @@ -495,8 +521,16 @@ open class UninitializedApp : Application() { notificationManager.notify(STATUS_NOTIFICATION_ID, notification) } - fun buildStatusNotification(vpnRunning: Boolean, hideDisconnectAction: Boolean): Notification { - val message = getString(if (vpnRunning) R.string.connected else R.string.not_connected) + fun buildStatusNotification( + vpnRunning: Boolean, + hideDisconnectAction: Boolean, + exitNodeName: String? = null + ): Notification { + val title = getString(if (vpnRunning) R.string.connected else R.string.not_connected) + val message = + if (vpnRunning && exitNodeName != null) { + getString(R.string.using_exit_node, exitNodeName) + } else null val icon = if (vpnRunning) R.drawable.ic_notification else R.drawable.ic_notification_disabled val action = if (vpnRunning) IPNReceiver.INTENT_DISCONNECT_VPN else IPNReceiver.INTENT_CONNECT_VPN @@ -520,7 +554,7 @@ open class UninitializedApp : Application() { val builder = NotificationCompat.Builder(this, STATUS_CHANNEL_ID) .setSmallIcon(icon) - .setContentTitle(getString(R.string.app_name)) + .setContentTitle(title) .setContentText(message) .setAutoCancel(!vpnRunning) .setOnlyAlertOnce(!vpnRunning) diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.kt b/android/src/main/java/com/tailscale/ipn/IPNService.kt index 920d08d..917b405 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNService.kt +++ b/android/src/main/java/com/tailscale/ipn/IPNService.kt @@ -12,12 +12,12 @@ import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.util.TSLog +import java.util.UUID import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import libtailscale.Libtailscale -import java.util.UUID open class IPNService : VpnService(), libtailscale.IPNService { private val TAG = "IPNService" @@ -47,11 +47,7 @@ open class IPNService : VpnService(), libtailscale.IPNService { START_NOT_STICKY } ACTION_START_VPN -> { - scope.launch { - // Collect the first value of hideDisconnectAction asynchronously. - val hideDisconnectAction = MDMSettings.forceEnabled.flow.first() - showForegroundNotification(hideDisconnectAction.value) - } + scope.launch { showForegroundNotification() } app.setWantRunning(true) Libtailscale.requestVPN(this) START_STICKY @@ -63,7 +59,9 @@ open class IPNService : VpnService(), libtailscale.IPNService { scope.launch { // Collect the first value of hideDisconnectAction asynchronously. val hideDisconnectAction = MDMSettings.forceEnabled.flow.first() - app.notifyStatus(true, hideDisconnectAction.value) + val exitNodeName = + UninitializedApp.getExitNodeName(Notifier.prefs.value, Notifier.netmap.value) + app.notifyStatus(true, hideDisconnectAction.value, exitNodeName) } app.setWantRunning(true) Libtailscale.requestVPN(this) @@ -73,11 +71,7 @@ open class IPNService : VpnService(), libtailscale.IPNService { // This means that we were restarted after the service was killed // (potentially due to OOM). if (UninitializedApp.get().isAbleToStartVPN()) { - scope.launch { - // Collect the first value of hideDisconnectAction asynchronously. - val hideDisconnectAction = MDMSettings.forceEnabled.flow.first() - showForegroundNotification(hideDisconnectAction.value) - } + scope.launch { showForegroundNotification() } App.get() Libtailscale.requestVPN(this) START_STICKY @@ -114,16 +108,25 @@ open class IPNService : VpnService(), libtailscale.IPNService { app.getAppScopedViewModel().setVpnPrepared(isPrepared) } - private fun showForegroundNotification(hideDisconnectAction: Boolean) { + private fun showForegroundNotification( + hideDisconnectAction: Boolean, + exitNodeName: String? = null + ) { try { startForeground( UninitializedApp.STATUS_NOTIFICATION_ID, - UninitializedApp.get().buildStatusNotification(true, hideDisconnectAction)) + UninitializedApp.get().buildStatusNotification(true, hideDisconnectAction, exitNodeName)) } catch (e: Exception) { TSLog.e(TAG, "Failed to start foreground service: $e") } } + private fun showForegroundNotification() { + val hideDisconnectAction = MDMSettings.forceEnabled.flow.value.value + val exitNodeName = UninitializedApp.getExitNodeName(Notifier.prefs.value, Notifier.netmap.value) + showForegroundNotification(hideDisconnectAction, exitNodeName) + } + private fun configIntent(): PendingIntent { return PendingIntent.getActivity( this, diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 7a68160..0b3b762 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -17,7 +17,6 @@ import android.net.NetworkCapabilities import android.os.Build import android.os.Bundle import android.provider.Settings -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.ActivityResultLauncher @@ -198,7 +197,7 @@ class MainActivity : ComponentActivity() { onNavigateToSearch = { viewModel.enableSearchAutoFocus() navController.navigate("search") - }) + }) val settingsNav = SettingsNav( @@ -245,9 +244,8 @@ class MainActivity : ComponentActivity() { viewModel = viewModel, navController = navController, onNavigateBack = { navController.popBackStack() }, - autoFocus = autoFocus - ) - } + autoFocus = autoFocus) + } composable("settings") { SettingsView(settingsNav) } composable("exitNodes") { ExitNodePicker(exitNodePickerNav) } composable("health") { HealthView(backTo("main")) } @@ -365,23 +363,21 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) if (intent.getBooleanExtra(START_AT_ROOT, false)) { - if (this::navController.isInitialized) { - val previousEntry = navController.previousBackStackEntry - TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry") - - if (previousEntry != null) { - navController.popBackStack(route = "main", inclusive = false) - } else { - TSLog.e("MainActivity", "onNewIntent: No previous back stack entry, navigating directly to 'main'") - navController.navigate("main") { - popUpTo("main") { inclusive = true } - } - } + if (this::navController.isInitialized) { + val previousEntry = navController.previousBackStackEntry + TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry") + + if (previousEntry != null) { + navController.popBackStack(route = "main", inclusive = false) + } else { + TSLog.e( + "MainActivity", + "onNewIntent: No previous back stack entry, navigating directly to 'main'") + navController.navigate("main") { popUpTo("main") { inclusive = true } } } + } } -} - - + } private fun login(urlString: String) { // Launch coroutine to listen for state changes. When the user completes login, relaunch diff --git a/android/src/main/java/com/tailscale/ipn/NetworkChangeCallback.kt b/android/src/main/java/com/tailscale/ipn/NetworkChangeCallback.kt index 9b6f9df..a8f5d82 100644 --- a/android/src/main/java/com/tailscale/ipn/NetworkChangeCallback.kt +++ b/android/src/main/java/com/tailscale/ipn/NetworkChangeCallback.kt @@ -9,9 +9,9 @@ import android.net.NetworkCapabilities import android.net.NetworkRequest import android.util.Log import com.tailscale.ipn.util.TSLog -import libtailscale.Libtailscale import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock +import libtailscale.Libtailscale object NetworkChangeCallback { diff --git a/android/src/main/java/com/tailscale/ipn/ShareActivity.kt b/android/src/main/java/com/tailscale/ipn/ShareActivity.kt index 3e121f1..09d9665 100644 --- a/android/src/main/java/com/tailscale/ipn/ShareActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/ShareActivity.kt @@ -21,12 +21,12 @@ import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.universalFit import com.tailscale.ipn.ui.view.TaildropView import com.tailscale.ipn.util.TSLog +import kotlin.random.Random import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlin.random.Random // ShareActivity is the entry point for Taildrop share intents class ShareActivity : ComponentActivity() { @@ -92,25 +92,22 @@ class ShareActivity : ComponentActivity() { } } - val pendingFiles: List = + val pendingFiles: List = uris?.filterNotNull()?.mapNotNull { uri -> - contentResolver?.query(uri, null, null, null, null)?.use { cursor -> - val nameCol = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - val sizeCol = cursor.getColumnIndex(OpenableColumns.SIZE) - - if (cursor.moveToFirst()) { - val name: String = cursor.getString(nameCol) - ?: generateFallbackName(uri) - val size: Long = cursor.getLong(sizeCol) - Ipn.OutgoingFile(Name = name, DeclaredSize = size).apply { - this.uri = uri - } - } else { - TSLog.e(TAG, "Cursor is empty for URI: $uri") - null - } + contentResolver?.query(uri, null, null, null, null)?.use { cursor -> + val nameCol = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeCol = cursor.getColumnIndex(OpenableColumns.SIZE) + + if (cursor.moveToFirst()) { + val name: String = cursor.getString(nameCol) ?: generateFallbackName(uri) + val size: Long = cursor.getLong(sizeCol) + Ipn.OutgoingFile(Name = name, DeclaredSize = size).apply { this.uri = uri } + } else { + TSLog.e(TAG, "Cursor is empty for URI: $uri") + null } - } ?: emptyList() + } + } ?: emptyList() if (pendingFiles.isEmpty()) { TSLog.e(TAG, "Share failure - no files extracted from intent") @@ -124,5 +121,5 @@ class ShareActivity : ComponentActivity() { val mimeType = contentResolver?.getType(uri) val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } return if (extension != null) "$randomId.$extension" else randomId.toString() -} + } } diff --git a/android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt b/android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt index ff48dc3..e2b2bbc 100644 --- a/android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt +++ b/android/src/main/java/com/tailscale/ipn/UseExitNodeWorker.kt @@ -10,7 +10,6 @@ import androidx.work.CoroutineWorker import androidx.work.Data import androidx.work.WorkerParameters import com.tailscale.ipn.UninitializedApp.Companion.STATUS_CHANNEL_ID -import com.tailscale.ipn.UninitializedApp.Companion.STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.notifier.Notifier @@ -18,95 +17,96 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -class UseExitNodeWorker( - appContext: Context, - workerParams: WorkerParameters -) : CoroutineWorker(appContext, workerParams) { - override suspend fun doWork(): Result { - val app = UninitializedApp.get() - suspend fun runAndGetResult(): String? { - val exitNodeName = inputData.getString(EXIT_NODE_NAME) - - val exitNodeId = if (exitNodeName.isNullOrEmpty()) { - null - } else { - if (!app.isAbleToStartVPN()) { - return app.getString(R.string.vpn_is_not_ready_to_start) - } - - val peers = - (Notifier.netmap.value - ?: run { return@runAndGetResult app.getString(R.string.tailscale_is_not_setup) }) - .Peers ?: run { return@runAndGetResult app.getString(R.string.no_peers_found) } - - val filteredPeers = peers.filter { - it.displayName == exitNodeName - }.toList() - - if (filteredPeers.isEmpty()) { - return app.getString(R.string.no_peers_with_name_found, exitNodeName) - } else if (filteredPeers.size > 1) { - return app.getString(R.string.multiple_peers_with_name_found, exitNodeName) - } else if (!filteredPeers[0].isExitNode) { - return app.getString( - R.string.peer_with_name_is_not_an_exit_node, - exitNodeName - ) - } - - filteredPeers[0].StableID +class UseExitNodeWorker(appContext: Context, workerParams: WorkerParameters) : + CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result { + val app = UninitializedApp.get() + suspend fun runAndGetResult(): String? { + val exitNodeName = inputData.getString(EXIT_NODE_NAME) + + val exitNodeId = + if (exitNodeName.isNullOrEmpty()) { + null + } else { + if (!app.isAbleToStartVPN()) { + return app.getString(R.string.vpn_is_not_ready_to_start) } - val allowLanAccess = inputData.getBoolean(ALLOW_LAN_ACCESS, false) - val prefsOut = Ipn.MaskedPrefs() - prefsOut.ExitNodeID = exitNodeId - prefsOut.ExitNodeAllowLANAccess = allowLanAccess - - val scope = CoroutineScope(Dispatchers.Default + Job()) - var result: String? = null - Client(scope).editPrefs(prefsOut) { - result = if (it.isFailure) { - it.exceptionOrNull()?.message - } else { - null - } + val peers = + (Notifier.netmap.value + ?: run { + return@runAndGetResult app.getString(R.string.tailscale_is_not_setup) + }) + .Peers + ?: run { + return@runAndGetResult app.getString(R.string.no_peers_found) + } + + val filteredPeers = peers.filter { it.displayName == exitNodeName }.toList() + + if (filteredPeers.isEmpty()) { + return app.getString(R.string.no_peers_with_name_found, exitNodeName) + } else if (filteredPeers.size > 1) { + return app.getString(R.string.multiple_peers_with_name_found, exitNodeName) + } else if (!filteredPeers[0].isExitNode) { + return app.getString(R.string.peer_with_name_is_not_an_exit_node, exitNodeName) } - scope.coroutineContext[Job]?.join() - - return result - } - - val result = runAndGetResult() + filteredPeers[0].StableID + } - return if (result != null) { - val intent = - Intent(app, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - } - val pendingIntent: PendingIntent = - PendingIntent.getActivity( - app, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + val allowLanAccess = inputData.getBoolean(ALLOW_LAN_ACCESS, false) + val prefsOut = Ipn.MaskedPrefs() + prefsOut.ExitNodeID = exitNodeId + prefsOut.ExitNodeAllowLANAccess = allowLanAccess - val notification = NotificationCompat.Builder(app, STATUS_CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle(app.getString(R.string.use_exit_node_intent_failed)) - .setContentText(result) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setContentIntent(pendingIntent) - .build() + val scope = CoroutineScope(Dispatchers.Default + Job()) + var result: String? = null + Client(scope).editPrefs(prefsOut) { + result = + if (it.isFailure) { + it.exceptionOrNull()?.message + } else { + null + } + } - app.notifyStatus(notification) + scope.coroutineContext[Job]?.join() - Result.failure(Data.Builder().putString(ERROR_KEY, result).build()) - } else { - Result.success() - } + return result } - companion object { - const val EXIT_NODE_NAME = "EXIT_NODE_NAME" - const val ALLOW_LAN_ACCESS = "ALLOW_LAN_ACCESS" - const val ERROR_KEY = "error" + val result = runAndGetResult() + + return if (result != null) { + val intent = + Intent(app, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + val pendingIntent: PendingIntent = + PendingIntent.getActivity( + app, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + val notification = + NotificationCompat.Builder(app, STATUS_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(app.getString(R.string.use_exit_node_intent_failed)) + .setContentText(result) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) + .build() + + app.notifyStatus(notification) + + Result.failure(Data.Builder().putString(ERROR_KEY, result).build()) + } else { + Result.success() } + } + + companion object { + const val EXIT_NODE_NAME = "EXIT_NODE_NAME" + const val ALLOW_LAN_ACCESS = "ALLOW_LAN_ACCESS" + const val ERROR_KEY = "error" + } } diff --git a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsChangedReceiver.kt b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsChangedReceiver.kt index 5584fde..d54129d 100644 --- a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsChangedReceiver.kt +++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsChangedReceiver.kt @@ -11,11 +11,12 @@ import com.tailscale.ipn.App import com.tailscale.ipn.util.TSLog class MDMSettingsChangedReceiver : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED) { - TSLog.d("syspolicy", "MDM settings changed") - val restrictionsManager = context?.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager - MDMSettings.update(App.get(), restrictionsManager) - } + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED) { + TSLog.d("syspolicy", "MDM settings changed") + val restrictionsManager = + context?.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager + MDMSettings.update(App.get(), restrictionsManager) } -} \ No newline at end of file + } +} diff --git a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt index 173159f..d704aeb 100644 --- a/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt +++ b/android/src/main/java/com/tailscale/ipn/mdm/MDMSettingsDefinitions.kt @@ -5,10 +5,8 @@ package com.tailscale.ipn.mdm import android.content.SharedPreferences import android.os.Bundle -import com.tailscale.ipn.App import com.tailscale.ipn.ui.util.set import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow data class SettingState(val value: T, val isSet: Boolean) @@ -29,18 +27,21 @@ abstract class MDMSetting(defaultValue: T, val key: String, val localizedTitl } protected abstract fun getFromBundle(bundle: Bundle): T + protected abstract fun getFromPrefs(prefs: SharedPreferences): T } class BooleanMDMSetting(key: String, localizedTitle: String) : MDMSetting(false, key, localizedTitle) { override fun getFromBundle(bundle: Bundle) = bundle.getBoolean(key) + override fun getFromPrefs(prefs: SharedPreferences) = prefs.getBoolean(key, false) } class StringMDMSetting(key: String, localizedTitle: String) : MDMSetting(null, key, localizedTitle) { override fun getFromBundle(bundle: Bundle) = bundle.getString(key) + override fun getFromPrefs(prefs: SharedPreferences) = prefs.getString(key, null) } @@ -50,13 +51,13 @@ class StringArrayListMDMSetting(key: String, localizedTitle: String) : // Try to retrieve the value as a String[] first val stringArray = bundle.getStringArray(key) if (stringArray != null) { - return stringArray.toList() + return stringArray.toList() } - + // Optionally, handle other types if necessary val stringArrayList = bundle.getStringArrayList(key) if (stringArrayList != null) { - return stringArrayList + return stringArrayList } // If neither String[] nor ArrayList is found, return null @@ -64,7 +65,7 @@ class StringArrayListMDMSetting(key: String, localizedTitle: String) : } override fun getFromPrefs(prefs: SharedPreferences): List? { - return prefs.getStringSet(key, HashSet())?.toList() + return prefs.getStringSet(key, HashSet())?.toList() } } @@ -72,14 +73,15 @@ class AlwaysNeverUserDecidesMDMSetting(key: String, localizedTitle: String) : MDMSetting(AlwaysNeverUserDecides.UserDecides, key, localizedTitle) { override fun getFromBundle(bundle: Bundle) = AlwaysNeverUserDecides.fromString(bundle.getString(key)) + override fun getFromPrefs(prefs: SharedPreferences) = AlwaysNeverUserDecides.fromString(prefs.getString(key, null)) } class ShowHideMDMSetting(key: String, localizedTitle: String) : MDMSetting(ShowHide.Show, key, localizedTitle) { - override fun getFromBundle(bundle: Bundle) = - ShowHide.fromString(bundle.getString(key)) + override fun getFromBundle(bundle: Bundle) = ShowHide.fromString(bundle.getString(key)) + override fun getFromPrefs(prefs: SharedPreferences) = ShowHide.fromString(prefs.getString(key, null)) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt index 2a30db4..5b38b6a 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt @@ -14,6 +14,9 @@ import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.util.InputStreamAdapter import com.tailscale.ipn.util.TSLog +import java.nio.charset.Charset +import kotlin.reflect.KType +import kotlin.reflect.typeOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -23,9 +26,6 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.serializer import libtailscale.FilePart -import java.nio.charset.Charset -import kotlin.reflect.KType -import kotlin.reflect.typeOf private object Endpoint { const val DEBUG = "debug" 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 6b32007..338b7a9 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 @@ -4,9 +4,9 @@ package com.tailscale.ipn.ui.model import android.net.Uri +import java.util.UUID import kotlinx.serialization.Serializable import kotlinx.serialization.Transient -import java.util.UUID class Ipn { 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 af36f21..5c6ac0e 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 @@ -3,8 +3,8 @@ package com.tailscale.ipn.ui.model -import kotlinx.serialization.Serializable import java.net.URL +import kotlinx.serialization.Serializable class IpnState { @Serializable 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 51cb917..f79b89d 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 @@ -15,9 +15,9 @@ import com.tailscale.ipn.ui.util.DisplayAddress import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.ui.util.flag import com.tailscale.ipn.ui.viewModel.PeerSettingInfo +import java.util.Date import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement -import java.util.Date class Tailcfg { @Serializable @@ -116,9 +116,9 @@ class Tailcfg { val exitNodeName: String get() { if (isMullvadNode && - Hostinfo.Location?.Country != null && - Hostinfo.Location?.City != null && - Hostinfo.Location?.CountryCode != null) { + Hostinfo.Location?.Country != null && + Hostinfo.Location?.City != null && + Hostinfo.Location?.CountryCode != null) { return "${Hostinfo.Location!!.CountryCode!!.flag()} ${Hostinfo.Location!!.Country!!}: ${Hostinfo.Location!!.City!!}" } return displayName diff --git a/android/src/main/java/com/tailscale/ipn/ui/notifier/HealthNotifier.kt b/android/src/main/java/com/tailscale/ipn/ui/notifier/HealthNotifier.kt index 8386bcb..17d86a9 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/notifier/HealthNotifier.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/HealthNotifier.kt @@ -56,7 +56,9 @@ class HealthNotifier( // When the client is Stopped, no warnings should get added, and any warnings added // previously should be removed. if (ipnState == Ipn.State.Stopped) { - TSLog.d(TAG, "Ignoring and dropping all pre-existing health messages in the Stopped state") + TSLog.d( + TAG, + "Ignoring and dropping all pre-existing health messages in the Stopped state") dropAllWarnings() return@collect } else { @@ -131,9 +133,8 @@ class HealthNotifier( /** * Sets the icon displayed to represent the overall health state. - * - * - If there are any high severity warnings, or warnings that affect internet connectivity, - * a warning icon is displayed. + * - If there are any high severity warnings, or warnings that affect internet connectivity, a + * warning icon is displayed. * - If there are any other kind of warnings, an info icon is displayed. * - If there are no warnings at all, no icon is set. */ @@ -171,8 +172,8 @@ class HealthNotifier( } /** - * Removes all warnings currently displayed, including any system notifications, and - * updates the icon (causing it to be set to null since the set of warnings is empty). + * Removes all warnings currently displayed, including any system notifications, and updates the + * icon (causing it to be set to null since the set of warnings is empty). */ private fun dropAllWarnings() { removeNotifications(this.currentWarnings.value) diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/AdvertisedRoutesHelper.kt b/android/src/main/java/com/tailscale/ipn/ui/util/AdvertisedRoutesHelper.kt index d97da48..418dd91 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/AdvertisedRoutesHelper.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/AdvertisedRoutesHelper.kt @@ -6,19 +6,19 @@ package com.tailscale.ipn.ui.util import com.tailscale.ipn.ui.model.Ipn class AdvertisedRoutesHelper { - companion object { - fun exitNodeOnFromPrefs(prefs: Ipn.Prefs): Boolean { - var v4 = false - var v6 = false - prefs.AdvertiseRoutes?.forEach { - if (it == "0.0.0.0/0") { - v4 = true - } - if (it == "::/0") { - v6 = true - } - } - return v4 && v6 + companion object { + fun exitNodeOnFromPrefs(prefs: Ipn.Prefs): Boolean { + var v4 = false + var v6 = false + prefs.AdvertiseRoutes?.forEach { + if (it == "0.0.0.0/0") { + v4 = true } + if (it == "::/0") { + v6 = true + } + } + return v4 && v6 } -} \ No newline at end of file + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/ClipboardValueView.kt b/android/src/main/java/com/tailscale/ipn/ui/util/ClipboardValueView.kt index 865282f..54db4d9 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/ClipboardValueView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/ClipboardValueView.kt @@ -29,33 +29,36 @@ import com.tailscale.ipn.R @Composable fun ClipboardValueView(value: String, title: String? = null, subtitle: String? = null) { - val isFocused = remember { mutableStateOf(false) } - val localClipboardManager = LocalClipboardManager.current - val interactionSource = remember { MutableInteractionSource() } + val isFocused = remember { mutableStateOf(false) } + val localClipboardManager = LocalClipboardManager.current + val interactionSource = remember { MutableInteractionSource() } - ListItem( - modifier = Modifier - .focusable(interactionSource = interactionSource) - .onFocusChanged { focusState -> isFocused.value = focusState.isFocused } - .clickable( - interactionSource = interactionSource, - indication = LocalIndication.current - ) { localClipboardManager.setText(AnnotatedString(value)) } - .background( - if (isFocused.value) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) - else Color.Transparent - ), - overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } }, - headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) }, - supportingContent = subtitle?.let { - { Text(it, modifier = Modifier.padding(top = 8.dp), style = MaterialTheme.typography.bodyMedium) } - }, - trailingContent = { - Icon( - painterResource(R.drawable.clipboard), - contentDescription = stringResource(R.string.copy_to_clipboard), - modifier = Modifier.size(24.dp) - ) - } - ) -} \ No newline at end of file + ListItem( + modifier = + Modifier.focusable(interactionSource = interactionSource) + .onFocusChanged { focusState -> isFocused.value = focusState.isFocused } + .clickable( + interactionSource = interactionSource, indication = LocalIndication.current) { + localClipboardManager.setText(AnnotatedString(value)) + } + .background( + if (isFocused.value) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + else Color.Transparent), + overlineContent = title?.let { { Text(it, style = MaterialTheme.typography.titleMedium) } }, + headlineContent = { Text(text = value, style = MaterialTheme.typography.bodyMedium) }, + supportingContent = + subtitle?.let { + { + Text( + it, + modifier = Modifier.padding(top = 8.dp), + style = MaterialTheme.typography.bodyMedium) + } + }, + trailingContent = { + Icon( + painterResource(R.drawable.clipboard), + contentDescription = stringResource(R.string.copy_to_clipboard), + modifier = Modifier.size(24.dp)) + }) +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/ConnectionMode.kt b/android/src/main/java/com/tailscale/ipn/ui/util/ConnectionMode.kt index 870541d..eb36ea1 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/ConnectionMode.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/ConnectionMode.kt @@ -11,43 +11,43 @@ import com.tailscale.ipn.R import com.tailscale.ipn.ui.theme.on sealed class ConnectionMode { - class NotConnected : ConnectionMode() + class NotConnected : ConnectionMode() - class Derp(val relayName: String) : ConnectionMode() + class Derp(val relayName: String) : ConnectionMode() - class Direct : ConnectionMode() + class Direct : ConnectionMode() - @Composable - fun titleString(): String { - return when (this) { - is NotConnected -> stringResource(id = R.string.not_connected) - is Derp -> stringResource(R.string.relayed_connection, relayName) - is Direct -> stringResource(R.string.direct_connection) - } + @Composable + fun titleString(): String { + return when (this) { + is NotConnected -> stringResource(id = R.string.not_connected) + is Derp -> stringResource(R.string.relayed_connection, relayName) + is Direct -> stringResource(R.string.direct_connection) } + } - fun contentKey(): String { - return when (this) { - is NotConnected -> "NotConnected" - is Derp -> "Derp($relayName)" - is Direct -> "Direct" - } + fun contentKey(): String { + return when (this) { + is NotConnected -> "NotConnected" + is Derp -> "Derp($relayName)" + is Direct -> "Direct" } + } - fun iconDrawable(): Int { - return when (this) { - is NotConnected -> R.drawable.xmark_circle - is Derp -> R.drawable.link_off - is Direct -> R.drawable.link - } + fun iconDrawable(): Int { + return when (this) { + is NotConnected -> R.drawable.xmark_circle + is Derp -> R.drawable.link_off + is Direct -> R.drawable.link } - - @Composable - fun color(): Color { - return when (this) { - is NotConnected -> MaterialTheme.colorScheme.onPrimary - is Derp -> MaterialTheme.colorScheme.error - is Direct -> MaterialTheme.colorScheme.on - } + } + + @Composable + fun color(): Color { + return when (this) { + is NotConnected -> MaterialTheme.colorScheme.onPrimary + is Derp -> MaterialTheme.colorScheme.error + is Direct -> MaterialTheme.colorScheme.on } -} \ No newline at end of file + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt b/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt index 696158c..71415c0 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt @@ -47,34 +47,30 @@ object Lists { fontWeight: FontWeight? = null, focusable: Boolean = false, backgroundColor: Color = MaterialTheme.colorScheme.surface, - fontColor: Color? = null + fontColor: Color? = null ) { - Box( - modifier = Modifier - .fillMaxWidth() - .background(color = backgroundColor, shape = RectangleShape) - ) { + Box( + modifier = + Modifier.fillMaxWidth().background(color = backgroundColor, shape = RectangleShape)) { if (fontColor != null) { - Text( - text = title, - modifier = Modifier - .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding) - .focusable(focusable), - style = style, - fontWeight = fontWeight, - color = fontColor - ) + Text( + text = title, + modifier = + Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding) + .focusable(focusable), + style = style, + fontWeight = fontWeight, + color = fontColor) } else { - Text( - text = title, - modifier = Modifier - .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding) - .focusable(focusable), - style = style, - fontWeight = fontWeight - ) + Text( + text = title, + modifier = + Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding) + .focusable(focusable), + style = style, + fontWeight = fontWeight) } - } + } } @Composable diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt b/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt index 1d59d91..54adf27 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt @@ -51,7 +51,7 @@ fun Avatar( modifier = Modifier.conditional(AndroidTVUtil.isAndroidTV(), { padding(4.dp) }) .conditional( - AndroidTVUtil.isAndroidTV() && isFocusable, + AndroidTVUtil.isAndroidTV() && isFocusable, { size((size * 1.5f).dp) // Focusable area is larger than the avatar }) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/CustomLogin.kt b/android/src/main/java/com/tailscale/ipn/ui/view/CustomLogin.kt index 82d2e37..991ec39 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/CustomLogin.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/CustomLogin.kt @@ -140,11 +140,10 @@ fun LoginView( placeholder = { Text(strings.placeholder, style = MaterialTheme.typography.bodySmall) }, - keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None) - ) + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None)) }) - ListItem( + ListItem( colors = MaterialTheme.colorScheme.listItem, headlineContent = { Box(modifier = Modifier.fillMaxWidth()) { diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/EditSubnetRouteDialogView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/EditSubnetRouteDialogView.kt index dfd7629..93e5600 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/EditSubnetRouteDialogView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/EditSubnetRouteDialogView.kt @@ -9,12 +9,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.CheckCircle -import androidx.compose.material.icons.rounded.Warning import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField @@ -36,7 +32,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow /** - * EditSubnetRouteDialogView is the content of the dialog that allows the user to add or edit a subnet route. + * EditSubnetRouteDialogView is the content of the dialog that allows the user to add or edit a + * subnet route. */ @Composable fun EditSubnetRouteDialogView( @@ -46,59 +43,52 @@ fun EditSubnetRouteDialogView( onCommit: (String) -> Unit, onCancel: () -> Unit ) { - val value by valueFlow.collectAsState() - val isValueValid by isValueValidFlow.collectAsState() - val focusRequester = remember { FocusRequester() } + val value by valueFlow.collectAsState() + val isValueValid by isValueValidFlow.collectAsState() + val focusRequester = remember { FocusRequester() } - Column( - modifier = Modifier.padding(16.dp), - ) { - Text(text = stringResource(R.string.enter_valid_route)) + Column( + modifier = Modifier.padding(16.dp), + ) { + Text(text = stringResource(R.string.enter_valid_route)) - Text( - text = stringResource(R.string.route_help_text), - color = MaterialTheme.colorScheme.secondary, - fontSize = MaterialTheme.typography.bodySmall.fontSize - ) + Text( + text = stringResource(R.string.route_help_text), + color = MaterialTheme.colorScheme.secondary, + fontSize = MaterialTheme.typography.bodySmall.fontSize) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - TextField( - value = value, - onValueChange = { onValueChange(it) }, - singleLine = true, - isError = !isValueValid, - modifier = Modifier.focusRequester(focusRequester) - ) + TextField( + value = value, + onValueChange = { onValueChange(it) }, + singleLine = true, + isError = !isValueValid, + modifier = Modifier.focusRequester(focusRequester)) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.align(Alignment.End) - ) { - Button(colors = ButtonDefaults.outlinedButtonColors(), onClick = { - onCancel() - }) { - Text(stringResource(R.string.cancel)) - } + Row(modifier = Modifier.align(Alignment.End)) { + Button(colors = ButtonDefaults.outlinedButtonColors(), onClick = { onCancel() }) { + Text(stringResource(R.string.cancel)) + } - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(8.dp)) - Button(onClick = { - onCommit(value) - }, enabled = value.isNotEmpty() && isValueValid) { - Text(stringResource(R.string.ok)) - } - } + Button(onClick = { onCommit(value) }, enabled = value.isNotEmpty() && isValueValid) { + Text(stringResource(R.string.ok)) + } } + } - // When the dialog is opened, focus on the text field to present the keyboard auto-magically. - val windowInfo = LocalWindowInfo.current - LaunchedEffect(windowInfo) { - snapshotFlow { windowInfo.isWindowFocused }.collect { isWindowFocused -> - if (isWindowFocused) { - focusRequester.requestFocus() - } + // When the dialog is opened, focus on the text field to present the keyboard auto-magically. + val windowInfo = LocalWindowInfo.current + LaunchedEffect(windowInfo) { + snapshotFlow { windowInfo.isWindowFocused } + .collect { isWindowFocused -> + if (isWindowFocused) { + focusRequester.requestFocus() + } } - } -} \ No newline at end of file + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt index c79d5bf..f57a71e 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt @@ -12,7 +12,6 @@ import androidx.compose.ui.tooling.preview.Preview import com.tailscale.ipn.R import com.tailscale.ipn.ui.theme.AppTheme - enum class ErrorDialogType { INVALID_CUSTOM_URL, LOGOUT_FAILED, @@ -54,11 +53,10 @@ enum class ErrorDialogType { @Composable fun ErrorDialog(type: ErrorDialogType, action: () -> Unit = {}) { ErrorDialog( - title = type.title, - message = stringResource(id = type.message), - buttonText = type.buttonText, - onDismiss = action - ) + title = type.title, + message = stringResource(id = type.message), + buttonText = type.buttonText, + onDismiss = action) } @Composable @@ -69,11 +67,10 @@ fun ErrorDialog( onDismiss: () -> Unit = {} ) { ErrorDialog( - title = title, - message = stringResource(id = message), - buttonText = buttonText, - onDismiss = onDismiss - ) + title = title, + message = stringResource(id = message), + buttonText = buttonText, + onDismiss = onDismiss) } @Composable @@ -83,15 +80,15 @@ fun ErrorDialog( @StringRes buttonText: Int = R.string.ok, onDismiss: () -> Unit = {} ) { - AppTheme { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(text = stringResource(id = title)) }, - text = { Text(text = message) }, - confirmButton = { - PrimaryActionButton(onClick = onDismiss) { Text(text = stringResource(id = buttonText)) } - }) - } + AppTheme { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(text = stringResource(id = title)) }, + text = { Text(text = message) }, + confirmButton = { + PrimaryActionButton(onClick = onDismiss) { Text(text = stringResource(id = buttonText)) } + }) + } } @Preview 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 index 0395e2e..827925b 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt @@ -3,6 +3,7 @@ package com.tailscale.ipn.ui.view +import android.os.Build import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -36,14 +37,13 @@ import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory import com.tailscale.ipn.ui.viewModel.selected import kotlinx.coroutines.flow.MutableStateFlow -import android.os.Build @Composable fun ExitNodePicker( nav: ExitNodePickerNav, model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav)) ) { - LoadingIndicator.Wrap { + LoadingIndicator.Wrap { Scaffold(topBar = { Header(R.string.choose_exit_node, onBack = nav.onNavigateBackHome) }) { innerPadding -> val tailnetExitNodes by model.tailnetExitNodes.collectAsState() @@ -101,7 +101,8 @@ fun ExitNodePicker( } // https://developer.android.com/reference/android/net/VpnService.Builder#excludeRoute(android.net.IpPrefix) - excludeRoute is only supported in API 33+, so don't show the option if allow LAN access is not enabled. - if (!allowLanAccessMDMDisposition.value.hiddenFromUser && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (!allowLanAccessMDMDisposition.value.hiddenFromUser && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { item(key = "allowLANAccess") { Lists.SectionDivider() diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/LoginQRView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/LoginQRView.kt index 022e471..d79ad27 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/LoginQRView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/LoginQRView.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.shape.RoundedCornerShape @@ -57,9 +56,7 @@ fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel( textAlign = TextAlign.Center) Box( - modifier = - Modifier.size(200.dp) - .background(MaterialTheme.colorScheme.onSurface), + modifier = Modifier.size(200.dp).background(MaterialTheme.colorScheme.onSurface), contentAlignment = Alignment.Center) { image?.let { Image( @@ -76,12 +73,11 @@ fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel( numCode?.let { Box( modifier = - Modifier - .clip(RoundedCornerShape(6.dp)) + Modifier.clip(RoundedCornerShape(6.dp)) .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)), contentAlignment = Alignment.Center) { Text( - text =it, + text = it, style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold), color = MaterialTheme.colorScheme.onSurface) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePickerList.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePickerList.kt index 3b192a1..c3d5349 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePickerList.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePickerList.kt @@ -45,9 +45,7 @@ fun MullvadExitNodePickerList( LazyColumn(modifier = Modifier.padding(innerPadding)) { val sortedCountries = - mullvadExitNodes.entries.toList().sortedBy { - it.value.first().country.lowercase() - } + mullvadExitNodes.entries.toList().sortedBy { it.value.first().country.lowercase() } itemsWithDividers(sortedCountries) { (countryCode, nodes) -> val first = nodes.first() 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 01801b9..1490e2b 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 @@ -47,7 +47,7 @@ import com.tailscale.ipn.ui.viewModel.PingViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun PeerDetails( - onNavigateBack: () -> Unit, + onNavigateBack: () -> Unit, nodeId: String, pingViewModel: PingViewModel, model: PeerDetailsViewModel = diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt index 399c7c0..3cc69fb 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt @@ -5,7 +5,6 @@ package com.tailscale.ipn.ui.view import android.app.Activity import android.os.Build -import android.util.Log import android.window.OnBackInvokedCallback import android.window.OnBackInvokedDispatcher import androidx.annotation.RequiresApi 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 52609f7..e29e988 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 @@ -33,15 +33,14 @@ import com.tailscale.ipn.mdm.ShowHide import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.theme.link import com.tailscale.ipn.ui.theme.listItem +import com.tailscale.ipn.ui.util.AndroidTVUtil import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV +import com.tailscale.ipn.ui.util.AppVersion import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsViewModel import com.tailscale.ipn.ui.viewModel.VpnViewModel -import com.tailscale.ipn.ui.notifier.Notifier -import com.tailscale.ipn.ui.util.AndroidTVUtil -import com.tailscale.ipn.ui.util.AppVersion @Composable fun SettingsView( @@ -49,180 +48,176 @@ fun SettingsView( viewModel: SettingsViewModel = viewModel(), vpnViewModel: VpnViewModel = viewModel() ) { - val handler = LocalUriHandler.current - - val user by viewModel.loggedInUser.collectAsState() - val isAdmin by viewModel.isAdmin.collectAsState() - val managedByOrganization by viewModel.managedByOrganization.collectAsState() - val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState() - val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState() - val isVPNPrepared by vpnViewModel.vpnPrepared.collectAsState() - val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState() - val useTailscaleSubnets by MDMSettings.useTailscaleSubnets.flow.collectAsState() - - Scaffold(topBar = { + val handler = LocalUriHandler.current + + val user by viewModel.loggedInUser.collectAsState() + val isAdmin by viewModel.isAdmin.collectAsState() + val managedByOrganization by viewModel.managedByOrganization.collectAsState() + val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState() + val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState() + val isVPNPrepared by vpnViewModel.vpnPrepared.collectAsState() + val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState() + val useTailscaleSubnets by MDMSettings.useTailscaleSubnets.flow.collectAsState() + + Scaffold( + topBar = { Header(titleRes = R.string.settings_title, onBack = settingsNav.onNavigateBackHome) - }) { innerPadding -> - Column( - modifier = Modifier - .padding(innerPadding) - .verticalScroll(rememberScrollState()) - ) { - if (isVPNPrepared) { - UserView( - profile = user, - actionState = UserActionState.NAV, - onClick = settingsNav.onNavigateToUserSwitcher - ) - } - - if (isAdmin && !isAndroidTV()) { - Lists.ItemDivider() - AdminTextView { handler.openUri(Links.ADMIN_URL) } - } - - Lists.SectionDivider() - Setting.Text( - R.string.dns_settings, subtitle = corpDNSEnabled?.let { + }) { innerPadding -> + Column(modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) { + if (isVPNPrepared) { + UserView( + profile = user, + actionState = UserActionState.NAV, + onClick = settingsNav.onNavigateToUserSwitcher) + } + + if (isAdmin && !isAndroidTV()) { + Lists.ItemDivider() + AdminTextView { handler.openUri(Links.ADMIN_URL) } + } + + Lists.SectionDivider() + Setting.Text( + R.string.dns_settings, + subtitle = + corpDNSEnabled?.let { stringResource( - if (it) R.string.using_tailscale_dns else R.string.not_using_tailscale_dns - ) - }, onClick = settingsNav.onNavigateToDNSSettings - ) + if (it) R.string.using_tailscale_dns else R.string.not_using_tailscale_dns) + }, + onClick = settingsNav.onNavigateToDNSSettings) + + Lists.ItemDivider() + Setting.Text( + R.string.split_tunneling, + subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale), + onClick = settingsNav.onNavigateToSplitTunneling) + if (showTailnetLock.value == ShowHide.Show) { Lists.ItemDivider() Setting.Text( - R.string.split_tunneling, - subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale), - onClick = settingsNav.onNavigateToSplitTunneling - ) - - if (showTailnetLock.value == ShowHide.Show) { - Lists.ItemDivider() - Setting.Text( - R.string.tailnet_lock, subtitle = tailnetLockEnabled?.let { - stringResource(if (it) R.string.enabled else R.string.disabled) - }, onClick = settingsNav.onNavigateToTailnetLock - ) - } - if (useTailscaleSubnets.value == AlwaysNeverUserDecides.UserDecides) { - Lists.ItemDivider() - Setting.Text( - R.string.subnet_routing, - onClick = settingsNav.onNavigateToSubnetRouting - ) - } - if (!AndroidTVUtil.isAndroidTV()) { - Lists.ItemDivider() - Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions) - } - - managedByOrganization.value?.let { - Lists.ItemDivider() - Setting.Text( - title = stringResource(R.string.managed_by_orgName, it), - onClick = settingsNav.onNavigateToManagedBy - ) - } - - Lists.SectionDivider() - Setting.Text(R.string.bug_report, onClick = settingsNav.onNavigateToBugReport) + R.string.tailnet_lock, + subtitle = + tailnetLockEnabled?.let { + stringResource(if (it) R.string.enabled else R.string.disabled) + }, + onClick = settingsNav.onNavigateToTailnetLock) + } + if (useTailscaleSubnets.value == AlwaysNeverUserDecides.UserDecides) { + Lists.ItemDivider() + Setting.Text(R.string.subnet_routing, onClick = settingsNav.onNavigateToSubnetRouting) + } + if (!AndroidTVUtil.isAndroidTV()) { + Lists.ItemDivider() + Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions) + } + managedByOrganization.value?.let { Lists.ItemDivider() Setting.Text( - R.string.about_tailscale, - subtitle = "${stringResource(id = R.string.version)} ${AppVersion.Short()}", - onClick = settingsNav.onNavigateToAbout - ) - - // TODO: put a heading for the debug section - if (BuildConfig.DEBUG) { - Lists.SectionDivider() - Lists.MutedHeader(text = stringResource(R.string.internal_debug_options)) - Setting.Text(R.string.mdm_settings, onClick = settingsNav.onNavigateToMDMSettings) - } + title = stringResource(R.string.managed_by_orgName, it), + onClick = settingsNav.onNavigateToManagedBy) + } + + Lists.SectionDivider() + Setting.Text(R.string.bug_report, onClick = settingsNav.onNavigateToBugReport) + + Lists.ItemDivider() + Setting.Text( + R.string.about_tailscale, + subtitle = "${stringResource(id = R.string.version)} ${AppVersion.Short()}", + onClick = settingsNav.onNavigateToAbout) + + // TODO: put a heading for the debug section + if (BuildConfig.DEBUG) { + Lists.SectionDivider() + Lists.MutedHeader(text = stringResource(R.string.internal_debug_options)) + Setting.Text(R.string.mdm_settings, onClick = settingsNav.onNavigateToMDMSettings) + } } - } + } } object Setting { - @Composable - fun Text( - titleRes: Int = 0, - title: String? = null, - subtitle: String? = null, - destructive: Boolean = false, - enabled: Boolean = true, - onClick: (() -> Unit)? = null - ) { - var modifier: Modifier = Modifier - if (enabled) { - onClick?.let { modifier = modifier.clickable(onClick = it) } - } - ListItem(modifier = modifier, - colors = MaterialTheme.colorScheme.listItem, - headlineContent = { + @Composable + fun Text( + titleRes: Int = 0, + title: String? = null, + subtitle: String? = null, + destructive: Boolean = false, + enabled: Boolean = true, + onClick: (() -> Unit)? = null + ) { + var modifier: Modifier = Modifier + if (enabled) { + onClick?.let { modifier = modifier.clickable(onClick = it) } + } + ListItem( + modifier = modifier, + colors = MaterialTheme.colorScheme.listItem, + headlineContent = { + Text( + title ?: stringResource(titleRes), + style = MaterialTheme.typography.bodyMedium, + color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified) + }, + supportingContent = + subtitle?.let { + { Text( - title ?: stringResource(titleRes), - style = MaterialTheme.typography.bodyMedium, - color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified - ) - }, - supportingContent = subtitle?.let { - { - Text( - it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } }) - } - - @Composable - fun Switch( - titleRes: Int = 0, - title: String? = null, - isOn: Boolean, - enabled: Boolean = true, - onToggle: (Boolean) -> Unit = {} - ) { - ListItem(colors = MaterialTheme.colorScheme.listItem, headlineContent = { - Text( - title ?: stringResource(titleRes), - style = MaterialTheme.typography.bodyMedium, - ) - }, trailingContent = { - TintedSwitch(checked = isOn, onCheckedChange = onToggle, enabled = enabled) + } + + @Composable + fun Switch( + titleRes: Int = 0, + title: String? = null, + isOn: Boolean, + enabled: Boolean = true, + onToggle: (Boolean) -> Unit = {} + ) { + ListItem( + colors = MaterialTheme.colorScheme.listItem, + headlineContent = { + Text( + title ?: stringResource(titleRes), + style = MaterialTheme.typography.bodyMedium, + ) + }, + trailingContent = { + TintedSwitch(checked = isOn, onCheckedChange = onToggle, enabled = enabled) }) - } + } } @Composable fun AdminTextView(onNavigateToAdminConsole: () -> Unit) { - val adminStr = buildAnnotatedString { - append(stringResource(id = R.string.settings_admin_prefix)) - - pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL) - withStyle( - style = SpanStyle( - color = MaterialTheme.colorScheme.link, textDecoration = TextDecoration.Underline - ) - ) { - append(stringResource(id = R.string.settings_admin_link)) + val adminStr = buildAnnotatedString { + append(stringResource(id = R.string.settings_admin_prefix)) + + pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL) + withStyle( + style = + SpanStyle( + color = MaterialTheme.colorScheme.link, + textDecoration = TextDecoration.Underline)) { + append(stringResource(id = R.string.settings_admin_link)) } - } + } - Lists.InfoItem(adminStr, onClick = onNavigateToAdminConsole) + Lists.InfoItem(adminStr, onClick = onNavigateToAdminConsole) } @Preview @Composable fun SettingsPreview() { - val vm = SettingsViewModel() - vm.corpDNSEnabled.set(true) - vm.tailNetLockEnabled.set(true) - vm.isAdmin.set(true) - vm.managedByOrganization.set("Tails and Scales Inc.") - SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm) + val vm = SettingsViewModel() + vm.corpDNSEnabled.set(true) + vm.tailNetLockEnabled.set(true) + vm.isAdmin.set(true) + vm.managedByOrganization.set("Tails and Scales Inc.") + SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SubnetRouteRowView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SubnetRouteRowView.kt index b6e797f..0b26e90 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SubnetRouteRowView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SubnetRouteRowView.kt @@ -19,8 +19,8 @@ import androidx.compose.ui.unit.dp import com.tailscale.ipn.R /** - * SubnetRouteRowView is a row in RunSubnetRouterView, representing a subnet route. - * It provides options to edit or delete the route. + * SubnetRouteRowView is a row in RunSubnetRouterView, representing a subnet route. It provides + * options to edit or delete the route. * * @param route The subnet route itself (e.g., "192.168.1.0/24"). * @param onEdit A callback invoked when the edit icon is clicked. @@ -28,31 +28,32 @@ import com.tailscale.ipn.R */ @Composable fun SubnetRouteRowView( - route: String, onEdit: () -> Unit, onDelete: () -> Unit, modifier: Modifier = Modifier + route: String, + onEdit: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier ) { - ListItem( - headlineContent = { Text(text = route, style = MaterialTheme.typography.bodyMedium) }, - trailingContent = { - Row { - IconButton(onClick = onEdit) { - Icon( - painterResource(R.drawable.pencil), - contentDescription = stringResource(R.string.edit_route), - modifier = Modifier.size(24.dp) - ) - } - IconButton( - onClick = onDelete, - colors = IconButtonDefaults.iconButtonColors(contentColor = MaterialTheme.colorScheme.error) - ) { - Icon( - painterResource(R.drawable.xmark), - contentDescription = stringResource(R.string.delete_route), - modifier = Modifier.size(24.dp) - ) - } - } - }, - modifier = modifier - ) -} \ No newline at end of file + ListItem( + headlineContent = { Text(text = route, style = MaterialTheme.typography.bodyMedium) }, + trailingContent = { + Row { + IconButton(onClick = onEdit) { + Icon( + painterResource(R.drawable.pencil), + contentDescription = stringResource(R.string.edit_route), + modifier = Modifier.size(24.dp)) + } + IconButton( + onClick = onDelete, + colors = + IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.error)) { + Icon( + painterResource(R.drawable.xmark), + contentDescription = stringResource(R.string.delete_route), + modifier = Modifier.size(24.dp)) + } + } + }, + modifier = modifier) +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/TailnetLockSetupView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/TailnetLockSetupView.kt index bf3cb79..e8e851a 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/TailnetLockSetupView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/TailnetLockSetupView.kt @@ -22,7 +22,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -63,12 +62,9 @@ fun TailnetLockSetupView( val interactionSource = remember { MutableInteractionSource() } ListItem( modifier = - Modifier.focusable( - interactionSource = interactionSource) - .clickable( - interactionSource = interactionSource, - indication = LocalIndication.current - ) {}, + Modifier.focusable(interactionSource = interactionSource).clickable( + interactionSource = interactionSource, + indication = LocalIndication.current) {}, leadingContent = { Icon( painter = painterResource(id = statusItem.icon), diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/TailscaleLogoView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/TailscaleLogoView.kt index bffc602..b9b5c23 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/TailscaleLogoView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/TailscaleLogoView.kt @@ -19,9 +19,9 @@ import com.tailscale.ipn.ui.theme.onBackgroundLogoDotEnabled import com.tailscale.ipn.ui.theme.standaloneLogoDotDisabled import com.tailscale.ipn.ui.theme.standaloneLogoDotEnabled import com.tailscale.ipn.ui.util.set +import kotlin.concurrent.timer import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlin.concurrent.timer // DotsMatrix represents the state of the progress indicator. typealias DotsMatrix = List> diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt index 5ba489a..7c5d3c4 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/DNSSettingsViewModel.kt @@ -18,12 +18,12 @@ import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.theme.off import com.tailscale.ipn.ui.theme.success import com.tailscale.ipn.ui.util.set +import com.tailscale.ipn.util.TSLog import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import com.tailscale.ipn.util.TSLog class DNSSettingsViewModelFactory : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") 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 index c24b4e5..360d30d 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt @@ -12,12 +12,12 @@ import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.set +import java.util.TreeMap import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import java.util.TreeMap data class ExitNodePickerNav( val onNavigateBackHome: () -> Unit, 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 index 8ff5353..2d75841 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -25,6 +25,7 @@ import com.tailscale.ipn.ui.util.PeerCategorizer import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.ui.util.set +import java.time.Duration import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -33,7 +34,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch -import java.time.Duration class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") 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 e46eb1c..b0531c1 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 @@ -11,10 +11,10 @@ import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.util.ComposableStringFormatter import com.tailscale.ipn.ui.util.set +import java.io.File import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import java.io.File data class PeerSettingInfo(val titleRes: Int, val value: ComposableStringFormatter) diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SubnetRoutingViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SubnetRoutingViewModel.kt index 0bee1bb..a7b1853 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SubnetRoutingViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SubnetRoutingViewModel.kt @@ -19,251 +19,243 @@ import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch /** - * SubnetRoutingViewModel is responsible for managing the content of the subnet router management view. - * This class watches the backend preferences and updates the UI accordingly whenever the advertised routes - * change. It also handles the state of the editing dialog, and updates the preferences stored in - * the backend when the routes are edited in the UI. + * SubnetRoutingViewModel is responsible for managing the content of the subnet router management + * view. This class watches the backend preferences and updates the UI accordingly whenever the + * advertised routes change. It also handles the state of the editing dialog, and updates the + * preferences stored in the backend when the routes are edited in the UI. */ class SubnetRoutingViewModel : ViewModel() { - private val TAG = "SubnetRoutingViewModel" + private val TAG = "SubnetRoutingViewModel" - /** - * Matches the value of the "RouteAll" backend preference. - */ - val routeAll: StateFlow = MutableStateFlow(true) + /** Matches the value of the "RouteAll" backend preference. */ + val routeAll: StateFlow = MutableStateFlow(true) - /** - * The advertised routes displayed at any point in time in the UI. The class observes - * this value for changes, and updates the backend preferences accordingly. - */ - val advertisedRoutes: StateFlow> = MutableStateFlow(listOf()) + /** + * The advertised routes displayed at any point in time in the UI. The class observes this value + * for changes, and updates the backend preferences accordingly. + */ + val advertisedRoutes: StateFlow> = MutableStateFlow(listOf()) - /** - * Whether we are presenting the add/edit dialog to set/change the value of a route. - */ - val isPresentingDialog: StateFlow = MutableStateFlow(false) + /** Whether we are presenting the add/edit dialog to set/change the value of a route. */ + val isPresentingDialog: StateFlow = MutableStateFlow(false) - /** - * When editing a route, this stores the initial value. It is used to determine which - * of the previously existing routes needs to be updated. This starts as empty, and dismissing - * the edit dialog should reset it to empty as well. - * If the user is adding a new route, this will be empty despite isPresentingDialog being true. - */ - private val editingRoute: StateFlow = MutableStateFlow("") + /** + * When editing a route, this stores the initial value. It is used to determine which of the + * previously existing routes needs to be updated. This starts as empty, and dismissing the edit + * dialog should reset it to empty as well. If the user is adding a new route, this will be empty + * despite isPresentingDialog being true. + */ + private val editingRoute: StateFlow = MutableStateFlow("") - /** - * The value currently entered in the add/edit dialog text field. - */ - val dialogTextFieldValue: MutableStateFlow = MutableStateFlow("") + /** The value currently entered in the add/edit dialog text field. */ + val dialogTextFieldValue: MutableStateFlow = MutableStateFlow("") - /** - * True if the value currently entered in the dialog text field is valid, false otherwise. - * If the text field is empty, this returns true as we don't want to display an error state - * when the user hasn't entered anything. - */ - val isTextFieldValueValid: StateFlow = MutableStateFlow(true) + /** + * True if the value currently entered in the dialog text field is valid, false otherwise. If the + * text field is empty, this returns true as we don't want to display an error state when the user + * hasn't entered anything. + */ + val isTextFieldValueValid: StateFlow = MutableStateFlow(true) - /** - * If an error occurred while saving the ipn.Prefs to the backend this value is - * non-null. Subsequent successful attempts to save will clear it. - */ - val currentError: MutableStateFlow = MutableStateFlow(null) + /** + * If an error occurred while saving the ipn.Prefs to the backend this value is non-null. + * Subsequent successful attempts to save will clear it. + */ + val currentError: MutableStateFlow = MutableStateFlow(null) - init { - viewModelScope.launch { - // Any time the value entered by the user in the add/edit dialog changes, we determine - // whether it is valid or invalid, and set isTextFieldValueValid accordingly. - dialogTextFieldValue - .collect { newValue -> - if (newValue.isEmpty()) { - isTextFieldValueValid.set(true) - return@collect - } - val isValid = isValidCIDR(newValue) - Log.v(TAG, "isValidCIDR($newValue): $isValid") - isTextFieldValueValid.set(isValid) - return@collect - } + init { + viewModelScope.launch { + // Any time the value entered by the user in the add/edit dialog changes, we determine + // whether it is valid or invalid, and set isTextFieldValueValid accordingly. + dialogTextFieldValue.collect { newValue -> + if (newValue.isEmpty()) { + isTextFieldValueValid.set(true) + return@collect } + val isValid = isValidCIDR(newValue) + Log.v(TAG, "isValidCIDR($newValue): $isValid") + isTextFieldValueValid.set(isValid) + return@collect + } + } - viewModelScope.launch { - // Similarly, if the routes change in the backend at any time, we should also reflect - // that change in the UI. - Notifier.prefs - // Ignore any prefs updates without AdvertiseRoutes - .mapNotNull { it?.AdvertiseRoutes } - // Ignore duplicate values to prevent an unnecessary UI update - .distinctUntilChanged() - // Ignore any value that matches the current value in UI, - // to prevent an unnecessary UI update - .filter { it != advertisedRoutes }.collect { newRoutesFromBackend -> - Log.d( - TAG, "AdvertiseRoutes changed in the backend: $newRoutesFromBackend" - ) - advertisedRoutes.set(newRoutesFromBackend) - } - } + viewModelScope.launch { + // Similarly, if the routes change in the backend at any time, we should also reflect + // that change in the UI. + Notifier.prefs + // Ignore any prefs updates without AdvertiseRoutes + .mapNotNull { it?.AdvertiseRoutes } + // Ignore duplicate values to prevent an unnecessary UI update + .distinctUntilChanged() + // Ignore any value that matches the current value in UI, + // to prevent an unnecessary UI update + .filter { it != advertisedRoutes } + .collect { newRoutesFromBackend -> + Log.d(TAG, "AdvertiseRoutes changed in the backend: $newRoutesFromBackend") + advertisedRoutes.set(newRoutesFromBackend) + } + } - viewModelScope.launch { - Notifier.prefs.map { it?.RouteAll }.distinctUntilChanged().collect { - Log.d(TAG, "RouteAll changed in the backend: $it") - routeAll.set(it) - } - } + viewModelScope.launch { + Notifier.prefs + .map { it?.RouteAll } + .distinctUntilChanged() + .collect { + Log.d(TAG, "RouteAll changed in the backend: $it") + routeAll.set(it) + } + } - viewModelScope.launch { - routeAll.collect { - val prefsOut = Ipn.MaskedPrefs() - prefsOut.RouteAll = it - Log.d(TAG, "Will save RouteAll in the backend: $it") - Client(viewModelScope).editPrefs(prefsOut, responseHandler = { result -> - if (result.isFailure) { - Log.e(TAG, "Error saving RouteAll: ${result.exceptionOrNull()}") - currentError.set(result.exceptionOrNull()?.localizedMessage) - return@editPrefs - } else { - Log.d( - TAG, - "RouteAll set in backend. New value: ${result.getOrNull()?.RouteAll}" - ) - currentError.set(null) - } + viewModelScope.launch { + routeAll.collect { + val prefsOut = Ipn.MaskedPrefs() + prefsOut.RouteAll = it + Log.d(TAG, "Will save RouteAll in the backend: $it") + Client(viewModelScope) + .editPrefs( + prefsOut, + responseHandler = { result -> + if (result.isFailure) { + Log.e(TAG, "Error saving RouteAll: ${result.exceptionOrNull()}") + currentError.set(result.exceptionOrNull()?.localizedMessage) + return@editPrefs + } else { + Log.d( + TAG, "RouteAll set in backend. New value: ${result.getOrNull()?.RouteAll}") + currentError.set(null) + } }) - } - } + } } + } - // Public functions + // Public functions - fun toggleUseSubnets(onDone: () -> Unit) { - routeAll.set(!routeAll.value) - onDone() - } + fun toggleUseSubnets(onDone: () -> Unit) { + routeAll.set(!routeAll.value) + onDone() + } - /** - * Deletes the given subnet route from the list of advertised routes. - * Calling this function will cause the backend preferences to be updated in the background. - * - * @param route The route string to be deleted from the list of advertised routes. - * If the route does not exist in the list, no changes are made. - */ - fun deleteRoute(route: String) { - val currentRoutes = advertisedRoutes.value.toMutableList() - if (!currentRoutes.contains(route)) { - Log.e(TAG, "Attempted to delete route, but it does not exist: $route") - return - } - currentRoutes.remove(route) - advertisedRoutes.set(currentRoutes) - saveRoutesToPrefs() + /** + * Deletes the given subnet route from the list of advertised routes. Calling this function will + * cause the backend preferences to be updated in the background. + * + * @param route The route string to be deleted from the list of advertised routes. If the route + * does not exist in the list, no changes are made. + */ + fun deleteRoute(route: String) { + val currentRoutes = advertisedRoutes.value.toMutableList() + if (!currentRoutes.contains(route)) { + Log.e(TAG, "Attempted to delete route, but it does not exist: $route") + return } + currentRoutes.remove(route) + advertisedRoutes.set(currentRoutes) + saveRoutesToPrefs() + } - /** - * Starts editing the given subnet route. Called when the user taps the 'pencil' button - * on a route in the list. - */ - fun startEditingRoute(route: String) { - Log.d(TAG, "startEditingRoute: $route") - editingRoute.set(route) - dialogTextFieldValue.set(route) - isPresentingDialog.set(true) - } + /** + * Starts editing the given subnet route. Called when the user taps the 'pencil' button on a route + * in the list. + */ + fun startEditingRoute(route: String) { + Log.d(TAG, "startEditingRoute: $route") + editingRoute.set(route) + dialogTextFieldValue.set(route) + isPresentingDialog.set(true) + } - /** - * Commits the changes made so far in the editing dialog. - */ - fun doneEditingRoute(newValue: String) { - Log.d(TAG, "doneEditingRoute: $newValue") - editRoute(editingRoute.value, newValue) - stopEditingRoute() - } + /** Commits the changes made so far in the editing dialog. */ + fun doneEditingRoute(newValue: String) { + Log.d(TAG, "doneEditingRoute: $newValue") + editRoute(editingRoute.value, newValue) + stopEditingRoute() + } - /** - * Cancels any current editing session and closes the dialog. - */ - fun stopEditingRoute() { - Log.d(TAG, "stopEditingRoute") - isPresentingDialog.set(false) - dialogTextFieldValue.set("") - editingRoute.set("") - } + /** Cancels any current editing session and closes the dialog. */ + fun stopEditingRoute() { + Log.d(TAG, "stopEditingRoute") + isPresentingDialog.set(false) + dialogTextFieldValue.set("") + editingRoute.set("") + } - /** - * This makes the actual changes whenever adding or editing a route. - * If adding a new route, oldRoute will be empty. - * This function validates the input before making any changes. If newRoute - * is not a valid CIDR IPv4/IPv6 range, this function does nothing. - */ - private fun editRoute(oldRoute: String, newRoute: String) { - val currentRoutes = advertisedRoutes.value.toMutableList() - if (oldRoute == newRoute) { - Log.v(TAG, "Attempted to call editRoute with the same route: $newRoute") - return - } - if (currentRoutes.contains(newRoute)) { - Log.e(TAG, "Attempted to call editRoute with a duplicate route: $newRoute") - return - } - // Verify the newRoute is a valid IPv4 or IPv6 CIDR range. - val isValid = isValidCIDR(newRoute) - if (!isValid) { - Log.e(TAG, "Attempted to call editRoute with an invalid route: $newRoute") - return - } - val index = currentRoutes.indexOf(oldRoute) - if (index == -1) { - Log.v(TAG, "Adding new route: $newRoute") - currentRoutes.add(newRoute) - } else { - Log.v(TAG, "Updating route at index $index: $newRoute") - currentRoutes[index] = newRoute - } - advertisedRoutes.set(currentRoutes) - saveRoutesToPrefs() + /** + * This makes the actual changes whenever adding or editing a route. If adding a new route, + * oldRoute will be empty. This function validates the input before making any changes. If + * newRoute is not a valid CIDR IPv4/IPv6 range, this function does nothing. + */ + private fun editRoute(oldRoute: String, newRoute: String) { + val currentRoutes = advertisedRoutes.value.toMutableList() + if (oldRoute == newRoute) { + Log.v(TAG, "Attempted to call editRoute with the same route: $newRoute") + return + } + if (currentRoutes.contains(newRoute)) { + Log.e(TAG, "Attempted to call editRoute with a duplicate route: $newRoute") + return + } + // Verify the newRoute is a valid IPv4 or IPv6 CIDR range. + val isValid = isValidCIDR(newRoute) + if (!isValid) { + Log.e(TAG, "Attempted to call editRoute with an invalid route: $newRoute") + return } + val index = currentRoutes.indexOf(oldRoute) + if (index == -1) { + Log.v(TAG, "Adding new route: $newRoute") + currentRoutes.add(newRoute) + } else { + Log.v(TAG, "Updating route at index $index: $newRoute") + currentRoutes[index] = newRoute + } + advertisedRoutes.set(currentRoutes) + saveRoutesToPrefs() + } - private fun saveRoutesToPrefs() { - val prefsOut = Ipn.MaskedPrefs() - prefsOut.AdvertiseRoutes = advertisedRoutes.value - Log.d(TAG, "Will save AdvertiseRoutes in the backend: $(advertisedRoutes.value)") - Client(viewModelScope).editPrefs(prefsOut, responseHandler = { result -> - if (result.isFailure) { + private fun saveRoutesToPrefs() { + val prefsOut = Ipn.MaskedPrefs() + prefsOut.AdvertiseRoutes = advertisedRoutes.value + Log.d(TAG, "Will save AdvertiseRoutes in the backend: $(advertisedRoutes.value)") + Client(viewModelScope) + .editPrefs( + prefsOut, + responseHandler = { result -> + if (result.isFailure) { Log.e(TAG, "Error saving AdvertiseRoutes: ${result.exceptionOrNull()}") currentError.set(result.exceptionOrNull()?.localizedMessage) return@editPrefs - } else { + } else { Log.d( TAG, - "AdvertiseRoutes set in backend. New value: ${result.getOrNull()?.AdvertiseRoutes}" - ) + "AdvertiseRoutes set in backend. New value: ${result.getOrNull()?.AdvertiseRoutes}") currentError.set(null) - } - }) - } + } + }) + } - /** - * Clears the current error message and reloads the routes currently saved in the backend - * to the UI. We call this when dismissing an error upon saving the routes. - */ - fun onErrorDismissed() { - currentError.set(null) - Client(viewModelScope).prefs { response -> - Log.d(TAG, "Reloading routes from backend due to failed save: $response") - this.advertisedRoutes.set(response.getOrNull()?.AdvertiseRoutes ?: emptyList()) - } + /** + * Clears the current error message and reloads the routes currently saved in the backend to the + * UI. We call this when dismissing an error upon saving the routes. + */ + fun onErrorDismissed() { + currentError.set(null) + Client(viewModelScope).prefs { response -> + Log.d(TAG, "Reloading routes from backend due to failed save: $response") + this.advertisedRoutes.set(response.getOrNull()?.AdvertiseRoutes ?: emptyList()) } + } - companion object RouteValidation { - /** - * Returns true if the given String is a valid IPv4 or IPv6 CIDR range, false otherwise. - */ - fun isValidCIDR(newRoute: String): Boolean { - val cidrPattern = - Regex("(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(\\d+)") // IPv4 CIDR - val ipv6CidrPattern = - Regex("(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/(\\d+)") // IPv6 CIDR - return cidrPattern.matches(newRoute) || ipv6CidrPattern.matches(newRoute) - } + companion object RouteValidation { + /** Returns true if the given String is a valid IPv4 or IPv6 CIDR range, false otherwise. */ + fun isValidCIDR(newRoute: String): Boolean { + val cidrPattern = + Regex( + "(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])/(\\d+)") // IPv4 CIDR + val ipv6CidrPattern = + Regex( + "(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/(\\d+)") // IPv6 CIDR + return cidrPattern.matches(newRoute) || ipv6CidrPattern.matches(newRoute) } + } } - diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index c1c539a..5626e58 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -9,6 +9,7 @@ Disconnect Unknown user Connected + Using exit node (%s) Not connected %s diff --git a/android/src/test/kotlin/com/tailcale/ipn/ui/util/TimeUtilTest.kt b/android/src/test/kotlin/com/tailcale/ipn/ui/util/TimeUtilTest.kt index 743e574..26f5553 100644 --- a/android/src/test/kotlin/com/tailcale/ipn/ui/util/TimeUtilTest.kt +++ b/android/src/test/kotlin/com/tailcale/ipn/ui/util/TimeUtilTest.kt @@ -1,13 +1,12 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause - package com.tailcale.ipn.ui.util - import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.util.TSLog import com.tailscale.ipn.util.TSLog.LibtailscaleWrapper +import java.time.Duration import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -16,92 +15,76 @@ import org.junit.Test import org.mockito.ArgumentMatchers.anyString import org.mockito.Mockito.doNothing import org.mockito.Mockito.mock -import java.time.Duration - class TimeUtilTest { - - private lateinit var libtailscaleWrapperMock: LibtailscaleWrapper - private lateinit var originalWrapper: LibtailscaleWrapper - - - @Before - fun setUp() { - libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java) - doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString()) - - - // Store the original wrapper so we can reset it later - originalWrapper = TSLog.libtailscaleWrapper - // Inject mock into TSLog - TSLog.libtailscaleWrapper = libtailscaleWrapperMock - } - - - @After - fun tearDown() { - // Reset TSLog after each test to avoid side effects - TSLog.libtailscaleWrapper = originalWrapper - } - - - @Test - fun durationInvalidMsUnits() { - val input = "5s10ms" - val actual = TimeUtil.duration(input) - assertNull("Should return null", actual) - } - - - @Test - fun durationInvalidUsUnits() { - val input = "5s10us" - val actual = TimeUtil.duration(input) - assertNull("Should return null", actual) - } - - - @Test - fun durationTestHappyPath() { - val input = arrayOf("1.0y1.0w1.0d1.0h1.0m1.0s", "1s", "1m", "1h", "1d", "1w", "1y") - val expectedSeconds = - arrayOf((31536000 + 604800 + 86400 + 3600 + 60 + 1), 1, 60, 3600, 86400, 604800, 31536000) - val expected = expectedSeconds.map { Duration.ofSeconds(it.toLong()) } - val actual = input.map { TimeUtil.duration(it) } - assertEquals("Incorrect conversion", expected, actual) - } - - - @Test - fun testBadDurationString() { - val input = "1..0y1.0w1.0d1.0h1.0m1.0s" - val actual = TimeUtil.duration(input) - assertNull("Should return null", actual) - } - - - @Test - fun testBadDInputString() { - val libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java) - doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString()) - - - val input = "1.0yy1.0w1.0d1.0h1.0m1.0s" - val actual = TimeUtil.duration(input) - assertNull("Should return null", actual) - } - - - @Test - fun testIgnoreFractionalSeconds() { - val input = "10.9s" - val expectedSeconds = 10 - val expected = Duration.ofSeconds(expectedSeconds.toLong()) - val actual = TimeUtil.duration(input) - assertEquals("Should return $expectedSeconds seconds", expected, actual) - } + private lateinit var libtailscaleWrapperMock: LibtailscaleWrapper + private lateinit var originalWrapper: LibtailscaleWrapper + + @Before + fun setUp() { + libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java) + doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString()) + + // Store the original wrapper so we can reset it later + originalWrapper = TSLog.libtailscaleWrapper + // Inject mock into TSLog + TSLog.libtailscaleWrapper = libtailscaleWrapperMock + } + + @After + fun tearDown() { + // Reset TSLog after each test to avoid side effects + TSLog.libtailscaleWrapper = originalWrapper + } + + @Test + fun durationInvalidMsUnits() { + val input = "5s10ms" + val actual = TimeUtil.duration(input) + assertNull("Should return null", actual) + } + + @Test + fun durationInvalidUsUnits() { + val input = "5s10us" + val actual = TimeUtil.duration(input) + assertNull("Should return null", actual) + } + + @Test + fun durationTestHappyPath() { + val input = arrayOf("1.0y1.0w1.0d1.0h1.0m1.0s", "1s", "1m", "1h", "1d", "1w", "1y") + val expectedSeconds = + arrayOf((31536000 + 604800 + 86400 + 3600 + 60 + 1), 1, 60, 3600, 86400, 604800, 31536000) + val expected = expectedSeconds.map { Duration.ofSeconds(it.toLong()) } + val actual = input.map { TimeUtil.duration(it) } + assertEquals("Incorrect conversion", expected, actual) + } + + @Test + fun testBadDurationString() { + val input = "1..0y1.0w1.0d1.0h1.0m1.0s" + val actual = TimeUtil.duration(input) + assertNull("Should return null", actual) + } + + @Test + fun testBadDInputString() { + val libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java) + doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString()) + + val input = "1.0yy1.0w1.0d1.0h1.0m1.0s" + val actual = TimeUtil.duration(input) + assertNull("Should return null", actual) + } + + @Test + fun testIgnoreFractionalSeconds() { + val input = "10.9s" + val expectedSeconds = 10 + val expected = Duration.ofSeconds(expectedSeconds.toLong()) + val actual = TimeUtil.duration(input) + assertEquals("Should return $expectedSeconds seconds", expected, actual) + } } - - - diff --git a/docker/DockerFile.amd64-build b/docker/DockerFile.amd64-build index 2b79c70..a08af10 100644 --- a/docker/DockerFile.amd64-build +++ b/docker/DockerFile.amd64-build @@ -36,7 +36,7 @@ COPY Makefile Makefile RUN make androidsdk # Preload Gradle -COPY android/gradlew android/build.gradle android +COPY android/gradlew android/build.gradle android/ COPY android/gradle android/gradle RUN chmod 755 android/gradlew && \