android: send Android logs to logz (#515)

TSLog sends log messages to Android's logcat and Tailscale's logger
Libtailscale wrapper is a Kotlin wrapper that allows us to get around the problems with mocking a native library

Fixes tailscale/corp#23191

Signed-off-by: kari-ts <kari@tailscale.com>
pull/522/head
kari-ts 1 year ago committed by GitHub
parent f26a828cbd
commit 08ae018468
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -160,6 +160,9 @@ dependencies {
// Unit Tests // Unit Tests
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:5.4.0'
testImplementation 'org.mockito:mockito-inline:5.2.0'
testImplementation 'org.mockito.kotlin:mockito-kotlin:5.4.0'
debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.ui:ui-tooling-preview")

@ -31,6 +31,7 @@ import com.tailscale.ipn.ui.notifier.HealthNotifier
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -162,7 +163,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
result.fold( result.fold(
onSuccess = { onSuccess?.invoke() }, onSuccess = { onSuccess?.invoke() },
onFailure = { error -> onFailure = { error ->
Log.d("TAG", "Set want running: failed to update preferences: ${error.message}") TSLog.d("TAG", "Set want running: failed to update preferences: ${error.message}")
}) })
} }
Client(applicationScope) Client(applicationScope)
@ -203,7 +204,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
private fun updateConnStatus(ableToStartVPN: Boolean) { private fun updateConnStatus(ableToStartVPN: Boolean) {
setAbleToStartVPN(ableToStartVPN) setAbleToStartVPN(ableToStartVPN)
QuickToggleService.updateTile() QuickToggleService.updateTile()
Log.d("App", "Set Tile Ready: $ableToStartVPN") TSLog.d("App", "Set Tile Ready: $ableToStartVPN")
} }
override fun getModelName(): String { override fun getModelName(): String {
@ -266,14 +267,14 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
downloads.mkdirs() downloads.mkdirs()
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to create downloads folder: $e") TSLog.e(TAG, "Failed to create downloads folder: $e")
downloads = File(this.filesDir, "Taildrop") downloads = File(this.filesDir, "Taildrop")
try { try {
if (!downloads.exists()) { if (!downloads.exists()) {
downloads.mkdirs() downloads.mkdirs()
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to create Taildrop folder: $e") TSLog.e(TAG, "Failed to create Taildrop folder: $e")
downloads = File("") downloads = File("")
} }
} }
@ -308,7 +309,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
val list = setting.value as? List<*> val list = setting.value as? List<*>
return Json.encodeToString(list) return Json.encodeToString(list)
} catch (e: Exception) { } catch (e: Exception) {
Log.d("MDM", "$key value cannot be serialized to JSON. Throwing NoSuchKeyException.") TSLog.d("MDM", "$key value cannot be serialized to JSON. Throwing NoSuchKeyException.")
throw MDMSettings.NoSuchKeyException() throw MDMSettings.NoSuchKeyException()
} }
} }
@ -373,13 +374,13 @@ open class UninitializedApp : Application() {
try { try {
startForegroundService(intent) startForegroundService(intent)
} catch (foregroundServiceStartException: IllegalStateException) { } catch (foregroundServiceStartException: IllegalStateException) {
Log.e( TSLog.e(
TAG, TAG,
"startVPN hit ForegroundServiceStartNotAllowedException in startForegroundService(): $foregroundServiceStartException") "startVPN hit ForegroundServiceStartNotAllowedException in startForegroundService(): $foregroundServiceStartException")
} catch (securityException: SecurityException) { } catch (securityException: SecurityException) {
Log.e(TAG, "startVPN hit SecurityException in startForegroundService(): $securityException") TSLog.e(TAG, "startVPN hit SecurityException in startForegroundService(): $securityException")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "startVPN hit exception in startForegroundService(): $e") TSLog.e(TAG, "startVPN hit exception in startForegroundService(): $e")
} }
} }
@ -388,9 +389,9 @@ open class UninitializedApp : Application() {
try { try {
startService(intent) startService(intent)
} catch (illegalStateException: IllegalStateException) { } catch (illegalStateException: IllegalStateException) {
Log.e(TAG, "stopVPN hit IllegalStateException in startService(): $illegalStateException") TSLog.e(TAG, "stopVPN hit IllegalStateException in startService(): $illegalStateException")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "stopVPN hit exception in startService(): $e") TSLog.e(TAG, "stopVPN hit exception in startService(): $e")
} }
} }
@ -465,7 +466,7 @@ open class UninitializedApp : Application() {
fun addUserDisallowedPackageName(packageName: String) { fun addUserDisallowedPackageName(packageName: String) {
if (packageName.isEmpty()) { if (packageName.isEmpty()) {
Log.e(TAG, "addUserDisallowedPackageName called with empty packageName") TSLog.e(TAG, "addUserDisallowedPackageName called with empty packageName")
return return
} }
@ -480,7 +481,7 @@ open class UninitializedApp : Application() {
fun removeUserDisallowedPackageName(packageName: String) { fun removeUserDisallowedPackageName(packageName: String) {
if (packageName.isEmpty()) { if (packageName.isEmpty()) {
Log.e(TAG, "removeUserDisallowedPackageName called with empty packageName") TSLog.e(TAG, "removeUserDisallowedPackageName called with empty packageName")
return return
} }
@ -498,7 +499,7 @@ open class UninitializedApp : Application() {
val mdmDisallowed = val mdmDisallowed =
MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
if (mdmDisallowed.isNotEmpty()) { if (mdmDisallowed.isNotEmpty()) {
Log.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed") TSLog.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed")
return builtInDisallowedPackageNames + mdmDisallowed return builtInDisallowedPackageNames + mdmDisallowed
} }
val userDisallowed = val userDisallowed =

@ -8,10 +8,10 @@ import android.content.pm.PackageManager
import android.net.VpnService import android.net.VpnService
import android.os.Build import android.os.Build
import android.system.OsConstants import android.system.OsConstants
import android.util.Log
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.util.TSLog
import libtailscale.Libtailscale import libtailscale.Libtailscale
import java.util.UUID import java.util.UUID
@ -97,7 +97,7 @@ open class IPNService : VpnService(), libtailscale.IPNService {
UninitializedApp.STATUS_NOTIFICATION_ID, UninitializedApp.STATUS_NOTIFICATION_ID,
UninitializedApp.get().buildStatusNotification(true)) UninitializedApp.get().buildStatusNotification(true))
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to start foreground service: $e") TSLog.e(TAG, "Failed to start foreground service: $e")
} }
} }
@ -113,7 +113,7 @@ open class IPNService : VpnService(), libtailscale.IPNService {
try { try {
b.addDisallowedApplication(name) b.addDisallowedApplication(name)
} catch (e: PackageManager.NameNotFoundException) { } catch (e: PackageManager.NameNotFoundException) {
Log.d(TAG, "Failed to add disallowed application: $e") TSLog.d(TAG, "Failed to add disallowed application: $e")
} }
} }
@ -135,7 +135,7 @@ open class IPNService : VpnService(), libtailscale.IPNService {
// Tailscale, // Tailscale,
// then only allow those apps. // then only allow those apps.
for (packageName in includedPackages) { for (packageName in includedPackages) {
Log.d(TAG, "Including app: $packageName") TSLog.d(TAG, "Including app: $packageName")
b.addAllowedApplication(packageName) b.addAllowedApplication(packageName)
} }
} else { } else {
@ -143,7 +143,7 @@ open class IPNService : VpnService(), libtailscale.IPNService {
// - any app that the user manually disallowed in the GUI // - any app that the user manually disallowed in the GUI
// - any app that we disallowed via hard-coding // - any app that we disallowed via hard-coding
for (disallowedPackageName in UninitializedApp.get().disallowedPackageNames()) { for (disallowedPackageName in UninitializedApp.get().disallowedPackageNames()) {
Log.d(TAG, "Disallowing app: $disallowedPackageName") TSLog.d(TAG, "Disallowing app: $disallowedPackageName")
disallowApp(b, disallowedPackageName) disallowApp(b, disallowedPackageName)
} }
} }

