Merge branch 'main' of github.com:tailscale/tailscale-android into kari/search
# Conflicts: # android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.ktkari/search
commit
3aaff20959
@ -0,0 +1,35 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
package com.tailscale.ipn
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
|
||||
object AppSourceChecker {
|
||||
|
||||
const val TAG = "AppSourceChecker"
|
||||
|
||||
fun getInstallSource(context: Context): String {
|
||||
val packageManager = context.packageManager
|
||||
val packageName = context.packageName
|
||||
Log.d(TAG, "Package name: $packageName")
|
||||
|
||||
val installerPackageName =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
packageManager.getInstallSourceInfo(packageName).installingPackageName
|
||||
} else {
|
||||
@Suppress("deprecation") packageManager.getInstallerPackageName(packageName)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Installer package name: $installerPackageName")
|
||||
|
||||
return when (installerPackageName) {
|
||||
"com.android.vending" -> "googleplay"
|
||||
"org.fdroid.fdroid" -> "fdroid"
|
||||
"com.amazon.venezia" -> "amazon"
|
||||
null -> "unknown"
|
||||
else -> "unknown($installerPackageName)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn;
|
||||
|
||||
import android.app.Activity;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
public class MaybeGoogle {
|
||||
static boolean isGoogle() {
|
||||
return getGoogle() != null;
|
||||
}
|
||||
|
||||
static String getIdTokenForActivity(Activity act) {
|
||||
Class<?> google = getGoogle();
|
||||
if (google == null) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
Method method = google.getMethod("getIdTokenForActivity", Activity.class);
|
||||
return (String) method.invoke(null, act);
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static Class getGoogle() {
|
||||
try {
|
||||
return Class.forName("com.tailscale.ipn.Google");
|
||||
} catch (ClassNotFoundException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,168 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
package com.tailscale.ipn
|
||||
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.LinkProperties
|
||||
import android.net.Network
|
||||
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
|
||||
|
||||
object NetworkChangeCallback {
|
||||
|
||||
private const val TAG = "NetworkChangeCallback"
|
||||
|
||||
private data class NetworkInfo(var caps: NetworkCapabilities, var linkProps: LinkProperties)
|
||||
|
||||
private val lock = ReentrantLock()
|
||||
|
||||
private val activeNetworks = mutableMapOf<Network, NetworkInfo>() // keyed by Network
|
||||
|
||||
// monitorDnsChanges sets up a network callback to monitor changes to the
|
||||
// system's network state and update the DNS configuration when interfaces
|
||||
// become available or properties of those interfaces change.
|
||||
fun monitorDnsChanges(connectivityManager: ConnectivityManager, dns: DnsConfig) {
|
||||
val networkConnectivityRequest =
|
||||
NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
.build()
|
||||
|
||||
// Use registerNetworkCallback to listen for updates from all networks, and
|
||||
// then update DNS configs for the best network when LinkProperties are changed.
|
||||
// Per
|
||||
// https://developer.android.com/reference/android/net/ConnectivityManager.NetworkCallback#onAvailable(android.net.Network), this happens after all other updates.
|
||||
//
|
||||
// Note that we can't use registerDefaultNetworkCallback because the
|
||||
// default network used by Tailscale will always show up with capability
|
||||
// NOT_VPN=false, and we must filter out NOT_VPN networks to avoid routing
|
||||
// loops.
|
||||
connectivityManager.registerNetworkCallback(
|
||||
networkConnectivityRequest,
|
||||
object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
super.onAvailable(network)
|
||||
|
||||
TSLog.d(TAG, "onAvailable: network ${network}")
|
||||
lock.withLock {
|
||||
activeNetworks[network] = NetworkInfo(NetworkCapabilities(), LinkProperties())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
|
||||
super.onCapabilitiesChanged(network, capabilities)
|
||||
lock.withLock { activeNetworks[network]?.caps = capabilities }
|
||||
}
|
||||
|
||||
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
|
||||
super.onLinkPropertiesChanged(network, linkProperties)
|
||||
lock.withLock {
|
||||
activeNetworks[network]?.linkProps = linkProperties
|
||||
maybeUpdateDNSConfig("onLinkPropertiesChanged", dns)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
super.onLost(network)
|
||||
|
||||
TSLog.d(TAG, "onLost: network ${network}")
|
||||
lock.withLock {
|
||||
activeNetworks.remove(network)
|
||||
maybeUpdateDNSConfig("onLost", dns)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// pickNonMetered returns the first non-metered network in the list of
|
||||
// networks, or the first network if none are non-metered.
|
||||
private fun pickNonMetered(networks: Map<Network, NetworkInfo>): Network? {
|
||||
for ((network, info) in networks) {
|
||||
if (info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)) {
|
||||
return network
|
||||
}
|
||||
}
|
||||
return networks.keys.firstOrNull()
|
||||
}
|
||||
|
||||
// pickDefaultNetwork returns a non-VPN network to use as the 'default'
|
||||
// network; one that is used as a gateway to the internet and from which we
|
||||
// obtain our DNS servers.
|
||||
private fun pickDefaultNetwork(): Network? {
|
||||
// Filter the list of all networks to those that have the INTERNET
|
||||
// capability, are not VPNs, and have a non-zero number of DNS servers
|
||||
// available.
|
||||
val networks =
|
||||
activeNetworks.filter { (_, info) ->
|
||||
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) &&
|
||||
info.linkProps.dnsServers.isNotEmpty() == true
|
||||
}
|
||||
|
||||
// If we have one; just return it; otherwise, prefer networks that are also
|
||||
// not metered (i.e. cell modems).
|
||||
val nonMeteredNetwork = pickNonMetered(networks)
|
||||
if (nonMeteredNetwork != null) {
|
||||
return nonMeteredNetwork
|
||||
}
|
||||
|
||||
// Okay, less good; just return the first network that has the INTERNET and
|
||||
// NOT_VPN capabilities; even though this interface doesn't have any DNS
|
||||
// servers set, we'll use our DNS fallback servers to make queries. It's
|
||||
// strictly better to return an interface + use the DNS fallback servers
|
||||
// than to return nothing and not be able to route traffic.
|
||||
for ((network, info) in activeNetworks) {
|
||||
if (info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"no networks available that also have DNS servers set; falling back to first network ${network}")
|
||||
return network
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, return nothing; we don't want to return a VPN network since
|
||||
// it could result in a routing loop, and a non-INTERNET network isn't
|
||||
// helpful.
|
||||
Log.w(TAG, "no networks available to pick a default network")
|
||||
return null
|
||||
}
|
||||
|
||||
// maybeUpdateDNSConfig will maybe update our DNS configuration based on the
|
||||
// current set of active Networks.
|
||||
private fun maybeUpdateDNSConfig(why: String, dns: DnsConfig) {
|
||||
val defaultNetwork = pickDefaultNetwork()
|
||||
if (defaultNetwork == null) {
|
||||
TSLog.d(TAG, "${why}: no default network available; not updating DNS config")
|
||||
return
|
||||
}
|
||||
val info = activeNetworks[defaultNetwork]
|
||||
if (info == null) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"${why}: [unexpected] no info available for default network; not updating DNS config")
|
||||
return
|
||||
}
|
||||
|
||||
val sb = StringBuilder()
|
||||
for (ip in info.linkProps.dnsServers) {
|
||||
sb.append(ip.hostAddress).append(" ")
|
||||
}
|
||||
val searchDomains: String? = info.linkProps.domains
|
||||
if (searchDomains != null) {
|
||||
sb.append("\n")
|
||||
sb.append(searchDomains)
|
||||
}
|
||||
if (dns.updateDNSFromNetwork(sb.toString())) {
|
||||
TSLog.d(
|
||||
TAG,
|
||||
"${why}: updated DNS config for network ${defaultNetwork} (${info.linkProps.interfaceName})")
|
||||
Libtailscale.onDNSConfigChanged(info.linkProps.interfaceName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.mdm
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.RestrictionsManager
|
||||
import com.tailscale.ipn.App
|
||||
import com.tailscale.ipn.util.TSLog
|
||||
|
||||
class MDMSettingsChangedReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == android.content.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
import com.tailscale.ipn.BuildConfig
|
||||
|
||||
class AppVersion {
|
||||
companion object {
|
||||
// Returns the short version of the build version, which is what users typically expect.
|
||||
// For instance, if the build version is "1.75.80-t8fdffb8da-g2daeee584df",
|
||||
// this function returns "1.75.80".
|
||||
fun Short(): String {
|
||||
// Split the full version string by hyphen (-)
|
||||
val parts = BuildConfig.VERSION_NAME.split("-")
|
||||
// Return only the part before the first hyphen
|
||||
return parts[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
/// Applies different modifiers to the receiver based on a condition.
|
||||
inline fun Modifier.conditional(
|
||||
condition: Boolean,
|
||||
ifTrue: Modifier.() -> Modifier,
|
||||
ifFalse: Modifier.() -> Modifier = { this },
|
||||
): Modifier =
|
||||
if (condition) {
|
||||
then(ifTrue(Modifier))
|
||||
} else {
|
||||
then(ifFalse(Modifier))
|
||||
}
|
||||
@ -0,0 +1,150 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SearchBar
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.tailscale.ipn.ui.viewModel.MainViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SearchView(
|
||||
viewModel: MainViewModel,
|
||||
navController: NavController, // Use NavController for navigation
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
val searchTerm by viewModel.searchTerm.collectAsState()
|
||||
val filteredPeers by viewModel.peers.collectAsState()
|
||||
val netmap by viewModel.netmap.collectAsState()
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
var expanded by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
keyboardController?.show()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().focusRequester(focusRequester).clickable {
|
||||
focusRequester.requestFocus()
|
||||
keyboardController?.show()
|
||||
}) {
|
||||
SearchBar(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
query = searchTerm,
|
||||
onQueryChange = { query ->
|
||||
viewModel.updateSearchTerm(query)
|
||||
expanded = query.isNotEmpty()
|
||||
},
|
||||
onSearch = { query ->
|
||||
viewModel.updateSearchTerm(query)
|
||||
focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
},
|
||||
placeholder = { Text("Search") },
|
||||
leadingIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
onNavigateBack()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
if (searchTerm.isNotEmpty()) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
viewModel.updateSearchTerm("")
|
||||
focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
}) {
|
||||
Icon(Icons.Default.Clear, contentDescription = "Clear search")
|
||||
}
|
||||
}
|
||||
},
|
||||
active = expanded,
|
||||
onActiveChange = { expanded = it },
|
||||
content = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState()).fillMaxSize()) {
|
||||
filteredPeers.forEach { peerSet ->
|
||||
val userName = peerSet.user?.DisplayName ?: "Unknown User"
|
||||
peerSet.peers.forEach { peer ->
|
||||
val deviceName = peer.displayName ?: "Unknown Device"
|
||||
val ipAddress = peer.Addresses?.firstOrNull()?.split("/")?.first() ?: "No IP"
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text(userName) },
|
||||
supportingContent = {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val onlineColor = peer.connectedColor(netmap)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(10.dp)
|
||||
.background(onlineColor, shape = RoundedCornerShape(50)))
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(deviceName)
|
||||
}
|
||||
Text(ipAddress)
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier =
|
||||
Modifier.clickable {
|
||||
navController.navigate("peerDetails/${peer.StableID}")
|
||||
}
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
package com.tailscale.ipn.util
|
||||
|
||||
import android.util.Log
|
||||
import libtailscale.Libtailscale
|
||||
|
||||
object TSLog {
|
||||
var libtailscaleWrapper = LibtailscaleWrapper()
|
||||
|
||||
fun d(tag: String?, message: String) {
|
||||
Log.d(tag, message)
|
||||
libtailscaleWrapper.sendLog(tag, message)
|
||||
}
|
||||
|
||||
fun w(tag: String, message: String) {
|
||||
Log.w(tag, message)
|
||||
libtailscaleWrapper.sendLog(tag, message)
|
||||
}
|
||||
|
||||
// Overloaded function without Throwable because Java does not support default parameters
|
||||
@JvmStatic
|
||||
fun e(tag: String?, message: String) {
|
||||
Log.e(tag, message)
|
||||
libtailscaleWrapper.sendLog(tag, message)
|
||||
}
|
||||
|
||||
fun e(tag: String?, message: String, throwable: Throwable? = null) {
|
||||
if (throwable == null) {
|
||||
Log.e(tag, message)
|
||||
libtailscaleWrapper.sendLog(tag, message)
|
||||
} else {
|
||||
Log.e(tag, message, throwable)
|
||||
libtailscaleWrapper.sendLog(tag, "$message ${throwable?.localizedMessage}")
|
||||
}
|
||||
}
|
||||
|
||||
class LibtailscaleWrapper {
|
||||
public fun sendLog(tag: String?, message: String) {
|
||||
val logTag = tag ?: ""
|
||||
Libtailscale.sendLog((logTag + ": " + message).toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@ -1,60 +1,107 @@
|
||||
// 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 org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
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 {
|
||||
|
||||
@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 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [[ -z "$TOOLCHAIN_DIR" ]]; then
|
||||
# By default, if TOOLCHAIN_DIR is unset, we assume we're
|
||||
# using the Tailscale Go toolchain (github.com/tailscale/go)
|
||||
# at the revision specified by go.toolchain.rev. If so,
|
||||
# we tell our caller to use the "tailscale_go" build tag.
|
||||
echo "tailscale_go"
|
||||
else
|
||||
# Otherwise, if TOOLCHAIN_DIR is specified, we assume
|
||||
# we're F-Droid or something using a stock Go toolchain.
|
||||
# That's fine. But we don't set the tailscale_go build tag.
|
||||
# Return some no-op build tag that's non-empty for clarity
|
||||
# when debugging.
|
||||
echo "not_tailscale_go"
|
||||
fi
|
||||
@ -1 +1 @@
|
||||
22ef9eb38e9a2d21b4a45f7adc75addb05f3efb8
|
||||
96578f73d04e1a231fa2a495ad3fa97747785bc6
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
source tailscale.version || echo >&2 "no tailscale.version file found"
|
||||
if [[ -z "${VERSION_LONG}" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
echo "-X tailscale.com/version.longStamp=${VERSION_LONG}"
|
||||
echo "-X tailscale.com/version.shortStamp=${VERSION_SHORT}"
|
||||
echo "-X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}"
|
||||
echo "-X tailscale.com/version.extraGitCommitStamp=${VERSION_EXTRA_HASH}"
|
||||
@ -1,43 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style
|
||||
# license that can be found in the LICENSE file.
|
||||
|
||||
# Print the version tailscale repository corresponding
|
||||
# to the version listed in go.mod.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
go_list=$(go list -m tailscale.com)
|
||||
# go list outputs `tailscale.com <version>`. Extract the version.
|
||||
mod_version=${go_list#tailscale.com}
|
||||
|
||||
if [ -z "$mod_version" ]; then
|
||||
echo >&2 "no version reported by go list -m tailscale.com: $go_list"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$mod_version" in
|
||||
*-*-*)
|
||||
# A pseudo-version such as "v1.1.1-0.20201030135043-eab6e9ea4e45"
|
||||
# includes the commit hash.
|
||||
mod_version=${mod_version##*-*-}
|
||||
;;
|
||||
esac
|
||||
|
||||
tailscale_clone=$(mktemp -d -t tailscale-clone-XXXXXXXXXX)
|
||||
git clone -q https://github.com/tailscale/tailscale.git "$tailscale_clone"
|
||||
|
||||
cd $tailscale_clone
|
||||
git reset --hard -q
|
||||
git clean -d -x -f
|
||||
git fetch -q --all --tags
|
||||
git checkout -q ${mod_version}
|
||||
|
||||
eval $(./build_dist.sh shellvars)
|
||||
git_hash=$(git rev-parse HEAD)
|
||||
short_hash=$(echo "$git_hash" | cut -c1-9)
|
||||
echo ${VERSION_SHORT}-t${short_hash}
|
||||
cd /tmp
|
||||
rm -rf "$tailscale_clone"
|
||||
Loading…
Reference in New Issue