@ -16,7 +16,6 @@ import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
@ -78,6 +77,7 @@ import com.tailscale.ipn.ui.viewModel.MainViewModelFactory
import com.tailscale.ipn.ui.viewModel.PingViewModel import com.tailscale.ipn.ui.viewModel.PingViewModel
import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -128,15 +128,15 @@ class MainActivity : ComponentActivity() {
vpnPermissionLauncher = vpnPermissionLauncher =
registerForActivityResult(VpnPermissionContract()) { granted -> registerForActivityResult(VpnPermissionContract()) { granted ->
if (granted) { if (granted) {
Log.d("VpnPermission", "VPN permission granted") TSLog.d("VpnPermission", "VPN permission granted")
vpnViewModel.setVpnPrepared(true) vpnViewModel.setVpnPrepared(true)
App.get().startVPN() App.get().startVPN()
} else { } else {
if (isAnotherVpnActive(this)) { if (isAnotherVpnActive(this)) {
Log.d("VpnPermission", "Another VPN is likely active") TSLog.d("VpnPermission", "Another VPN is likely active")
showOtherVPNConflictDialog() showOtherVPNConflictDialog()
} else { } else {
Log.d("VpnPermission", "Permission was denied by the user") TSLog.d("VpnPermission", "Permission was denied by the user")
vpnViewModel.setVpnPrepared(false) vpnViewModel.setVpnPrepared(false)
} }
} }
@ -357,7 +357,7 @@ class MainActivity : ComponentActivity() {
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Login: failed to start MainActivity: $e") TSLog.e(TAG, "Login: failed to start MainActivity: $e")
} }
} }
@ -371,7 +371,7 @@ class MainActivity : ComponentActivity() {
val fallbackIntent = Intent(Intent.ACTION_VIEW, url) val fallbackIntent = Intent(Intent.ACTION_VIEW, url)
startActivity(fallbackIntent) startActivity(fallbackIntent)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Login: failed to open browser: $e") TSLog.e(TAG, "Login: failed to open browser: $e")
} }
} }
} }

@ -8,6 +8,7 @@ import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import android.util.Log import android.util.Log
import com.tailscale.ipn.util.TSLog
import libtailscale.Libtailscale import libtailscale.Libtailscale
import java.util.concurrent.locks.ReentrantLock import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock import kotlin.concurrent.withLock
@ -47,7 +48,7 @@ object NetworkChangeCallback {
override fun onAvailable(network: Network) { override fun onAvailable(network: Network) {
super.onAvailable(network) super.onAvailable(network)
Log.d(TAG, "onAvailable: network ${network}") TSLog.d(TAG, "onAvailable: network ${network}")
lock.withLock { lock.withLock {
activeNetworks[network] = NetworkInfo(NetworkCapabilities(), LinkProperties()) activeNetworks[network] = NetworkInfo(NetworkCapabilities(), LinkProperties())
} }
@ -69,7 +70,7 @@ object NetworkChangeCallback {
override fun onLost(network: Network) { override fun onLost(network: Network) {
super.onLost(network) super.onLost(network)
Log.d(TAG, "onLost: network ${network}") TSLog.d(TAG, "onLost: network ${network}")
lock.withLock { lock.withLock {
activeNetworks.remove(network) activeNetworks.remove(network)
maybeUpdateDNSConfig("onLost", dns) maybeUpdateDNSConfig("onLost", dns)
@ -137,7 +138,7 @@ object NetworkChangeCallback {
private fun maybeUpdateDNSConfig(why: String, dns: DnsConfig) { private fun maybeUpdateDNSConfig(why: String, dns: DnsConfig) {
val defaultNetwork = pickDefaultNetwork() val defaultNetwork = pickDefaultNetwork()
if (defaultNetwork == null) { if (defaultNetwork == null) {
Log.d(TAG, "${why}: no default network available; not updating DNS config") TSLog.d(TAG, "${why}: no default network available; not updating DNS config")
return return
} }
val info = activeNetworks[defaultNetwork] val info = activeNetworks[defaultNetwork]
@ -158,7 +159,7 @@ object NetworkChangeCallback {
sb.append(searchDomains) sb.append(searchDomains)
} }
if (dns.updateDNSFromNetwork(sb.toString())) { if (dns.updateDNSFromNetwork(sb.toString())) {
Log.d( TSLog.d(
TAG, TAG,
"${why}: updated DNS config for network ${defaultNetwork} (${info.linkProps.interfaceName})") "${why}: updated DNS config for network ${defaultNetwork} (${info.linkProps.interfaceName})")
Libtailscale.onDNSConfigChanged(info.linkProps.interfaceName) Libtailscale.onDNSConfigChanged(info.linkProps.interfaceName)

@ -8,7 +8,6 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.util.Log
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@ -20,6 +19,7 @@ import com.tailscale.ipn.ui.theme.AppTheme
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.util.universalFit import com.tailscale.ipn.ui.util.universalFit
import com.tailscale.ipn.ui.view.TaildropView import com.tailscale.ipn.ui.view.TaildropView
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlin.random.Random import kotlin.random.Random
@ -59,7 +59,7 @@ class ShareActivity : ComponentActivity() {
// Loads the files from the intent. // Loads the files from the intent.
fun loadFiles() { fun loadFiles() {
if (intent == null) { if (intent == null) {
Log.e(TAG, "Share failure - No intent found") TSLog.e(TAG, "Share failure - No intent found")
return return
} }
@ -83,7 +83,7 @@ class ShareActivity : ComponentActivity() {
} }
} }
else -> { else -> {
Log.e(TAG, "No extras found in intent - nothing to share") TSLog.e(TAG, "No extras found in intent - nothing to share")
null null
} }
} }
@ -117,7 +117,7 @@ class ShareActivity : ComponentActivity() {
} ?: emptyList() } ?: emptyList()
if (pendingFiles.isEmpty()) { if (pendingFiles.isEmpty()) {
Log.e(TAG, "Share failure - no files extracted from intent") TSLog.e(TAG, "Share failure - no files extracted from intent")
} }
requestedTransfers.set(pendingFiles) requestedTransfers.set(pendingFiles)

@ -15,6 +15,8 @@ import androidx.annotation.NonNull;
import androidx.work.Worker; import androidx.work.Worker;
import androidx.work.WorkerParameters; import androidx.work.WorkerParameters;
import com.tailscale.ipn.util.TSLog;
/** /**
* A worker that exists to support IPNReceiver. * A worker that exists to support IPNReceiver.
*/ */
@ -38,7 +40,7 @@ public final class StartVPNWorker extends Worker {
} }
// We aren't ready to start the VPN or don't have permission, open the Tailscale app. // We aren't ready to start the VPN or don't have permission, open the Tailscale app.
android.util.Log.e("StartVPNWorker", "Tailscale isn't ready to start the VPN, notify the user."); TSLog.e("StartVPNWorker", "Tailscale isn't ready to start the VPN, notify the user.");
// Send notification // Send notification
NotificationManager notificationManager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE); NotificationManager notificationManager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE);

@ -4,7 +4,6 @@
package com.tailscale.ipn.ui.localapi package com.tailscale.ipn.ui.localapi
import android.content.Context import android.content.Context
import android.util.Log
import com.tailscale.ipn.ui.model.BugReportID import com.tailscale.ipn.ui.model.BugReportID
import com.tailscale.ipn.ui.model.Errors import com.tailscale.ipn.ui.model.Errors
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
@ -13,6 +12,7 @@ import com.tailscale.ipn.ui.model.IpnState
import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.util.InputStreamAdapter import com.tailscale.ipn.ui.util.InputStreamAdapter
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -175,7 +175,7 @@ class Client(private val scope: CoroutineScope) {
}) })
} catch (e: Exception) { } catch (e: Exception) {
parts.forEach { it.body.close() } parts.forEach { it.body.close() }
Log.e(TAG, "Error creating file upload body: $e") TSLog.e(TAG, "Error creating file upload body: $e")
responseHandler(Result.failure(e)) responseHandler(Result.failure(e))
return return
} }
@ -307,7 +307,7 @@ class Request<T>(
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
fun execute() { fun execute() {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
Log.d(TAG, "Executing request:${method}:${fullPath} on app $app") TSLog.d(TAG, "Executing request:${method}:${fullPath} on app $app")
try { try {
val resp = val resp =
if (parts != null) app.callLocalAPIMultipart(timeoutMillis, method, fullPath, parts) if (parts != null) app.callLocalAPIMultipart(timeoutMillis, method, fullPath, parts)
@ -350,7 +350,7 @@ class Request<T>(
// The response handler will invoked internally by the request parser // The response handler will invoked internally by the request parser
scope.launch { responseHandler(response) } scope.launch { responseHandler(response) }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error executing request:${method}:${fullPath}: $e") TSLog.e(TAG, "Error executing request:${method}:${fullPath}: $e")
scope.launch { responseHandler(Result.failure(e)) } scope.launch { responseHandler(Result.failure(e)) }
} }
} }

@ -5,7 +5,6 @@ package com.tailscale.ipn.ui.notifier
import android.Manifest import android.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.util.Log
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.tailscale.ipn.App import com.tailscale.ipn.App
@ -14,6 +13,7 @@ import com.tailscale.ipn.UninitializedApp.Companion.notificationManager
import com.tailscale.ipn.ui.model.Health import com.tailscale.ipn.ui.model.Health
import com.tailscale.ipn.ui.model.Health.UnhealthyState import com.tailscale.ipn.ui.model.Health.UnhealthyState
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -47,7 +47,7 @@ class HealthNotifier(
.distinctUntilChanged { old, new -> old?.Warnings?.count() == new?.Warnings?.count() } .distinctUntilChanged { old, new -> old?.Warnings?.count() == new?.Warnings?.count() }
.debounce(5000) .debounce(5000)
.collect { health -> .collect { health ->
Log.d(TAG, "Health updated: ${health?.Warnings?.keys?.sorted()}") TSLog.d(TAG, "Health updated: ${health?.Warnings?.keys?.sorted()}")
health?.Warnings?.let { health?.Warnings?.let {
notifyHealthUpdated(it.values.mapNotNull { it }.toTypedArray()) notifyHealthUpdated(it.values.mapNotNull { it }.toTypedArray())
} }
@ -76,22 +76,22 @@ class HealthNotifier(
continue continue
} else if (warning.hiddenByDependencies(currentWarnableCodes)) { } else if (warning.hiddenByDependencies(currentWarnableCodes)) {
// Ignore this warning because a dependency is also unhealthy // Ignore this warning because a dependency is also unhealthy
Log.d(TAG, "Ignoring ${warning.WarnableCode} because of dependency") TSLog.d(TAG, "Ignoring ${warning.WarnableCode} because of dependency")
continue continue
} else if (!isWarmingUp) { } else if (!isWarmingUp) {
Log.d(TAG, "Adding health warning: ${warning.WarnableCode}") TSLog.d(TAG, "Adding health warning: ${warning.WarnableCode}")
this.currentWarnings.set(this.currentWarnings.value + warning) this.currentWarnings.set(this.currentWarnings.value + warning)
if (warning.Severity == Health.Severity.high) { if (warning.Severity == Health.Severity.high) {
this.sendNotification(warning.Title, warning.Text, warning.WarnableCode) this.sendNotification(warning.Title, warning.Text, warning.WarnableCode)
} }
} else { } else {
Log.d(TAG, "Ignoring ${warning.WarnableCode} because warming up") TSLog.d(TAG, "Ignoring ${warning.WarnableCode} because warming up")
} }
} }
val warningsToDrop = warningsBeforeAdd.minus(addedWarnings) val warningsToDrop = warningsBeforeAdd.minus(addedWarnings)
if (warningsToDrop.isNotEmpty()) { if (warningsToDrop.isNotEmpty()) {
Log.d(TAG, "Dropping health warnings with codes $warningsToDrop") TSLog.d(TAG, "Dropping health warnings with codes $warningsToDrop")
this.removeNotifications(warningsToDrop) this.removeNotifications(warningsToDrop)
} }
currentWarnings.set(this.currentWarnings.value.subtract(warningsToDrop)) currentWarnings.set(this.currentWarnings.value.subtract(warningsToDrop))
@ -113,7 +113,7 @@ class HealthNotifier(
} }
private fun sendNotification(title: String, text: String, code: String) { private fun sendNotification(title: String, text: String, code: String) {
Log.d(TAG, "Sending notification for $code") TSLog.d(TAG, "Sending notification for $code")
val notification = val notification =
NotificationCompat.Builder(App.get().applicationContext, HEALTH_CHANNEL_ID) NotificationCompat.Builder(App.get().applicationContext, HEALTH_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
@ -125,14 +125,14 @@ class HealthNotifier(
if (ActivityCompat.checkSelfPermission( if (ActivityCompat.checkSelfPermission(
App.get().applicationContext, Manifest.permission.POST_NOTIFICATIONS) != App.get().applicationContext, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED) { PackageManager.PERMISSION_GRANTED) {
Log.d(TAG, "Notification permission not granted") TSLog.d(TAG, "Notification permission not granted")
return return
} }
notificationManager.notify(code.hashCode(), notification) notificationManager.notify(code.hashCode(), notification)
} }
private fun removeNotifications(warnings: Set<UnhealthyState>) { private fun removeNotifications(warnings: Set<UnhealthyState>) {
Log.d(TAG, "Removing notifications for $warnings") TSLog.d(TAG, "Removing notifications for $warnings")
for (warning in warnings) { for (warning in warnings) {
notificationManager.cancel(warning.WarnableCode.hashCode()) notificationManager.cancel(warning.WarnableCode.hashCode())
} }

@ -19,6 +19,7 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.decodeFromStream
import com.tailscale.ipn.util.TSLog
// Notifier is a wrapper around the IPN Bus notifier. It provides a way to watch // Notifier is a wrapper around the IPN Bus notifier. It provides a way to watch
// for changes in various parts of the Tailscale engine. You will typically only use // for changes in various parts of the Tailscale engine. You will typically only use
@ -59,7 +60,7 @@ object Notifier {
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
fun start(scope: CoroutineScope) { fun start(scope: CoroutineScope) {
Log.d(TAG, "Starting") TSLog.d(TAG, "Starting Notifier")
if (!::app.isInitialized) { if (!::app.isInitialized) {
App.get() App.get()
} }
@ -89,7 +90,7 @@ object Notifier {
} }
fun stop() { fun stop() {
Log.d(TAG, "Stopping") TSLog.d(TAG, "Stopping Notifier")
manager?.let { manager?.let {
it.stop() it.stop()
manager = null manager = null

@ -3,8 +3,8 @@
package com.tailscale.ipn.ui.util package com.tailscale.ipn.ui.util
import android.util.Log
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.util.TSLog
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@ -108,12 +108,12 @@ object TimeUtil {
'm' -> durationFragment * 60.0 'm' -> durationFragment * 60.0
's' -> durationFragment 's' -> durationFragment
else -> { else -> {
Log.e(TAG, "Invalid duration string: $goDuration") TSLog.e(TAG, "Invalid duration string: $goDuration")
return null return null
} }
} }
} catch (e: NumberFormatException) { } catch (e: NumberFormatException) {
Log.e(TAG, "Invalid duration string: $goDuration") TSLog.e(TAG, "Invalid duration string: $goDuration")
return null return null
} }
valStr = "" valStr = ""

@ -3,7 +3,6 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import android.util.Log
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -24,6 +23,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.tailscale.ipn.util.TSLog
class DNSSettingsViewModelFactory : ViewModelProvider.Factory { class DNSSettingsViewModelFactory : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@ -43,7 +43,7 @@ class DNSSettingsViewModel : IpnViewModel() {
.combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) } .combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) }
.stateIn(viewModelScope) .stateIn(viewModelScope)
.collect { (netmap, prefs) -> .collect { (netmap, prefs) ->
Log.d("DNSSettingsViewModel", "prefs: CorpDNS=" + prefs?.CorpDNS.toString()) TSLog.d("DNSSettingsViewModel", "prefs: CorpDNS=" + prefs?.CorpDNS.toString())
prefs?.let { prefs?.let {
if (it.CorpDNS) { if (it.CorpDNS) {
enablementState.set(DNSEnablementState.ENABLED) enablementState.set(DNSEnablementState.ENABLED)

@ -3,7 +3,6 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.UninitializedApp import com.tailscale.ipn.UninitializedApp
@ -16,6 +15,7 @@ import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.AdvertisedRoutesHelper import com.tailscale.ipn.ui.util.AdvertisedRoutesHelper
import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -130,7 +130,7 @@ open class IpnViewModel : ViewModel() {
} }
.collect { nodeState -> _nodeState.value = nodeState } .collect { nodeState -> _nodeState.value = nodeState }
} }
Log.d(TAG, "Created") TSLog.d(TAG, "Created")
} }
// VPN Control // VPN Control
@ -153,8 +153,8 @@ open class IpnViewModel : ViewModel() {
val loginAction = { val loginAction = {
Client(viewModelScope).startLoginInteractive { result -> Client(viewModelScope).startLoginInteractive { result ->
result result
.onSuccess { Log.d(TAG, "Login started: $it") } .onSuccess { TSLog.d(TAG, "Login started: $it") }
.onFailure { Log.e(TAG, "Error starting login: ${it.message}") } .onFailure { TSLog.e(TAG, "Error starting login: ${it.message}") }
completionHandler(result) completionHandler(result)
} }
} }
@ -165,7 +165,7 @@ open class IpnViewModel : ViewModel() {
Client(viewModelScope).editPrefs(Ipn.MaskedPrefs().apply { WantRunning = false }) { result -> Client(viewModelScope).editPrefs(Ipn.MaskedPrefs().apply { WantRunning = false }) { result ->
result result
.onSuccess { loginAction() } .onSuccess { loginAction() }
.onFailure { Log.e(TAG, "Error setting wantRunning to false: ${it.message}") } .onFailure { TSLog.e(TAG, "Error setting wantRunning to false: ${it.message}") }
} }
} }
@ -182,7 +182,7 @@ open class IpnViewModel : ViewModel() {
if (mdmControlURL != null) { if (mdmControlURL != null) {
prefs = prefs ?: Ipn.MaskedPrefs() prefs = prefs ?: Ipn.MaskedPrefs()
prefs.ControlURL = mdmControlURL prefs.ControlURL = mdmControlURL
Log.d(TAG, "Overriding control URL with MDM value: $mdmControlURL") TSLog.d(TAG, "Overriding control URL with MDM value: $mdmControlURL")
} }
prefs?.let { prefs?.let {
@ -210,8 +210,8 @@ open class IpnViewModel : ViewModel() {
fun logout(completionHandler: (Result<String>) -> Unit = {}) { fun logout(completionHandler: (Result<String>) -> Unit = {}) {
Client(viewModelScope).logout { result -> Client(viewModelScope).logout { result ->
result result
.onSuccess { Log.d(TAG, "Logout started: $it") } .onSuccess { TSLog.d(TAG, "Logout started: $it") }
.onFailure { Log.e(TAG, "Error starting logout: ${it.message}") } .onFailure { TSLog.e(TAG, "Error starting logout: ${it.message}") }
completionHandler(result) completionHandler(result)
} }
} }
@ -221,14 +221,14 @@ open class IpnViewModel : ViewModel() {
private fun loadUserProfiles() { private fun loadUserProfiles() {
Client(viewModelScope).profiles { result -> Client(viewModelScope).profiles { result ->
result.onSuccess(loginProfiles::set).onFailure { result.onSuccess(loginProfiles::set).onFailure {
Log.e(TAG, "Error loading profiles: ${it.message}") TSLog.e(TAG, "Error loading profiles: ${it.message}")
} }
} }
Client(viewModelScope).currentProfile { result -> Client(viewModelScope).currentProfile { result ->
result result
.onSuccess { loggedInUser.set(if (it.isEmpty()) null else it) } .onSuccess { loggedInUser.set(if (it.isEmpty()) null else it) }
.onFailure { Log.e(TAG, "Error loading current profile: ${it.message}") } .onFailure { TSLog.e(TAG, "Error loading current profile: ${it.message}") }
} }
} }
@ -242,7 +242,7 @@ open class IpnViewModel : ViewModel() {
Client(viewModelScope).editPrefs(Ipn.MaskedPrefs().apply { WantRunning = false }) { result -> Client(viewModelScope).editPrefs(Ipn.MaskedPrefs().apply { WantRunning = false }) { result ->
result result
.onSuccess { switchProfile() } .onSuccess { switchProfile() }
.onFailure { Log.e(TAG, "Error setting wantRunning to false: ${it.message}") } .onFailure { TSLog.e(TAG, "Error setting wantRunning to false: ${it.message}") }
} }
} }
@ -277,7 +277,7 @@ open class IpnViewModel : ViewModel() {
Client(viewModelScope).setUseExitNode(true) { LoadingIndicator.stop() } Client(viewModelScope).setUseExitNode(true) { LoadingIndicator.stop() }
} else { } else {
// This should not be possible. In this state the button is hidden // This should not be possible. In this state the button is hidden
Log.e(TAG, "No exit node to disable and no prior exit node to enable") TSLog.e(TAG, "No exit node to disable and no prior exit node to enable")
} }
} }
@ -292,7 +292,7 @@ open class IpnViewModel : ViewModel() {
} }
Client(viewModelScope).editPrefs(newPrefs) { result -> Client(viewModelScope).editPrefs(newPrefs) { result ->
LoadingIndicator.stop() LoadingIndicator.stop()
Log.d("RunExitNodeViewModel", "Edited prefs: $result") TSLog.d("RunExitNodeViewModel", "Edited prefs: $result")
} }
} }
} }

@ -5,7 +5,6 @@ package com.tailscale.ipn.ui.viewModel
import android.content.Context import android.content.Context
import android.os.CountDownTimer import android.os.CountDownTimer
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -16,6 +15,7 @@ import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.util.ConnectionMode import com.tailscale.ipn.ui.util.ConnectionMode
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.view.roundedString import com.tailscale.ipn.ui.view.roundedString
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -39,7 +39,7 @@ class PingViewModel : ViewModel() {
} }
override fun onFinish() { override fun onFinish() {
Log.d(TAG, "Ping timer terminated") TSLog.d(TAG, "Ping timer terminated")
} }
} }
@ -94,7 +94,7 @@ class PingViewModel : ViewModel() {
response.onFailure { error -> response.onFailure { error ->
val context: Context = App.get().applicationContext val context: Context = App.get().applicationContext
val stringError = error.toString() val stringError = error.toString()
Log.d(TAG, "Ping request failed: $stringError") TSLog.d(TAG, "Ping request failed: $stringError")
if (stringError.contains("timeout")) { if (stringError.contains("timeout")) {
this.errorMessage.set( this.errorMessage.set(
context.getString( context.getString(
@ -125,7 +125,7 @@ class PingViewModel : ViewModel() {
} }
} }
} }
statusResult.onFailure { Log.d(TAG, "Failed to fetch status: $it") } statusResult.onFailure { TSLog.d(TAG, "Failed to fetch status: $it") }
} }
} }
} }

@ -4,7 +4,6 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import android.content.Context import android.content.Context
import android.util.Log
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -26,6 +25,7 @@ import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.view.ActivityIndicator import com.tailscale.ipn.ui.view.ActivityIndicator
import com.tailscale.ipn.ui.view.CheckedIndicator import com.tailscale.ipn.ui.view.CheckedIndicator
import com.tailscale.ipn.ui.view.ErrorDialogType import com.tailscale.ipn.ui.view.ErrorDialogType
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -144,7 +144,7 @@ class TaildropViewModel(
allSharablePeers.filter { !(it.Online ?: false) }.sortedBy { it.Name } allSharablePeers.filter { !(it.Online ?: false) }.sortedBy { it.Name }
myPeers.set(onlinePeers + offlinePeers) myPeers.set(onlinePeers + offlinePeers)
} }
.onFailure { Log.e(TAG, "Error loading targets: ${it.message}") } .onFailure { TSLog.e(TAG, "Error loading targets: ${it.message}") }
} }
} }

@ -26,10 +26,13 @@ class VpnViewModelFactory(private val application: Application) : ViewModelProvi
// application scoped because Tailscale might be toggled on and off outside of the activity // application scoped because Tailscale might be toggled on and off outside of the activity
// lifecycle. // lifecycle.
class VpnViewModel(application: Application) : AndroidViewModel(application) { class VpnViewModel(application: Application) : AndroidViewModel(application) {
// Whether the VPN is prepared. This is set to true if the VPN application is already prepared, or if the user has previously consented to the VPN application. This is used to determine whether a VPN permission launcher needs to be shown. // Whether the VPN is prepared. This is set to true if the VPN application is already prepared, or
// if the user has previously consented to the VPN application. This is used to determine whether
// a VPN permission launcher needs to be shown.
val _vpnPrepared = MutableStateFlow(false) val _vpnPrepared = MutableStateFlow(false)
val vpnPrepared: StateFlow<Boolean> = _vpnPrepared val vpnPrepared: StateFlow<Boolean> = _vpnPrepared
// Whether a VPN interface has been established. This is set by net.updateTUN upon VpnServiceBuilder.establish, and consumed by UI to reflect VPN state. // Whether a VPN interface has been established. This is set by net.updateTUN upon
// VpnServiceBuilder.establish, and consumed by UI to reflect VPN state.
val _vpnActive = MutableStateFlow(false) val _vpnActive = MutableStateFlow(false)
val vpnActive: StateFlow<Boolean> = _vpnActive val vpnActive: StateFlow<Boolean> = _vpnActive
val TAG = "VpnViewModel" val TAG = "VpnViewModel"

@ -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))
}
}
}

@ -1,60 +1,107 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package com.tailcale.ipn.ui.util package com.tailcale.ipn.ui.util
import com.tailscale.ipn.ui.util.TimeUtil 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.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test import org.junit.Test
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.doNothing
import org.mockito.Mockito.mock
import java.time.Duration import java.time.Duration
class TimeUtilTest { class TimeUtilTest {
@Test
fun durationInvalidMsUnits() { private lateinit var libtailscaleWrapperMock: LibtailscaleWrapper
val input = "5s10ms" private lateinit var originalWrapper: LibtailscaleWrapper
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
} @Before
fun setUp() {
@Test libtailscaleWrapperMock = mock(LibtailscaleWrapper::class.java)
fun durationInvalidUsUnits() { doNothing().`when`(libtailscaleWrapperMock).sendLog(anyString(), anyString())
val input = "5s10us"
val actual = TimeUtil.duration(input)
assertNull("Should return null", actual) // Store the original wrapper so we can reset it later
} originalWrapper = TSLog.libtailscaleWrapper
// Inject mock into TSLog
@Test TSLog.libtailscaleWrapper = libtailscaleWrapperMock
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) @After
val expected = expectedSeconds.map { Duration.ofSeconds(it.toLong()) } fun tearDown() {
val actual = input.map { TimeUtil.duration(it) } // Reset TSLog after each test to avoid side effects
assertEquals("Incorrect conversion", expected, actual) TSLog.libtailscaleWrapper = originalWrapper
} }
@Test
fun testBadDurationString() { @Test
val input = "1..0y1.0w1.0d1.0h1.0m1.0s" fun durationInvalidMsUnits() {
val actual = TimeUtil.duration(input) val input = "5s10ms"
assertNull("Should return null", actual) val actual = TimeUtil.duration(input)
} assertNull("Should return null", actual)
}
@Test
fun testBadDInputString() {
val input = "1.0yy1.0w1.0d1.0h1.0m1.0s" @Test
val actual = TimeUtil.duration(input) fun durationInvalidUsUnits() {
assertNull("Should return null", actual) val input = "5s10us"
} val actual = TimeUtil.duration(input)
assertNull("Should return null", actual)
@Test }
fun testIgnoreFractionalSeconds() {
val input = "10.9s"
val expectedSeconds = 10 @Test
val expected = Duration.ofSeconds(expectedSeconds.toLong()) fun durationTestHappyPath() {
val actual = TimeUtil.duration(input) val input = arrayOf("1.0y1.0w1.0d1.0h1.0m1.0s", "1s", "1m", "1h", "1d", "1w", "1y")
assertEquals("Should return $expectedSeconds seconds", expected, actual) 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)
}
} }

@ -20,6 +20,9 @@ var (
// onDNSConfigChanged is notified when the network changes and the DNS config needs to be updated. It receives the updated interface name. // onDNSConfigChanged is notified when the network changes and the DNS config needs to be updated. It receives the updated interface name.
onDNSConfigChanged = make(chan string, 1) onDNSConfigChanged = make(chan string, 1)
// onLog receives Android logs to be sent to the logger
onLog = make(chan string, 10)
) )
// ifname is the interface name retrieved from LinkProperties on network change. An empty string is used if there is no network available. // ifname is the interface name retrieved from LinkProperties on network change. An empty string is used if there is no network available.

@ -4,6 +4,8 @@
package libtailscale package libtailscale
import ( import (
"log"
_ "golang.org/x/mobile/bind" _ "golang.org/x/mobile/bind"
) )
@ -168,3 +170,13 @@ func RequestVPN(service IPNService) {
func ServiceDisconnect(service IPNService) { func ServiceDisconnect(service IPNService) {
onDisconnect <- service onDisconnect <- service
} }
func SendLog(logstr []byte) {
select {
case onLog <- string(logstr):
// Successfully sent log
default:
// Channel is full, log not sent
log.Printf("Log %v not sent", logstr) // missing argument in original code
}
}

@ -134,4 +134,13 @@ func (b *backend) setupLogs(logDir string, logID logid.PrivateID, logf logger.Lo
if filchErr != nil { if filchErr != nil {
log.Printf("SetupLogs: filch setup failed: %v", filchErr) log.Printf("SetupLogs: filch setup failed: %v", filchErr)
} }
go func() {
for {
select {
case logstr := <-onLog:
b.logger.Logf(logstr)
}
}
}()
} }

Loading…
Cancel
Save